Skip to content

Commit 93ab3ba

Browse files
committed
refactor(ui): book browser page on the shared browse layer
1 parent 217be40 commit 93ab3ba

68 files changed

Lines changed: 5047 additions & 225 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/src/app/app.routes.spec.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,25 @@ describe('app routes', () => {
2626
it('defines the authenticated shell routes behind the auth guard', () => {
2727
const shellRoute = routes.find(route => route.path === '' && Array.isArray(route.children));
2828
const children = shellRoute?.children ?? [];
29+
const browsePage = (path: string) => children.find(route => route.path === path)
30+
?.children?.find(route => route.path === '');
31+
const browseFilterPage = (path: string) => children.find(route => route.path === path)
32+
?.children?.find(route => route.path === 'filter');
2933

3034
expect(children).toHaveLength(21);
35+
expect(children.find(route => route.path === 'browse/filter')).toBeUndefined();
3136
expect(shellRoute?.canActivateChild).toEqual([AuthChildGuard]);
3237
expect(children.find(route => route.path === 'dashboard')?.canActivate).toBeUndefined();
3338
expect(children.find(route => route.path === 'all-books')?.canActivate).toBeUndefined();
3439
expect(children.find(route => route.path === 'magic-shelf/:magicShelfId/books')?.canActivate).toBeUndefined();
3540
expect(children.find(route => route.path === 'notebook')?.canActivate).toBeUndefined();
36-
expect(typeof children.find(route => route.path === 'all-books')?.loadComponent).toBe('function');
37-
expect(typeof children.find(route => route.path === 'library/:libraryId/books')?.loadComponent).toBe('function');
38-
expect(typeof children.find(route => route.path === 'shelf/:shelfId/books')?.loadComponent).toBe('function');
39-
expect(typeof children.find(route => route.path === 'unshelved-books')?.loadComponent).toBe('function');
40-
expect(typeof children.find(route => route.path === 'magic-shelf/:magicShelfId/books')?.loadComponent).toBe('function');
41+
expect(typeof browsePage('all-books')?.loadComponent).toBe('function');
42+
expect(typeof browseFilterPage('all-books')?.loadComponent).toBe('function');
43+
expect(children.find(route => route.path === 'browse')).toBeUndefined();
44+
expect(typeof browsePage('library/:libraryId/books')?.loadComponent).toBe('function');
45+
expect(typeof browsePage('shelf/:shelfId/books')?.loadComponent).toBe('function');
46+
expect(typeof browsePage('unshelved-books')?.loadComponent).toBe('function');
47+
expect(typeof browsePage('magic-shelf/:magicShelfId/books')?.loadComponent).toBe('function');
4148
expect(typeof children.find(route => route.path === 'design-system')?.loadComponent).toBe('function');
4249
expect(typeof children.find(route => route.path === 'design-system/form/library')?.loadComponent).toBe('function');
4350
expect(typeof children.find(route => route.path === 'design-system/form/device')?.loadComponent).toBe('function');

frontend/src/app/app.routes.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,17 @@ import {BookdropGuard} from './core/security/guards/bookdrop.guard';
1414
import {LibraryStatsGuard} from './core/security/guards/library-stats.guard';
1515
import {UserStatsGuard} from './core/security/guards/user-stats.guard';
1616
import {EditMetadataGuard} from './core/security/guards/edit-metdata.guard';
17+
import {type BookBrowseRouteData} from './features/book/browse/book-browse-scope';
1718

18-
const loadBookBrowserComponent = () =>
19-
import('./features/book/components/book-browser/book-browser.component').then(m => m.BookBrowserComponent);
19+
const loadBookBrowsePage = () =>
20+
import('./features/book/browse/book-browse-page.component').then(m => m.BookBrowsePageComponent);
21+
const loadBookBrowseFilterPage = () =>
22+
import('./features/book/browse/book-browse-filter-page.component').then(m => m.BookBrowseFilterPageComponent);
23+
24+
const bookBrowseRoutes = (data: BookBrowseRouteData = {}) => [
25+
{path: '', loadComponent: loadBookBrowsePage, ...(data.browseScope ? {data} : {})},
26+
{path: 'filter', loadComponent: loadBookBrowseFilterPage, ...(data.browseScope ? {data} : {})},
27+
];
2028

2129
export const routes: Routes = [
2230
{
@@ -37,16 +45,16 @@ export const routes: Routes = [
3745
canActivateChild: [AuthChildGuard],
3846
children: [
3947
{path: 'dashboard', component: MainDashboardComponent},
40-
{path: 'all-books', loadComponent: loadBookBrowserComponent},
48+
{path: 'all-books', children: bookBrowseRoutes()},
4149
{path: 'settings', loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent)},
42-
{path: 'library/:libraryId/books', loadComponent: loadBookBrowserComponent},
43-
{path: 'shelf/:shelfId/books', loadComponent: loadBookBrowserComponent},
44-
{path: 'unshelved-books', loadComponent: loadBookBrowserComponent},
50+
{path: 'library/:libraryId/books', children: bookBrowseRoutes()},
51+
{path: 'shelf/:shelfId/books', children: bookBrowseRoutes()},
52+
{path: 'unshelved-books', children: bookBrowseRoutes({browseScope: 'unshelved'})},
4553
{path: 'series', loadComponent: () => import('./features/series-browser/components/series-browser/series-browser.component').then(m => m.SeriesBrowserComponent)},
4654
{path: 'series/:seriesName', loadComponent: () => import('./features/book/components/series-page/series-page.component').then(m => m.SeriesPageComponent)},
4755
{path: 'authors', loadComponent: () => import('./features/author-browser/components/author-browser/author-browser.component').then(m => m.AuthorBrowserComponent)},
4856
{path: 'author/:authorId', loadComponent: () => import('./features/author-browser/components/author-detail/author-detail.component').then(m => m.AuthorDetailComponent)},
49-
{path: 'magic-shelf/:magicShelfId/books', loadComponent: loadBookBrowserComponent},
57+
{path: 'magic-shelf/:magicShelfId/books', children: bookBrowseRoutes()},
5058
{path: 'book/:bookId', loadComponent: () => import('./features/metadata/component/book-metadata-center/book-metadata-center.component').then(m => m.BookMetadataCenterComponent)},
5159
{path: 'bookdrop', loadComponent: () => import('./features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component').then(m => m.BookdropFileReviewComponent), canActivate: [BookdropGuard]},
5260
{path: 'metadata-manager', loadComponent: () => import('./features/metadata/component/metadata-manager/metadata-manager.component').then(m => m.MetadataManagerComponent), canActivate: [EditMetadataGuard]},
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
@if (selection().active()) {
2+
<app-browse-bulk-actions-bar
3+
[count]="selection().count()"
4+
[total]="total()"
5+
[moreMenu]="bulkMoreMenu"
6+
[moreAlways]="moreMenuOnlyItems()"
7+
[moreDisabled]="isResolving()"
8+
(clearSelection)="selection().clear()"
9+
(selectAll)="selection().selectAll()">
10+
<app-button
11+
appBrowseBulkActionsItem="addToShelf"
12+
variant="ghost"
13+
size="md"
14+
[disabled]="isResolving()"
15+
[label]="'book.menu.addToShelf' | transloco"
16+
[appMenuTriggerFor]="bulkShelfMenuHost.menu()">
17+
<svg lucideBookmark aria-hidden="true"></svg>
18+
</app-button>
19+
<app-button
20+
appBrowseBulkActionsItem="markAs"
21+
variant="ghost"
22+
size="md"
23+
[disabled]="isResolving()"
24+
[label]="'book.menu.markAs' | transloco"
25+
[appMenuTriggerFor]="bulkMarkAsMenu">
26+
<svg lucideCheck aria-hidden="true"></svg>
27+
</app-button>
28+
@if (bulkPermissions().canEditMetadata) {
29+
<app-button
30+
appBrowseBulkActionsItem="edit"
31+
variant="ghost"
32+
size="md"
33+
[disabled]="isResolving()"
34+
[label]="'common.edit' | transloco"
35+
[appMenuTriggerFor]="bulkEditMenu">
36+
<svg lucidePenLine aria-hidden="true"></svg>
37+
</app-button>
38+
}
39+
@if (bulkMetadataAvailable()) {
40+
<app-button
41+
appBrowseBulkActionsItem="metadata"
42+
variant="ghost"
43+
size="md"
44+
[disabled]="isResolving()"
45+
[label]="'book.menu.metadata' | transloco"
46+
[appMenuTriggerFor]="bulkMetadataMenu">
47+
<svg lucideDatabase aria-hidden="true"></svg>
48+
</app-button>
49+
}
50+
@if (actionPermissions().canDeleteBook) {
51+
<span appBrowseBulkActionsItem="delete" appBrowseBulkActionsTrailing class="flex items-center gap-1">
52+
<app-browse-bulk-actions-divider />
53+
<app-button
54+
variant="ghost"
55+
tone="danger"
56+
size="md"
57+
[label]="'common.delete' | transloco"
58+
[loading]="bulkDeleting()"
59+
[disabled]="isResolving()"
60+
(clicked)="commands.delete()" />
61+
</span>
62+
}
63+
</app-browse-bulk-actions-bar>
64+
65+
<app-shelf-membership-menu
66+
#bulkShelfMenuHost
67+
[shelves]="bulkShelves()"
68+
(toggleShelf)="commands.toggleShelf($event.shelfId, $event.checked)"
69+
(createShelf)="commands.createShelf()"
70+
(removeFromAllShelves)="commands.removeFromAllShelves(bulkShelfIds())" />
71+
72+
<app-menu #bulkMarkAsMenu [ariaLabel]="'book.menu.markAs' | transloco">
73+
@for (status of readStatusTargets; track status) {
74+
<app-menu-item [value]="status" [disabled]="isResolving()" (selected)="commands.markAs(status)">{{ statusLabelKey(status) | transloco }}</app-menu-item>
75+
}
76+
<app-menu-separator />
77+
<app-menu-item value="clear-read-status" [disabled]="isResolving()" (selected)="commands.markAs(clearReadStatus)">
78+
{{ clearReadStatusLabelKey | transloco }}
79+
</app-menu-item>
80+
</app-menu>
81+
82+
<app-menu #bulkEditMenu [ariaLabel]="'common.edit' | transloco">
83+
<app-menu-item value="edit-all" [disabled]="isResolving()" (selected)="commands.editAll()">{{ 'browse.bulk.editAll' | transloco }}</app-menu-item>
84+
<app-menu-item value="edit-one-by-one" [disabled]="isResolving()" (selected)="commands.editOneByOne()">{{ 'browse.bulk.editOneByOne' | transloco }}</app-menu-item>
85+
</app-menu>
86+
87+
<app-menu #bulkMetadataMenu [ariaLabel]="'book.menu.metadata' | transloco">
88+
@if (bulkPermissions().canAutoFetchMetadata) {
89+
<app-menu-item value="fetch-metadata" [disabled]="isResolving()" (selected)="commands.fetchMetadata()">{{ 'book.menu.fetchMetadata' | transloco }}</app-menu-item>
90+
}
91+
@if (bulkPermissions().canCustomFetchMetadata) {
92+
<app-menu-item value="fetch-metadata-with-options" [disabled]="isResolving()" (selected)="commands.fetchMetadataWithOptions()">{{ 'book.menu.fetchMetadataWithOptions' | transloco }}</app-menu-item>
93+
}
94+
<app-menu-separator [class]="autoSeparatorClass" />
95+
@if (bulkPermissions().canLockUnlockMetadata) {
96+
<app-menu-item value="lock-metadata" [disabled]="isResolving()" (selected)="commands.setMetadataLocks(true)">{{ 'metadata.editor.lockAllBtn' | transloco }}</app-menu-item>
97+
<app-menu-item value="unlock-metadata" [disabled]="isResolving()" (selected)="commands.setMetadataLocks(false)">{{ 'metadata.editor.unlockAllBtn' | transloco }}</app-menu-item>
98+
<app-menu-item value="lock-unlock-metadata" [disabled]="isResolving()" (selected)="commands.lockUnlockMetadata()">{{ 'browse.bulk.lockUnlockMetadata' | transloco }}</app-menu-item>
99+
}
100+
<app-menu-separator [class]="autoSeparatorClass" />
101+
@if (bulkPermissions().canRegenerateCover) {
102+
<app-menu-item value="restore-cover" [disabled]="isResolving()" (selected)="commands.changeCovers('regenerate')">{{ 'book.menu.restoreCover' | transloco }}</app-menu-item>
103+
<app-menu-item value="generate-cover" [disabled]="isResolving()" (selected)="commands.changeCovers('generate')">{{ 'book.menu.generateCover' | transloco }}</app-menu-item>
104+
}
105+
</app-menu>
106+
107+
<app-menu #bulkMoreMenu [ariaLabel]="'browse.moreActions' | transloco">
108+
@if (overflowed().has('addToShelf')) {
109+
<app-menu-item value="add-to-shelf" [disabled]="isResolving()" [submenu]="bulkShelfMenuHost.ariaMenu()">{{ 'book.menu.addToShelf' | transloco }}</app-menu-item>
110+
}
111+
@if (overflowed().has('markAs')) {
112+
<app-menu-item value="mark-as" [disabled]="isResolving()" [submenu]="bulkMarkAsMenu.menu">{{ 'book.menu.markAs' | transloco }}</app-menu-item>
113+
}
114+
@if (overflowed().has('edit')) {
115+
<app-menu-item value="edit" [disabled]="isResolving()" [submenu]="bulkEditMenu.menu">{{ 'common.edit' | transloco }}</app-menu-item>
116+
}
117+
@if (overflowed().has('metadata')) {
118+
<app-menu-item value="metadata" [disabled]="isResolving()" [submenu]="bulkMetadataMenu.menu">{{ 'book.menu.metadata' | transloco }}</app-menu-item>
119+
}
120+
<app-menu-separator [class]="autoSeparatorClass" />
121+
@if (actionPermissions().canResetGrimmoryProgress) {
122+
<app-menu-item value="reset-grimmory-progress" [disabled]="isResolving()" (selected)="commands.resetProgress('GRIMMORY')">{{ 'book.menu.resetGrimmoryProgress' | transloco }}</app-menu-item>
123+
}
124+
@if (actionPermissions().canResetKoreaderProgress) {
125+
<app-menu-item value="reset-koreader-progress" [disabled]="isResolving()" (selected)="commands.resetProgress('KOREADER')">{{ 'book.menu.resetKoreaderProgress' | transloco }}</app-menu-item>
126+
}
127+
<app-menu-separator [class]="autoSeparatorClass" />
128+
@if (bulkPermissions().canOrganizeFiles) {
129+
<app-menu-item value="organize-files" [disabled]="isResolving()" (selected)="commands.organizeFiles()">{{ 'browse.bulk.organizeFiles' | transloco }}</app-menu-item>
130+
}
131+
@if (bulkPermissions().canAttachFiles) {
132+
<app-menu-item value="attach-files" [disabled]="!bulkAttachEligible() || isResolving()" (selected)="commands.attachFiles()">
133+
{{ 'book.fileAttacher.attachFilesBulk' | transloco }}
134+
</app-menu-item>
135+
}
136+
<app-menu-separator [class]="autoSeparatorClass" />
137+
@if (overflowed().has('delete')) {
138+
<app-menu-item value="delete" variant="destructive" [loading]="bulkDeleting()" [disabled]="isResolving()" (selected)="commands.delete()">
139+
{{ 'common.delete' | transloco }}
140+
</app-menu-item>
141+
}
142+
</app-menu>
143+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {signal, type WritableSignal} from '@angular/core';
2+
import {ComponentFixture, TestBed} from '@angular/core/testing';
3+
import {ConfirmationService, MessageService} from '@openng/optimus-ui/api';
4+
import {beforeEach, describe, expect, it, vi} from 'vitest';
5+
6+
import {createQueryClientHarness, flushQueryAsync} from '../../../core/testing/query-testing';
7+
import {getTranslocoModule} from '../../../core/testing/transloco-testing';
8+
import {type BrowseSelection, type BrowseSelectionState} from '../../../shared/browse/selection';
9+
import {AppSettingsService} from '../../../shared/service/app-settings.service';
10+
import {UserService} from '../../settings/user-management/user.service';
11+
import {type BookSummary} from '../data/book-response.models';
12+
import {ShelfDefinitionQueryService} from '../data/shelf-definition-query.service';
13+
import {BookDialogHelperService} from '../components/book-browser/book-dialog-helper.service';
14+
import {BookBrowseBulkBarComponent} from './book-browse-bulk-bar.component';
15+
16+
function selectionOf(ids: readonly number[]): BrowseSelection {
17+
const selected = new Set(ids);
18+
return {
19+
state: signal<BrowseSelectionState>({mode: 'explicit', ids: selected}),
20+
count: signal(ids.length),
21+
active: signal(true),
22+
allMatchingSelected: signal(false),
23+
isSelected: id => selected.has(id),
24+
toggle: vi.fn(),
25+
selectAll: vi.fn(),
26+
clear: vi.fn(),
27+
pruneDeleted: vi.fn(),
28+
};
29+
}
30+
31+
function book(id: number, libraryId = 1): BookSummary {
32+
return {id, libraryId, libraryName: `Library ${libraryId}`};
33+
}
34+
35+
function user(permissions: Record<string, boolean>, id = 1) {
36+
return {id, permissions};
37+
}
38+
39+
describe('BookBrowseBulkBarComponent', () => {
40+
let fixture: ComponentFixture<BookBrowseBulkBarComponent>;
41+
let currentUser: WritableSignal<ReturnType<typeof user> | null>;
42+
let appSettings: WritableSignal<{diskType: string}>;
43+
let shelfDefinitions: {id: number; userId: number; name: string}[];
44+
45+
function mount(books: BookSummary[], selectedIds: number[]): BookBrowseBulkBarComponent {
46+
fixture = TestBed.createComponent(BookBrowseBulkBarComponent);
47+
fixture.componentRef.setInput('selection', selectionOf(selectedIds));
48+
fixture.componentRef.setInput('books', books);
49+
fixture.componentRef.setInput('total', books.length);
50+
fixture.componentRef.setInput('fetchIds', () => Promise.resolve([]));
51+
fixture.detectChanges();
52+
return fixture.componentInstance;
53+
}
54+
55+
beforeEach(() => {
56+
const harness = createQueryClientHarness();
57+
currentUser = signal(null);
58+
appSettings = signal({diskType: 'LOCAL'});
59+
shelfDefinitions = [];
60+
61+
TestBed.configureTestingModule({
62+
imports: [BookBrowseBulkBarComponent, getTranslocoModule()],
63+
providers: [
64+
...harness.providers,
65+
{provide: UserService, useValue: {currentUser}},
66+
{provide: AppSettingsService, useValue: {appSettings}},
67+
{
68+
provide: ShelfDefinitionQueryService,
69+
useValue: {
70+
definitions: () => ({
71+
queryKey: ['shelves', 'query', 'definitions'] as const,
72+
queryFn: () => Promise.resolve(shelfDefinitions),
73+
}),
74+
},
75+
},
76+
{provide: BookDialogHelperService, useValue: {}},
77+
{provide: ConfirmationService, useValue: {confirm: vi.fn()}},
78+
{provide: MessageService, useValue: {add: vi.fn()}},
79+
],
80+
}).overrideComponent(BookBrowseBulkBarComponent, {
81+
set: {template: '', imports: []},
82+
});
83+
});
84+
85+
it('allows only the metadata actions the user is permitted', () => {
86+
currentUser.set(user({canBulkLockUnlockMetadata: true}));
87+
const bar = mount([book(1)], [1]);
88+
89+
expect(bar['bulkMetadataAvailable']()).toBe(true);
90+
expect(bar['bulkPermissions']()).toMatchObject({
91+
canLockUnlockMetadata: true,
92+
canAutoFetchMetadata: false,
93+
canCustomFetchMetadata: false,
94+
canRegenerateCover: false,
95+
});
96+
97+
currentUser.set(user({
98+
canBulkAutoFetchMetadata: true,
99+
canBulkCustomFetchMetadata: true,
100+
canBulkRegenerateCover: true,
101+
}));
102+
103+
expect(bar['bulkPermissions']()).toMatchObject({
104+
canLockUnlockMetadata: false,
105+
canAutoFetchMetadata: true,
106+
canCustomFetchMetadata: true,
107+
canRegenerateCover: true,
108+
});
109+
110+
currentUser.set(user({}));
111+
expect(bar['bulkMetadataAvailable']()).toBe(false);
112+
});
113+
114+
it('requires permission and one library to attach files, and local disk to organize files', () => {
115+
currentUser.set(user({canManageLibrary: true}));
116+
const bar = mount([book(1, 1), book(2, 2)], [1, 2]);
117+
expect(bar['bulkPermissions']().canAttachFiles).toBe(true);
118+
expect(bar['bulkAttachEligible']()).toBe(false);
119+
120+
fixture.componentRef.setInput('books', [book(1), book(2)]);
121+
expect(bar['bulkAttachEligible']()).toBe(true);
122+
123+
fixture.componentRef.setInput('books', [book(1)]);
124+
expect(bar['bulkAttachEligible']()).toBe(false);
125+
126+
currentUser.set(user({canMoveOrganizeFiles: true}));
127+
expect(bar['bulkPermissions']().canAttachFiles).toBe(false);
128+
appSettings.set({diskType: 'S3'});
129+
expect(bar['bulkPermissions']().canOrganizeFiles).toBe(false);
130+
131+
appSettings.set({diskType: 'LOCAL'});
132+
expect(bar['bulkPermissions']().canOrganizeFiles).toBe(true);
133+
134+
currentUser.set(user({}));
135+
expect(bar['bulkPermissions']().canOrganizeFiles).toBe(false);
136+
});
137+
138+
it('offers only the current user shelves for bulk assignment', async () => {
139+
currentUser.set(user({}, 7));
140+
shelfDefinitions = [
141+
{id: 5, userId: 7, name: 'Mine'},
142+
{id: 6, userId: 9, name: 'Shared by someone else'},
143+
];
144+
const bar = mount([book(1)], [1]);
145+
await flushQueryAsync();
146+
147+
expect(bar['bulkShelves']().map(shelf => shelf.id)).toEqual([5]);
148+
149+
currentUser.set(user({}, 9));
150+
expect(bar['bulkShelves']().map(shelf => shelf.id)).toEqual([6]);
151+
});
152+
});

0 commit comments

Comments
 (0)