From 2091b35cef3ec4545b727990a98e608f325c89b3 Mon Sep 17 00:00:00 2001 From: Hippari Date: Sun, 8 Dec 2024 17:03:44 +0100 Subject: [PATCH 01/14] Enforce a readable line length for Read More (#3442) --- .../_single-module/review-card/review-card.component.html | 2 +- .../src/app/chapter-detail/chapter-detail.component.html | 2 +- .../collection-detail/collection-detail.component.html | 2 +- .../reading-list-detail/reading-list-detail.component.html | 4 ++-- .../_components/series-detail/series-detail.component.html | 2 +- UI/Web/src/app/shared/read-more/read-more.component.scss | 7 +++++++ UI/Web/src/app/volume-detail/volume-detail.component.html | 2 +- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index b30f67901..99a788471 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -16,7 +16,7 @@ {{review.isExternal ? t('external-review') : t('local-review')}} -->

- +

diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index 38baf9a61..b342849aa 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -85,7 +85,7 @@
- +
diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index bfe7aaba7..4fc6fdbad 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -41,7 +41,7 @@
@if (summary.length > 0) {
- +
@if (collectionTag.source !== ScrobbleProvider.Kavita) { diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 90f8f1de8..55ffee870 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -124,8 +124,8 @@ -
- +
+
@if (characters$ | async; as characters) { 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 950b5a8eb..3895f84f3 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 @@ -109,7 +109,7 @@
- +
diff --git a/UI/Web/src/app/shared/read-more/read-more.component.scss b/UI/Web/src/app/shared/read-more/read-more.component.scss index fe87f8f84..1680ce481 100644 --- a/UI/Web/src/app/shared/read-more/read-more.component.scss +++ b/UI/Web/src/app/shared/read-more/read-more.component.scss @@ -1,3 +1,5 @@ +@import "../../../theme/variables"; + .blur-text { color: transparent; text-shadow: 0 0 5px var(--body-text-color); @@ -8,5 +10,10 @@ div { word-break: break-word; + max-width: 75ch; + + @media (max-width: $grid-breakpoints-sm) { + max-width: 50ch; + } } } diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index d1f64e20f..75f196e1e 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -89,7 +89,7 @@
- +
From e7fb2017eaf46a0e5719a5c3c890e0247d317c9d Mon Sep 17 00:00:00 2001 From: Hippari Date: Sun, 8 Dec 2024 17:04:13 +0100 Subject: [PATCH 02/14] Rework sidebar to avoid jump when collapsing (#3444) --- .../card-actionables.component.html | 4 +-- UI/Web/src/app/app.component.scss | 2 +- UI/Web/src/theme/components/_sidenav.scss | 31 ++++++++++--------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html index 9e1b96ac9..2543a7106 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html @@ -1,14 +1,14 @@ @if (actions.length > 0) { @if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) { - } @else {
- -
- - +
+ + + + +
diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index b460e82f8..4de2e5205 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -50,7 +50,6 @@ export class EditUserComponent implements OnInit { this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email])); this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); - this.userForm.get('email')?.disable(); this.selectedRestriction = this.member.ageRestriction; this.cdRef.markForCheck(); } From 634b1653182a22d6cdb46b695628eb27adbd03f8 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 10:32:18 -0600 Subject: [PATCH 05/14] Ensure that if a user doesn't have access to a person, person detail page redirects and informs them. --- UI/Web/src/app/_services/person.service.ts | 2 +- .../src/app/person-detail/person-detail.component.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index f37ba2d65..676aa6e71 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -26,7 +26,7 @@ export class PersonService { } get(name: string) { - return this.httpClient.get(this.baseUrl + `person?name=${name}`); + return this.httpClient.get(this.baseUrl + `person?name=${name}`); } getRolesForPerson(personId: number) { diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts index 3ebe918f1..63874b51d 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.ts +++ b/UI/Web/src/app/person-detail/person-detail.component.ts @@ -39,6 +39,7 @@ import {translate, TranslocoDirective} from "@jsverse/transloco"; import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component"; import {ThemeService} from "../_services/theme.service"; import {DefaultModalOptions} from "../_models/default-modal-options"; +import {ToastrService} from "ngx-toastr"; @Component({ selector: 'app-person-detail', @@ -74,6 +75,7 @@ export class PersonDetailComponent { protected readonly imageService = inject(ImageService); protected readonly accountService = inject(AccountService); private readonly themeService = inject(ThemeService); + private readonly toastr = inject(ToastrService); protected readonly TagBadgeCursor = TagBadgeCursor; @@ -104,7 +106,14 @@ export class PersonDetailComponent { this.personName = personName; return this.personService.get(personName); }), - tap(person => { + tap((person) => { + + if (person == null) { + this.toastr.error(translate('toasts.unauthorized-1')); + this.router.navigateByUrl('/home'); + return; + } + this.person = person; this.personSubject.next(person); // emit the person data for subscribers this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor); From 4f13ae0f0b209fb03f73be4edd89b7987f1450aa Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 10:53:46 -0600 Subject: [PATCH 06/14] Fixed a bug where the age restriction logic for a person detail page wasn't allowing the person to show if the user had access to SOME of their works. --- API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index b8def7377..000fea68f 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -105,8 +105,10 @@ public static class RestrictByAgeExtensions sm.SeriesMetadata.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadataPeople.All(sm => - sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating != AgeRating.Unknown) && + c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating && cp.Chapter.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) From cfb7ef54f62d98719951eff9780dcae2068cfc89 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 10:55:28 -0600 Subject: [PATCH 07/14] Fixed pointing to the wrong locale string --- .../app/admin/manage-settings/manage-settings.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index 3f5c71d61..e4468ccc5 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -290,7 +290,7 @@
@if (settingsForm.get('onDeckUpdateDays'); as formControl) { - + {{formControl.value}} From 0a7bc4b3f6011fa1d6925bbf22b6ee14b0c3bef3 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 11:09:43 -0600 Subject: [PATCH 08/14] Fixed a bug with double clicking manga reader on mobile no longer triggering bookmarking --- .../app/_directives/dbl-click.directive.ts | 27 ++ .../age-rating-image.component.html | 0 .../age-rating-image.component.scss | 0 .../age-rating-image.component.ts | 0 .../publisher-flipper.component.html | 0 .../publisher-flipper.component.scss | 0 .../publisher-flipper.component.ts | 0 .../related-tab/related-tab.component.html | 0 .../related-tab/related-tab.component.scss | 0 .../related-tab/related-tab.component.ts | 0 .../chapter-detail.component.ts | 2 +- .../manga-reader/manga-reader.component.html | 407 +++++++++--------- .../manga-reader/manga-reader.component.ts | 9 +- .../metadata-detail-row.component.ts | 4 +- .../series-detail/series-detail.component.ts | 2 +- .../volume-detail/volume-detail.component.ts | 2 +- 16 files changed, 250 insertions(+), 203 deletions(-) create mode 100644 UI/Web/src/app/_directives/dbl-click.directive.ts rename UI/Web/src/app/{_single-modules => _single-module}/age-rating-image/age-rating-image.component.html (100%) rename UI/Web/src/app/{_single-modules => _single-module}/age-rating-image/age-rating-image.component.scss (100%) rename UI/Web/src/app/{_single-modules => _single-module}/age-rating-image/age-rating-image.component.ts (100%) rename UI/Web/src/app/{_single-modules => _single-module}/publisher-flipper/publisher-flipper.component.html (100%) rename UI/Web/src/app/{_single-modules => _single-module}/publisher-flipper/publisher-flipper.component.scss (100%) rename UI/Web/src/app/{_single-modules => _single-module}/publisher-flipper/publisher-flipper.component.ts (100%) rename UI/Web/src/app/{_single-modules => _single-module}/related-tab/related-tab.component.html (100%) rename UI/Web/src/app/{_single-modules => _single-module}/related-tab/related-tab.component.scss (100%) rename UI/Web/src/app/{_single-modules => _single-module}/related-tab/related-tab.component.ts (100%) diff --git a/UI/Web/src/app/_directives/dbl-click.directive.ts b/UI/Web/src/app/_directives/dbl-click.directive.ts new file mode 100644 index 000000000..98fabf843 --- /dev/null +++ b/UI/Web/src/app/_directives/dbl-click.directive.ts @@ -0,0 +1,27 @@ +import {Directive, EventEmitter, HostListener, Output} from '@angular/core'; + +@Directive({ + selector: '[appDblClick]', + standalone: true +}) +export class DblClickDirective { + + @Output() doubleClick = new EventEmitter(); + + private lastTapTime = 0; + private tapTimeout = 300; // Time threshold for a double tap (in milliseconds) + + @HostListener('click', ['$event']) + handleClick(event: Event): void { + event.stopPropagation(); + event.preventDefault(); + + const currentTime = new Date().getTime(); + if (currentTime - this.lastTapTime < this.tapTimeout) { + // Detected a double click/tap + this.doubleClick.emit(event); + } + this.lastTapTime = currentTime; + } + +} diff --git a/UI/Web/src/app/_single-modules/age-rating-image/age-rating-image.component.html b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.html similarity index 100% rename from UI/Web/src/app/_single-modules/age-rating-image/age-rating-image.component.html rename to UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.html diff --git a/UI/Web/src/app/_single-modules/age-rating-image/age-rating-image.component.scss b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.scss similarity index 100% rename from UI/Web/src/app/_single-modules/age-rating-image/age-rating-image.component.scss rename to UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.scss diff --git a/UI/Web/src/app/_single-modules/age-rating-image/age-rating-image.component.ts b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts similarity index 100% rename from UI/Web/src/app/_single-modules/age-rating-image/age-rating-image.component.ts rename to UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts diff --git a/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.html b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html similarity index 100% rename from UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.html rename to UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html diff --git a/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.scss b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss similarity index 100% rename from UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.scss rename to UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss diff --git a/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.ts b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts similarity index 100% rename from UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.ts rename to UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts diff --git a/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html b/UI/Web/src/app/_single-module/related-tab/related-tab.component.html similarity index 100% rename from UI/Web/src/app/_single-modules/related-tab/related-tab.component.html rename to UI/Web/src/app/_single-module/related-tab/related-tab.component.html diff --git a/UI/Web/src/app/_single-modules/related-tab/related-tab.component.scss b/UI/Web/src/app/_single-module/related-tab/related-tab.component.scss similarity index 100% rename from UI/Web/src/app/_single-modules/related-tab/related-tab.component.scss rename to UI/Web/src/app/_single-module/related-tab/related-tab.component.scss diff --git a/UI/Web/src/app/_single-modules/related-tab/related-tab.component.ts b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts similarity index 100% rename from UI/Web/src/app/_single-modules/related-tab/related-tab.component.ts rename to UI/Web/src/app/_single-module/related-tab/related-tab.component.ts diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index 72eb9d975..eea06f2fb 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -48,7 +48,7 @@ import {FilterUtilitiesService} from "../shared/_services/filter-utilities.servi import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ReadingList} from "../_models/reading-list"; import {ReadingListService} from "../_services/reading-list.service"; -import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component"; +import {RelatedTabComponent} from "../_single-module/related-tab/related-tab.component"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; import { MetadataDetailRowComponent diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index 21b711822..8fe1f6833 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -3,47 +3,58 @@ @if(debugMode) {
@for(img of cachedImages; track img.src) { - + @if (this.readerService.imageUrlToPageNum(img.src); as imageNum) { {{this.readerService.imageUrlToPageNum(img.src)}} - + } }
} -
-
- + @if (menuOpen) { +
+
+ -
-
{{title}} ({{t('incognito-title')}})
-
- {{subtitle}} {{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }} +
+
{{title}} + @if (incognitoMode) { + ({{t('incognito-title')}}) + } +
+
+ {{subtitle}} + @if (totalSeriesPages > 0) { + {{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }} + } +
+
+ +
+ + @if (!bookmarkMode && hasBookmarkRights) { + + }
- -
- - @if (!bookmarkMode && hasBookmarkRights) { - - } -
-
+ } + +
- + @if (readerMode !== ReaderMode.Webtoon) {
-
- -
+ @if (showClickOverlay) { +
+ +
+ }
-
- -
+ @if (showClickOverlay) { +
+ +
+ }
-
+
- - - - -
- - -
-
- - + } @else { + @if (!isLoading && !inSetup) { +
+ + +
+ } + }
-
- -
- -
- - -
- -
- -
- + @if (menuOpen) { +
+ @if (pageOptions !== undefined && pageOptions.ceil !== undefined) { +
+ +
+ + + @if (pageOptions.ceil > 0) { +
+ +
+ } @else { +
+ +
+ } + +
- - - +
+ } +
+
+ +
+
+ +
+
+ +
+
+ +
-
-
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-   -
-
+ @if (settingsOpen && generalSettingsForm) { +
+ +
+
+   +
+
+
+ +
+ +
+   + +
- -
-
-   - -
-
- -
-
-   - - -
+
+
+   + + +
-
-
- -
+
+
+ +
- + -
-
- -
+
+
+ +
- + -
-
-
- -
-
-
-
-
- - -
+
+ + +
-
+
+
+
+
+ + +
+
+
-
-
-
- - +
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
-
-
-
-
-
- - -
+
+
+ + {{generalSettingsForm.get('darkness')?.value + '%'}} + +
+ +
+ + +
+ + +
+
-
+
-
-
- - {{generalSettingsForm.get('darkness')?.value + '%'}} - -
- -
- - -
- - -
- -
-
- + }
-
+ } +
diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 0095c5052..f796012ea 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -13,7 +13,7 @@ import { OnInit, ViewChild } from '@angular/core'; -import {AsyncPipe, DOCUMENT, NgClass, NgFor, NgIf, NgStyle, NgSwitch, NgSwitchCase, PercentPipe} from '@angular/common'; +import {AsyncPipe, DOCUMENT, NgClass, NgFor, NgStyle, NgSwitch, NgSwitchCase, PercentPipe} from '@angular/common'; import {ActivatedRoute, Router} from '@angular/router'; import { BehaviorSubject, @@ -70,6 +70,7 @@ import {SwipeDirective} from '../../../ng-swipe/ng-swipe.directive'; import {LoadingComponent} from '../../../shared/loading/loading.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {shareReplay} from "rxjs/operators"; +import {DblClickDirective} from "../../../_directives/dbl-click.directive"; const PREFETCH_PAGES = 10; @@ -123,10 +124,10 @@ enum KeyDirection { ]) ], standalone: true, - imports: [NgStyle, NgIf, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent, + imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent, DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent, NgxSliderModule, ReactiveFormsModule, NgFor, NgSwitch, NgSwitchCase, FittingIconPipe, ReaderModeIconPipe, - FullscreenIconPipe, TranslocoDirective, NgbProgressbar, PercentPipe, NgClass, AsyncPipe] + FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective] }) export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { @@ -1656,7 +1657,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Bookmarks the current page for the chapter */ - bookmarkPage(event: MouseEvent | undefined = undefined) { + bookmarkPage(event: Event | undefined = undefined) { if (event) { event.stopPropagation(); event.preventDefault(); diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts index 952916d08..0647ae192 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts @@ -1,5 +1,5 @@ import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; -import {AgeRatingImageComponent} from "../../../_single-modules/age-rating-image/age-rating-image.component"; +import {AgeRatingImageComponent} from "../../../_single-module/age-rating-image/age-rating-image.component"; import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe"; import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe"; import {ReadTimePipe} from "../../../_pipes/read-time.pipe"; @@ -17,7 +17,7 @@ import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {MangaFormat} from "../../../_models/manga-format"; import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component"; -import {PublisherFlipperComponent} from "../../../_single-modules/publisher-flipper/publisher-flipper.component"; +import {PublisherFlipperComponent} from "../../../_single-module/publisher-flipper/publisher-flipper.component"; @Component({ selector: 'app-metadata-detail-row', 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 c3d92fc47..250fbc0a6 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 @@ -115,7 +115,7 @@ import {DownloadButtonComponent} from "../download-button/download-button.compon import {hasAnyCast} from "../../../_models/common/i-has-cast"; import {EditVolumeModalComponent} from "../../../_single-module/edit-volume-modal/edit-volume-modal.component"; import {CoverUpdateEvent} from "../../../_models/events/cover-update-event"; -import {RelatedSeriesPair, RelatedTabComponent} from "../../../_single-modules/related-tab/related-tab.component"; +import {RelatedSeriesPair, RelatedTabComponent} from "../../../_single-module/related-tab/related-tab.component"; import {CollectionTagService} from "../../../_services/collection-tag.service"; import {UserCollection} from "../../../_models/collection-tag"; import {CoverImageComponent} from "../../../_single-module/cover-image/cover-image.component"; diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 0797fd7de..8baecad29 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -59,7 +59,7 @@ import { } from "../_single-module/edit-volume-modal/edit-volume-modal.component"; import {Genre} from "../_models/metadata/genre"; import {Tag} from "../_models/tag"; -import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component"; +import {RelatedTabComponent} from "../_single-module/related-tab/related-tab.component"; import {ReadingList} from "../_models/reading-list"; import {ReadingListService} from "../_services/reading-list.service"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; From fffc21a53d8a4250e6ca3903d720f054b9c01806 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 11:39:34 -0600 Subject: [PATCH 09/14] Fixed a bug where LibraryType and MaxChaptersInASeries wasn't hooked up correctly --- API/Services/Tasks/StatsService.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 33ad72719..1fd25c2eb 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -14,6 +14,7 @@ using API.DTOs.Stats.V3; using API.Entities; using API.Entities.Enums; using API.Services.Plus; +using API.Services.Tasks.Scanner.Parser; using Flurl.Http; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; @@ -231,11 +232,12 @@ public class StatsService : IStatsService { // If first time flow, just return 0 if (!await _context.Chapter.AnyAsync()) return 0; + return await _context.Series .AsNoTracking() .AsSplitQuery() .MaxAsync(s => s.Volumes! - .Where(v => v.MinNumber == 0) + .Where(v => v.MinNumber == Parser.LooseLeafVolumeNumber) .SelectMany(v => v.Chapters!) .Count()); } @@ -314,6 +316,7 @@ public class StatsService : IStatsService libDto.UsingFolderWatching = library.FolderWatching; libDto.CreateCollectionsFromMetadata = library.ManageCollections; libDto.CreateReadingListsFromMetadata = library.ManageReadingLists; + libDto.LibraryType = library.Type; dto.Libraries.Add(libDto); } From e41eddb7a77543679ce1ca301ece4751d219a967 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 11:42:24 -0600 Subject: [PATCH 10/14] A bit of memory pressure on stats --- API/Services/Tasks/StatsService.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 1fd25c2eb..4e6fcfb60 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -264,13 +264,13 @@ public class StatsService : IStatsService dto.MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(); dto.MaxVolumesInASeries = await MaxVolumesInASeries(); dto.MaxChaptersInASeries = await MaxChaptersInASeries(); - dto.TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles(); - dto.TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(); - dto.TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(); - dto.TotalSeries = await _unitOfWork.SeriesRepository.GetCountAsync(); - dto.TotalLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(); - dto.NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(); - dto.NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(); + dto.TotalFiles = await _context.MangaFile.CountAsync(); + dto.TotalGenres = await _context.Genre.CountAsync(); + dto.TotalPeople = await _context.Person.CountAsync(); + dto.TotalSeries = await _context.Series.CountAsync(); + dto.TotalLibraries = await _context.Library.CountAsync(); + dto.NumberOfCollections = await _context.AppUserCollection.CountAsync(); + dto.NumberOfReadingLists = await _context.ReadingList.CountAsync(); try { From 3b5189c83fd4223ed600196a68c01a1649e1393a Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 15:28:57 -0600 Subject: [PATCH 11/14] Fixed up a broken unit test. For People restriction, IncludeUnknowns doesn't really do much. --- API.Tests/Extensions/QueryableExtensionsTests.cs | 8 ++++---- API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index d902ae353..4ea9a5a4b 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -123,14 +123,14 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] - [InlineData(false, 1)] - public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + [InlineData(false, 2)] + public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedPeopleCount) { // Arrange var items = new List { CreatePersonWithSeriesMetadata("Test1", AgeRating.Teen), - CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), + CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), // 2 series on this person, restrict will still allow access CreatePersonWithSeriesMetadata("Test3", AgeRating.X18Plus) }; @@ -144,7 +144,7 @@ public class QueryableExtensionsTests var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(ageRestriction); // Assert - Assert.Equal(expectedCount, filtered.Count()); + Assert.Equal(expectedPeopleCount, filtered.Count()); } private static Person CreatePersonWithSeriesMetadata(string name, params AgeRating[] ageRatings) diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index 000fea68f..ebc233056 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -101,12 +101,13 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadataPeople.All(sm => - sm.SeriesMetadata.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating) || + c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating)); } return queryable.Where(c => - c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating != AgeRating.Unknown) && + c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating != AgeRating.Unknown) || c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating && cp.Chapter.AgeRating != AgeRating.Unknown) ); } From ca50549c1b0721d0dcc4caf8de99ab3a5e4b234e Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 17:00:51 -0600 Subject: [PATCH 12/14] Added basic playwright config. Next up, trialing login page --- UI/Web/.gitignore | 3 + UI/Web/e2e-tests/example.spec.ts | 18 + UI/Web/package-lock.json | 75 +++- UI/Web/package.json | 3 +- UI/Web/playwright.config.ts | 79 ++++ UI/Web/tests-examples/demo-todo-app.spec.ts | 437 ++++++++++++++++++++ 6 files changed, 602 insertions(+), 13 deletions(-) create mode 100644 UI/Web/e2e-tests/example.spec.ts create mode 100644 UI/Web/playwright.config.ts create mode 100644 UI/Web/tests-examples/demo-todo-app.spec.ts diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore index 73b400165..92096907b 100644 --- a/UI/Web/.gitignore +++ b/UI/Web/.gitignore @@ -2,3 +2,6 @@ node_modules/ test-results/ playwright-report/ i18n-cache-busting.json +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/UI/Web/e2e-tests/example.spec.ts b/UI/Web/e2e-tests/example.spec.ts new file mode 100644 index 000000000..54a906a4e --- /dev/null +++ b/UI/Web/e2e-tests/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 28237dbc0..35486bc5c 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -60,6 +60,7 @@ "@angular/build": "^18.2.10", "@angular/cli": "^18.2.10", "@angular/compiler-cli": "^18.2.9", + "@playwright/test": "^1.49.0", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", "@types/luxon": "^3.4.0", @@ -464,7 +465,6 @@ "version": "18.2.9", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz", "integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==", - "dev": true, "dependencies": { "@babel/core": "7.25.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -492,7 +492,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", - "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -507,7 +506,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", - "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -2461,6 +2459,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0.tgz", + "integrity": "sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==", + "dev": true, + "dependencies": { + "playwright": "1.49.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.25", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", @@ -4010,8 +4023,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -4518,7 +4530,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -4528,7 +4539,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7322,6 +7332,50 @@ "nice-napi": "^1.0.2" } }, + "node_modules/playwright": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz", + "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==", + "dev": true, + "dependencies": { + "playwright-core": "1.49.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz", + "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.41", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", @@ -7470,8 +7524,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -7742,7 +7795,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/sass": { "version": "1.77.6", @@ -7776,7 +7829,6 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -8331,7 +8383,6 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/package.json b/UI/Web/package.json index 07261704c..c6913319f 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -12,7 +12,7 @@ "prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "lint": "ng lint", - "e2e": "ng e2e" + "e2e": "npx playwright test --ui" }, "private": true, "dependencies": { @@ -68,6 +68,7 @@ "@angular/build": "^18.2.10", "@angular/cli": "^18.2.10", "@angular/compiler-cli": "^18.2.9", + "@playwright/test": "^1.49.0", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", "@types/luxon": "^3.4.0", diff --git a/UI/Web/playwright.config.ts b/UI/Web/playwright.config.ts new file mode 100644 index 000000000..86297eba9 --- /dev/null +++ b/UI/Web/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e-tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/UI/Web/tests-examples/demo-todo-app.spec.ts b/UI/Web/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 000000000..8641cb5f5 --- /dev/null +++ b/UI/Web/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,437 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +] as const; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); +} From 11e096b69c1c45b99cd5cb803e93c1aef98e1104 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 8 Dec 2024 17:12:16 -0600 Subject: [PATCH 13/14] Set up a basic login flow. Need to figure out how to test a complex flow. --- UI/Web/e2e-tests/pages/login-page.ts | 19 +++++++++++++++ UI/Web/e2e-tests/tests/Login/login.spec.ts | 27 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 UI/Web/e2e-tests/pages/login-page.ts create mode 100644 UI/Web/e2e-tests/tests/Login/login.spec.ts diff --git a/UI/Web/e2e-tests/pages/login-page.ts b/UI/Web/e2e-tests/pages/login-page.ts new file mode 100644 index 000000000..9501081e7 --- /dev/null +++ b/UI/Web/e2e-tests/pages/login-page.ts @@ -0,0 +1,19 @@ +import { Page } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async navigate() { + await this.page.goto('/login'); + } + + async login(username: string, password: string) { + await this.page.fill('input[formControlName="username"]', username); + await this.page.fill('input[formControlName="password"]', password); + await this.page.click('button[type="submit"]'); + } +} diff --git a/UI/Web/e2e-tests/tests/Login/login.spec.ts b/UI/Web/e2e-tests/tests/Login/login.spec.ts new file mode 100644 index 000000000..e7f4fb9fe --- /dev/null +++ b/UI/Web/e2e-tests/tests/Login/login.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from 'e2e-tests/pages/login-page'; + + +const url = 'https://demo.kavitareader.com/'; + +test('has title', async ({ page }) => { + await page.goto(url); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Kavita/); +}); + +test('login functionality', async ({ page }) => { + // Navigate to the login page + await page.goto(url); + + // Verify the page title + await expect(page).toHaveTitle(/Kavita/); + + const loginPage = new LoginPage(page); + await loginPage.navigate(); + await loginPage.login('demouser', 'Demouser64'); + + // Verify successful login by checking for Home on side nav + await expect(page.locator('#null')).toBeVisible(); +}); From 8464835d274e4427d0f0c3c0394ed0df218898f4 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Mon, 9 Dec 2024 07:25:16 -0600 Subject: [PATCH 14/14] Set up a basic login flow. Need to figure out how to test a complex flow. --- UI/Web/e2e-tests/environment.ts | 8 ++++++++ UI/Web/e2e-tests/tests/Login/login.spec.ts | 11 +++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 UI/Web/e2e-tests/environment.ts diff --git a/UI/Web/e2e-tests/environment.ts b/UI/Web/e2e-tests/environment.ts new file mode 100644 index 000000000..0c16ed522 --- /dev/null +++ b/UI/Web/e2e-tests/environment.ts @@ -0,0 +1,8 @@ +/** + * This is public information - create a environment.local.ts file and use admin account there + */ +export const environment = { + baseUrl: 'https://demo.kavitareader.com/', + username: 'demouser', + password: 'Demouser64', +}; diff --git a/UI/Web/e2e-tests/tests/Login/login.spec.ts b/UI/Web/e2e-tests/tests/Login/login.spec.ts index e7f4fb9fe..fc5ce626c 100644 --- a/UI/Web/e2e-tests/tests/Login/login.spec.ts +++ b/UI/Web/e2e-tests/tests/Login/login.spec.ts @@ -1,11 +1,10 @@ import { test, expect } from '@playwright/test'; import { LoginPage } from 'e2e-tests/pages/login-page'; +import {environment} from "../../environment"; -const url = 'https://demo.kavitareader.com/'; - test('has title', async ({ page }) => { - await page.goto(url); + await page.goto(environment.baseUrl); // Expect a title "to contain" a substring. await expect(page).toHaveTitle(/Kavita/); @@ -13,14 +12,14 @@ test('has title', async ({ page }) => { test('login functionality', async ({ page }) => { // Navigate to the login page - await page.goto(url); + await page.goto(environment.baseUrl); // Verify the page title await expect(page).toHaveTitle(/Kavita/); const loginPage = new LoginPage(page); - await loginPage.navigate(); - await loginPage.login('demouser', 'Demouser64'); + //await loginPage.navigate(); + await loginPage.login(environment.username, environment.password); // Verify successful login by checking for Home on side nav await expect(page.locator('#null')).toBeVisible();