Skip to content

Commit 32acb61

Browse files
authored
fix(ui): convert book metadata suggestions to signals (#1583)
1 parent 743303e commit 32acb61

9 files changed

Lines changed: 153 additions & 84 deletions

File tree

frontend/src/app/features/book/service/book.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export class BookService {
136136
});
137137
}
138138

139-
private getBookRecommendationsQueryOptions(bookId: number, limit: number) {
139+
bookRecommendationsQueryOptions(bookId: number, limit: number) {
140140
return queryOptions({
141141
queryKey: bookRecommendationsQueryKey(bookId, limit),
142142
queryFn: () => lastValueFrom(this.http.get<BookRecommendation[]>(`${this.url}/${bookId}/recommendations`, {
@@ -183,7 +183,7 @@ export class BookService {
183183
}
184184

185185
getBookRecommendations(bookId: number, limit: number = 20): Observable<BookRecommendation[]> {
186-
return from(this.queryClient.ensureQueryData(this.getBookRecommendationsQueryOptions(bookId, limit)));
186+
return from(this.queryClient.ensureQueryData(this.bookRecommendationsQueryOptions(bookId, limit)));
187187
}
188188

189189
/*------------------ Book Operations ------------------*/

frontend/src/app/features/metadata/component/book-metadata-center/book-metadata-center.component.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ <h2 class="panel-title">{{ t('title') }}</h2>
2525
<i [class]="'pi pi-book'"></i>
2626
{{ t('tabView') }}
2727
</p-tab>
28-
@if (admin || canEditMetadata) {
28+
@if (admin() || canEditMetadata()) {
2929
<p-tab value="edit">
3030
<i [class]="'pi pi-pencil'"></i>
3131
{{ t('tabEdit') }}
3232
</p-tab>
3333
}
34-
@if (admin || canEditMetadata) {
34+
@if (admin() || canEditMetadata()) {
3535
<p-tab value="match">
3636
<i [class]="'pi pi-search'"></i>
3737
{{ t('tabSearch') }}
@@ -48,17 +48,17 @@ <h2 class="panel-title">{{ t('title') }}</h2>
4848
<p-tabpanel value="view">
4949
<app-metadata-viewer
5050
[book]="book()"
51-
[recommendedBooks]="recommendedBooks">
51+
[recommendedBooks]="recommendedBooks()">
5252
</app-metadata-viewer>
5353
</p-tabpanel>
54-
@if (admin || canEditMetadata) {
54+
@if (admin() || canEditMetadata()) {
5555
<p-tabpanel value="edit">
5656
<app-metadata-editor
5757
[book]="book()">
5858
</app-metadata-editor>
5959
</p-tabpanel>
6060
}
61-
@if (admin || canEditMetadata) {
61+
@if (admin() || canEditMetadata()) {
6262
<p-tabpanel value="match">
6363
<app-metadata-searcher [book]="book()" [isActiveTab]="tab === 'match'"></app-metadata-searcher>
6464
</p-tabpanel>

frontend/src/app/features/metadata/component/book-metadata-center/book-metadata-center.component.ts

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {computed, Component, effect, inject, OnDestroy, OnInit, signal} from '@angular/core';
1+
import {computed, Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
22
import {ActivatedRoute, Router} from '@angular/router';
33
import {UserService} from '../../../settings/user-management/user.service';
44
import {Book, BookRecommendation} from '../../../book/model/book.model';
@@ -15,7 +15,8 @@ import {MetadataViewerComponent} from './metadata-viewer/metadata-viewer.compone
1515
import {MetadataEditorComponent} from './metadata-editor/metadata-editor.component';
1616
import {MetadataSearcherComponent} from './metadata-searcher/metadata-searcher.component';
1717
import {SidecarViewerComponent} from './sidecar-viewer/sidecar-viewer.component';
18-
import {injectQuery} from '@tanstack/angular-query-experimental';
18+
import {injectQuery, queryOptions} from '@tanstack/angular-query-experimental';
19+
import {bookRecommendationsQueryKey} from '../../../book/service/book-query-keys';
1920

2021
@Component({
2122
selector: 'app-book-metadata-center',
@@ -64,33 +65,41 @@ export class BookMetadataCenterComponent implements OnInit, OnDestroy {
6465
return this.bookService.bookDetailQueryOptions(bookId, true);
6566
});
6667
readonly book = computed(() => this.bookQuery.data() ?? null);
67-
private readonly fetchRecommendations = effect(() => {
68+
private readonly recommendationsQuery = injectQuery(() => {
6869
const bookId = this.currentBookId();
69-
if (bookId == null) {
70-
this.recommendedBooks = [];
71-
return;
70+
const settings = this.appSettingsService.appSettings();
71+
72+
if (bookId == null || !(settings?.similarBookRecommendation ?? false)) {
73+
return queryOptions({
74+
queryKey: bookRecommendationsQueryKey(-1, 20),
75+
queryFn: async (): Promise<BookRecommendation[]> => [],
76+
enabled: false,
77+
});
7278
}
7379

74-
this.fetchBookRecommendationsIfNeeded(bookId);
80+
return this.bookService.bookRecommendationsQueryOptions(bookId, 20);
7581
});
76-
77-
recommendedBooks: BookRecommendation[] = [];
82+
readonly recommendedBooks = computed(() =>
83+
[...(this.recommendationsQuery.data() ?? [])].sort(
84+
(a, b) => (b.similarityScore ?? 0) - (a.similarityScore ?? 0)
85+
)
86+
);
7887
private _tab: string = 'view';
79-
canEditMetadata: boolean = false;
80-
admin: boolean = false;
81-
private readonly syncUserPermissionsEffect = effect(() => {
88+
readonly canEditMetadata = computed(() => {
89+
const user = this.userService.currentUser();
90+
return user?.permissions?.canEditMetadata ?? false;
91+
});
92+
readonly admin = computed(() => {
8293
const user = this.userService.currentUser();
83-
if (!user) return;
84-
this.canEditMetadata = user.permissions?.canEditMetadata ?? false;
85-
this.admin = user.permissions?.admin ?? false;
94+
return user?.permissions?.admin ?? false;
8695
});
8796
get isPhysical(): boolean { return this.book()?.isPhysical ?? false; }
88-
isLocalStorage: boolean = true;
97+
readonly isLocalStorage = computed(() => this.appSettingsService.appSettings()?.diskType === 'LOCAL');
8998
get canShowSidecarTab(): boolean {
9099
const settings = this.appSettingsService.appSettings();
91100
const sidecarEnabled = settings?.metadataPersistenceSettings?.sidecarSettings?.enabled ?? false;
92101

93-
return (this.admin || this.canEditMetadata) && !this.isPhysical && this.isLocalStorage && sidecarEnabled;
102+
return (this.admin() || this.canEditMetadata()) && !this.isPhysical && this.isLocalStorage() && sidecarEnabled;
94103
}
95104
private validTabs = ['view', 'edit', 'match', 'sidecar'];
96105

@@ -142,24 +151,6 @@ export class BookMetadataCenterComponent implements OnInit, OnDestroy {
142151
this._tab = this.validTabs.includes(tabParam) ? tabParam : 'view';
143152
});
144153

145-
const currentSettings = this.appSettingsService.appSettings();
146-
if (currentSettings) {
147-
this.isLocalStorage = currentSettings.diskType === 'LOCAL';
148-
}
149-
}
150-
151-
private fetchBookRecommendationsIfNeeded(bookId: number): void {
152-
const settings = this.appSettingsService.appSettings();
153-
if (!settings || !(settings.similarBookRecommendation ?? false)) {
154-
return;
155-
}
156-
this.bookService.getBookRecommendations(bookId)
157-
.pipe(takeUntil(this.destroy$))
158-
.subscribe(recommendations => {
159-
this.recommendedBooks = recommendations.sort(
160-
(a, b) => (b.similarityScore ?? 0) - (a.similarityScore ?? 0)
161-
);
162-
});
163154
}
164155

165156
ngOnDestroy(): void {

frontend/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-tabs/metadata-tabs.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<ng-container *transloco="let t; prefix: 'metadata.tabs'">
22
<p-tabs lazy class="custom-p-tabs" [value]="defaultTabValue" scrollable (valueChange)="onTabChange($event)">
33
<p-tablist>
4-
@if (bookInSeries.length > 1) {
4+
@if (hasSeries) {
55
<p-tab value="series">
66
<i class="pi pi-ethereum"></i> {{ t('moreInSeries') }}
77
</p-tab>

frontend/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-tabs/metadata-tabs.component.spec.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,63 @@
1-
import {describe, expect, it} from 'vitest';
1+
import {ComponentFixture, TestBed} from '@angular/core/testing';
2+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
3+
4+
import {AudiobookService} from '../../../../../readers/audiobook-player/audiobook.service';
5+
import {BookMetadataManageService} from '../../../../../book/service/book-metadata-manage.service';
6+
import {UrlHelperService} from '../../../../../../shared/service/url-helper.service';
7+
import {MetadataTabsComponent} from './metadata-tabs.component';
8+
import {Book} from '../../../../../book/model/book.model';
9+
import {getTranslocoModule} from '../../../../../../core/testing/transloco-testing';
10+
11+
describe('MetadataTabsComponent default tab selection', () => {
12+
beforeEach(() => {
13+
vi.stubGlobal('ResizeObserver', class {
14+
observe = vi.fn();
15+
unobserve = vi.fn();
16+
disconnect = vi.fn();
17+
});
18+
});
19+
20+
afterEach(() => {
21+
vi.unstubAllGlobals();
22+
TestBed.resetTestingModule();
23+
});
24+
25+
function createFixture(hasSeries = false): ComponentFixture<MetadataTabsComponent> {
26+
TestBed.configureTestingModule({
27+
imports: [
28+
MetadataTabsComponent,
29+
getTranslocoModule({translocoConfig: {reRenderOnLangChange: false}}),
30+
],
31+
providers: [
32+
{provide: UrlHelperService, useValue: {}},
33+
{provide: BookMetadataManageService, useValue: {supportsDualCovers: () => false}},
34+
{provide: AudiobookService, useValue: {getAudiobookInfo: () => undefined}},
35+
],
36+
});
37+
38+
const fixture = TestBed.createComponent(MetadataTabsComponent);
39+
fixture.componentInstance.book = {
40+
id: 21,
41+
libraryId: 1,
42+
libraryName: 'Library',
43+
metadata: {bookId: 21, title: 'Test Book', authors: []},
44+
alternativeFormats: [],
45+
supplementaryFiles: [],
46+
} satisfies Book;
47+
fixture.componentInstance.hasSeries = hasSeries;
48+
fixture.componentInstance.bookInSeries = [];
49+
fixture.detectChanges();
50+
51+
return fixture;
52+
}
53+
54+
it('selects the series tab from book metadata before series contents load', () => {
55+
const fixture = createFixture(true);
56+
const component = fixture.componentInstance;
57+
58+
expect(component.defaultTabValue).toBe('series');
59+
});
60+
});
261

362
// TODO(seam): This tab surface coordinates nested review, notes, reading-session, and
463
// audiobook child components, so it needs an integration harness around real tab changes.

frontend/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-tabs/metadata-tabs.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export interface DetachBookFileEvent {
8383
export class MetadataTabsComponent {
8484
@Input() book!: Book;
8585
@Input() bookInSeries: Book[] = [];
86+
@Input() hasSeries = false;
8687
@Input() recommendedBooks: BookRecommendation[] = [];
8788

8889
protected urlHelper = inject(UrlHelperService);
@@ -102,7 +103,7 @@ export class MetadataTabsComponent {
102103
@Output() detachBookFile = new EventEmitter<DetachBookFileEvent>();
103104

104105
get defaultTabValue(): string {
105-
return this.bookInSeries && this.bookInSeries.length > 1 ? 'series' : 'similar';
106+
return this.hasSeries ? 'series' : 'similar';
106107
}
107108

108109
read(bookId: number, reader?: 'epub-streaming', bookType?: BookType): void {

frontend/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,8 @@ <h1 class="book-title">
958958
<app-metadata-tabs
959959
[book]="book"
960960
[bookInSeries]="bookInSeries"
961-
[recommendedBooks]="recommendedBooks"
961+
[hasSeries]="!!book.metadata?.seriesName"
962+
[recommendedBooks]="filteredRecommendedBooks()"
962963
(readBook)="onReadBook($event)"
963964
(downloadBook)="onDownloadBook($event)"
964965
(downloadFile)="onDownloadFile($event)"

frontend/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.spec.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ describe('MetadataViewerComponent', () => {
256256
});
257257
});
258258

259-
it('filters series recommendations and builds the read, download, and other menus from the current book', () => {
259+
it('filters series recommendations and builds the read, download, and other menus from the current book', async () => {
260260
const component = createComponent();
261261
const seriesBooks = [
262262
{id: 8, metadata: {seriesNumber: 2}},
@@ -310,8 +310,10 @@ describe('MetadataViewerComponent', () => {
310310

311311
component.book = richBook;
312312

313-
expect(component.bookInSeries.map(book => book.id)).toEqual([4, 8]);
314-
expect(component.recommendedBooks.map(book => book.book.id)).toEqual([17]);
313+
await vi.waitFor(() => {
314+
expect(component.bookInSeries.map(book => book.id)).toEqual([4, 8]);
315+
});
316+
expect(component.filteredRecommendedBooks().map(book => book.book.id)).toEqual([17]);
315317

316318
const readItems = component.readMenuItems();
317319
expect(readItems.map(item => item.separator ? 'separator' : item.label)).toEqual([
@@ -374,6 +376,18 @@ describe('MetadataViewerComponent', () => {
374376
expect(confirm).toHaveBeenCalledTimes(2);
375377
});
376378

379+
it('falls back to an empty series list when the series lookup fails', async () => {
380+
const component = createComponent();
381+
getBooksInSeries.mockReturnValueOnce(throwError(() => new Error('series failed')));
382+
383+
component.book = createBook({}, {bookId: 21, seriesName: 'Series One'});
384+
385+
await vi.waitFor(() => {
386+
expect(getBooksInSeries).toHaveBeenCalledWith(21);
387+
});
388+
expect(component.bookInSeries).toEqual([]);
389+
});
390+
377391
it('chooses confirmation copy for file deletion branches and runs the accept callbacks', () => {
378392
const component = createComponent();
379393
const book = createBook();

0 commit comments

Comments
 (0)