Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions frontend/src/app/app.routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,25 @@ describe('app routes', () => {
it('defines the authenticated shell routes behind the auth guard', () => {
const shellRoute = routes.find(route => route.path === '' && Array.isArray(route.children));
const children = shellRoute?.children ?? [];
const browsePage = (path: string) => children.find(route => route.path === path)
?.children?.find(route => route.path === '');
const browseFilterPage = (path: string) => children.find(route => route.path === path)
?.children?.find(route => route.path === 'filter');

expect(children).toHaveLength(21);
expect(children.find(route => route.path === 'browse/filter')).toBeUndefined();
expect(shellRoute?.canActivateChild).toEqual([AuthChildGuard]);
expect(children.find(route => route.path === 'dashboard')?.canActivate).toBeUndefined();
expect(children.find(route => route.path === 'all-books')?.canActivate).toBeUndefined();
expect(children.find(route => route.path === 'magic-shelf/:magicShelfId/books')?.canActivate).toBeUndefined();
expect(children.find(route => route.path === 'notebook')?.canActivate).toBeUndefined();
expect(typeof children.find(route => route.path === 'all-books')?.loadComponent).toBe('function');
expect(typeof children.find(route => route.path === 'library/:libraryId/books')?.loadComponent).toBe('function');
expect(typeof children.find(route => route.path === 'shelf/:shelfId/books')?.loadComponent).toBe('function');
expect(typeof children.find(route => route.path === 'unshelved-books')?.loadComponent).toBe('function');
expect(typeof children.find(route => route.path === 'magic-shelf/:magicShelfId/books')?.loadComponent).toBe('function');
expect(typeof browsePage('all-books')?.loadComponent).toBe('function');
expect(typeof browseFilterPage('all-books')?.loadComponent).toBe('function');
expect(children.find(route => route.path === 'browse')).toBeUndefined();
expect(typeof browsePage('library/:libraryId/books')?.loadComponent).toBe('function');
expect(typeof browsePage('shelf/:shelfId/books')?.loadComponent).toBe('function');
expect(typeof browsePage('unshelved-books')?.loadComponent).toBe('function');
expect(typeof browsePage('magic-shelf/:magicShelfId/books')?.loadComponent).toBe('function');
expect(typeof children.find(route => route.path === 'design-system')?.loadComponent).toBe('function');
expect(typeof children.find(route => route.path === 'design-system/form/library')?.loadComponent).toBe('function');
expect(typeof children.find(route => route.path === 'design-system/form/device')?.loadComponent).toBe('function');
Expand Down
22 changes: 15 additions & 7 deletions frontend/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@ import {BookdropGuard} from './core/security/guards/bookdrop.guard';
import {LibraryStatsGuard} from './core/security/guards/library-stats.guard';
import {UserStatsGuard} from './core/security/guards/user-stats.guard';
import {EditMetadataGuard} from './core/security/guards/edit-metdata.guard';
import {validBookBrowseScope, type BookBrowseRouteData} from './features/book/browse/book-browse-scope';

const loadBookBrowserComponent = () =>
import('./features/book/components/book-browser/book-browser.component').then(m => m.BookBrowserComponent);
const loadBookBrowsePage = () =>
import('./features/book/browse/book-browse-page.component').then(m => m.BookBrowsePageComponent);
const loadBookBrowseFilterPage = () =>
import('./features/book/browse/book-browse-filter-page.component').then(m => m.BookBrowseFilterPageComponent);

const bookBrowseRoutes = (data: BookBrowseRouteData = {}) => [
{path: '', loadComponent: loadBookBrowsePage, ...(data.browseScope ? {data} : {})},
{path: 'filter', loadComponent: loadBookBrowseFilterPage, ...(data.browseScope ? {data} : {})},
];

export const routes: Routes = [
{
Expand All @@ -37,16 +45,16 @@ export const routes: Routes = [
canActivateChild: [AuthChildGuard],
children: [
{path: 'dashboard', component: MainDashboardComponent},
{path: 'all-books', loadComponent: loadBookBrowserComponent},
{path: 'all-books', children: bookBrowseRoutes()},
{path: 'settings', loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent)},
{path: 'library/:libraryId/books', loadComponent: loadBookBrowserComponent},
{path: 'shelf/:shelfId/books', loadComponent: loadBookBrowserComponent},
{path: 'unshelved-books', loadComponent: loadBookBrowserComponent},
{path: 'library/:libraryId/books', canActivateChild: [validBookBrowseScope], children: bookBrowseRoutes()},
{path: 'shelf/:shelfId/books', canActivateChild: [validBookBrowseScope], children: bookBrowseRoutes()},
{path: 'unshelved-books', children: bookBrowseRoutes({browseScope: 'unshelved'})},
{path: 'series', loadComponent: () => import('./features/series-browser/components/series-browser/series-browser.component').then(m => m.SeriesBrowserComponent)},
{path: 'series/:seriesName', loadComponent: () => import('./features/book/components/series-page/series-page.component').then(m => m.SeriesPageComponent)},
{path: 'authors', loadComponent: () => import('./features/author-browser/components/author-browser/author-browser.component').then(m => m.AuthorBrowserComponent)},
{path: 'author/:authorId', loadComponent: () => import('./features/author-browser/components/author-detail/author-detail.component').then(m => m.AuthorDetailComponent)},
{path: 'magic-shelf/:magicShelfId/books', loadComponent: loadBookBrowserComponent},
{path: 'magic-shelf/:magicShelfId/books', canActivateChild: [validBookBrowseScope], children: bookBrowseRoutes()},
{path: 'book/:bookId', loadComponent: () => import('./features/metadata/component/book-metadata-center/book-metadata-center.component').then(m => m.BookMetadataCenterComponent)},
{path: 'bookdrop', loadComponent: () => import('./features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component').then(m => m.BookdropFileReviewComponent), canActivate: [BookdropGuard]},
{path: 'metadata-manager', loadComponent: () => import('./features/metadata/component/metadata-manager/metadata-manager.component').then(m => m.MetadataManagerComponent), canActivate: [EditMetadataGuard]},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
@if (selection().active()) {
<app-browse-bulk-actions-bar
[count]="selection().count()"
[total]="total()"
[moreMenu]="bulkMoreMenu"
[moreAlways]="moreMenuOnlyItems()"
[moreDisabled]="isResolving()"
(clearSelection)="selection().clear()"
(selectAll)="selection().selectAll()">
<app-button
appBrowseBulkActionsItem="addToShelf"
variant="ghost"
size="md"
[disabled]="isResolving()"
[label]="'book.menu.addToShelf' | transloco"
[appMenuTriggerFor]="bulkShelfMenuHost.menu()">
<svg lucideBookmark aria-hidden="true"></svg>
</app-button>
<app-button
appBrowseBulkActionsItem="markAs"
variant="ghost"
size="md"
[disabled]="isResolving()"
[label]="'book.menu.markAs' | transloco"
[appMenuTriggerFor]="bulkMarkAsMenu">
<svg lucideCheck aria-hidden="true"></svg>
</app-button>
@if (bulkPermissions().canEditMetadata) {
<app-button
appBrowseBulkActionsItem="edit"
variant="ghost"
size="md"
[disabled]="isResolving()"
[label]="'common.edit' | transloco"
[appMenuTriggerFor]="bulkEditMenu">
<svg lucidePenLine aria-hidden="true"></svg>
</app-button>
}
@if (bulkMetadataAvailable()) {
<app-button
appBrowseBulkActionsItem="metadata"
variant="ghost"
size="md"
[disabled]="isResolving()"
[label]="'book.menu.metadata' | transloco"
[appMenuTriggerFor]="bulkMetadataMenu">
<svg lucideDatabase aria-hidden="true"></svg>
</app-button>
}
@if (actionPermissions().canDeleteBook) {
<span appBrowseBulkActionsItem="delete" appBrowseBulkActionsTrailing class="flex items-center gap-1">
<app-browse-bulk-actions-divider />
<app-button
variant="ghost"
tone="danger"
size="md"
[label]="'common.delete' | transloco"
[loading]="bulkDeleting()"
[disabled]="isResolving()"
(clicked)="commands.delete()" />
</span>
}
</app-browse-bulk-actions-bar>

<app-shelf-membership-menu
#bulkShelfMenuHost
[shelves]="bulkShelves()"
(toggleShelf)="commands.toggleShelf($event.shelfId, $event.checked)"
(createShelf)="commands.createShelf()"
(removeFromAllShelves)="commands.removeFromAllShelves(bulkShelfIds())" />

<app-menu #bulkMarkAsMenu [ariaLabel]="'book.menu.markAs' | transloco">
@for (status of readStatusTargets; track status) {
<app-menu-item [value]="status" [disabled]="isResolving()" (selected)="commands.markAs(status)">{{ statusLabelKey(status) | transloco }}</app-menu-item>
}
<app-menu-separator />
<app-menu-item value="clear-read-status" [disabled]="isResolving()" (selected)="commands.markAs(clearReadStatus)">
{{ clearReadStatusLabelKey | transloco }}
</app-menu-item>
</app-menu>

<app-menu #bulkEditMenu [ariaLabel]="'common.edit' | transloco">
<app-menu-item value="edit-all" [disabled]="isResolving()" (selected)="commands.editAll()">{{ 'browse.bulk.editAll' | transloco }}</app-menu-item>
<app-menu-item value="edit-one-by-one" [disabled]="isResolving()" (selected)="commands.editOneByOne()">{{ 'browse.bulk.editOneByOne' | transloco }}</app-menu-item>
</app-menu>

<app-menu #bulkMetadataMenu [ariaLabel]="'book.menu.metadata' | transloco">
@if (bulkPermissions().canAutoFetchMetadata) {
<app-menu-item value="fetch-metadata" [disabled]="isResolving()" (selected)="commands.fetchMetadata()">{{ 'book.menu.fetchMetadata' | transloco }}</app-menu-item>
}
@if (bulkPermissions().canCustomFetchMetadata) {
<app-menu-item value="fetch-metadata-with-options" [disabled]="isResolving()" (selected)="commands.fetchMetadataWithOptions()">{{ 'book.menu.fetchMetadataWithOptions' | transloco }}</app-menu-item>
}
<app-menu-separator [class]="autoSeparatorClass" />
@if (bulkPermissions().canLockUnlockMetadata) {
<app-menu-item value="lock-metadata" [disabled]="isResolving()" (selected)="commands.setMetadataLocks(true)">{{ 'metadata.editor.lockAllBtn' | transloco }}</app-menu-item>
<app-menu-item value="unlock-metadata" [disabled]="isResolving()" (selected)="commands.setMetadataLocks(false)">{{ 'metadata.editor.unlockAllBtn' | transloco }}</app-menu-item>
<app-menu-item value="lock-unlock-metadata" [disabled]="isResolving()" (selected)="commands.lockUnlockMetadata()">{{ 'browse.bulk.lockUnlockMetadata' | transloco }}</app-menu-item>
}
<app-menu-separator [class]="autoSeparatorClass" />
@if (bulkPermissions().canRegenerateCover) {
<app-menu-item value="restore-cover" [disabled]="isResolving()" (selected)="commands.changeCovers('regenerate')">{{ 'book.menu.restoreCover' | transloco }}</app-menu-item>
<app-menu-item value="generate-cover" [disabled]="isResolving()" (selected)="commands.changeCovers('generate')">{{ 'book.menu.generateCover' | transloco }}</app-menu-item>
}
</app-menu>

<app-menu #bulkMoreMenu [ariaLabel]="'browse.moreActions' | transloco">
@if (overflowed().has('addToShelf')) {
<app-menu-item value="add-to-shelf" [disabled]="isResolving()" [submenu]="bulkShelfMenuHost.ariaMenu()">{{ 'book.menu.addToShelf' | transloco }}</app-menu-item>
}
@if (overflowed().has('markAs')) {
<app-menu-item value="mark-as" [disabled]="isResolving()" [submenu]="bulkMarkAsMenu.menu">{{ 'book.menu.markAs' | transloco }}</app-menu-item>
}
@if (overflowed().has('edit')) {
<app-menu-item value="edit" [disabled]="isResolving()" [submenu]="bulkEditMenu.menu">{{ 'common.edit' | transloco }}</app-menu-item>
}
@if (overflowed().has('metadata')) {
<app-menu-item value="metadata" [disabled]="isResolving()" [submenu]="bulkMetadataMenu.menu">{{ 'book.menu.metadata' | transloco }}</app-menu-item>
}
<app-menu-separator [class]="autoSeparatorClass" />
@if (actionPermissions().canResetGrimmoryProgress) {
<app-menu-item value="reset-grimmory-progress" [disabled]="isResolving()" (selected)="commands.resetProgress('GRIMMORY')">{{ 'book.menu.resetGrimmoryProgress' | transloco }}</app-menu-item>
}
@if (actionPermissions().canResetKoreaderProgress) {
<app-menu-item value="reset-koreader-progress" [disabled]="isResolving()" (selected)="commands.resetProgress('KOREADER')">{{ 'book.menu.resetKoreaderProgress' | transloco }}</app-menu-item>
}
<app-menu-separator [class]="autoSeparatorClass" />
@if (bulkPermissions().canOrganizeFiles) {
<app-menu-item value="organize-files" [disabled]="isResolving()" (selected)="commands.organizeFiles()">{{ 'browse.bulk.organizeFiles' | transloco }}</app-menu-item>
}
@if (bulkPermissions().canAttachFiles) {
<app-menu-item value="attach-files" [disabled]="!bulkAttachEligible() || isResolving()" (selected)="commands.attachFiles()">
{{ 'book.fileAttacher.attachFilesBulk' | transloco }}
</app-menu-item>
}
<app-menu-separator [class]="autoSeparatorClass" />
@if (overflowed().has('delete')) {
<app-menu-item value="delete" variant="destructive" [loading]="bulkDeleting()" [disabled]="isResolving()" (selected)="commands.delete()">
{{ 'common.delete' | transloco }}
</app-menu-item>
}
</app-menu>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {signal, type WritableSignal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ConfirmationService, MessageService} from '@openng/optimus-ui/api';
import {beforeEach, describe, expect, it, vi} from 'vitest';

import {createQueryClientHarness, flushQueryAsync} from '../../../core/testing/query-testing';
import {getTranslocoModule} from '../../../core/testing/transloco-testing';
import {type BrowseSelection, type BrowseSelectionState} from '../../../shared/browse/selection';
import {AppSettingsService} from '../../../shared/service/app-settings.service';
import {UserService} from '../../settings/user-management/user.service';
import {type BookSummary} from '../data/book-response.models';
import {ShelfDefinitionQueryService} from '../data/shelf-definition-query.service';
import {BookDialogHelperService} from '../components/book-browser/book-dialog-helper.service';
import {BookBrowseBulkBarComponent} from './book-browse-bulk-bar.component';

function selectionOf(ids: readonly number[]): BrowseSelection {
const selected = new Set(ids);
return {
state: signal<BrowseSelectionState>({mode: 'explicit', ids: selected}),
count: signal(ids.length),
active: signal(true),
allMatchingSelected: signal(false),
isSelected: id => selected.has(id),
toggle: vi.fn(),
selectAll: vi.fn(),
clear: vi.fn(),
pruneDeleted: vi.fn(),
};
}

function book(id: number, libraryId = 1): BookSummary {
return {id, libraryId, libraryName: `Library ${libraryId}`};
}

function user(permissions: Record<string, boolean>, id = 1) {
return {id, permissions};
}

describe('BookBrowseBulkBarComponent', () => {
let fixture: ComponentFixture<BookBrowseBulkBarComponent>;
let currentUser: WritableSignal<ReturnType<typeof user> | null>;
let appSettings: WritableSignal<{diskType: string}>;
let shelfDefinitions: {id: number; userId: number; name: string}[];

function mount(books: BookSummary[], selectedIds: number[]): BookBrowseBulkBarComponent {
fixture = TestBed.createComponent(BookBrowseBulkBarComponent);
fixture.componentRef.setInput('selection', selectionOf(selectedIds));
fixture.componentRef.setInput('books', books);
fixture.componentRef.setInput('total', books.length);
fixture.componentRef.setInput('fetchIds', () => Promise.resolve([]));
fixture.detectChanges();
return fixture.componentInstance;
}

beforeEach(() => {
const harness = createQueryClientHarness();
currentUser = signal(null);
appSettings = signal({diskType: 'LOCAL'});
shelfDefinitions = [];

TestBed.configureTestingModule({
imports: [BookBrowseBulkBarComponent, getTranslocoModule()],
providers: [
...harness.providers,
{provide: UserService, useValue: {currentUser}},
{provide: AppSettingsService, useValue: {appSettings}},
{
provide: ShelfDefinitionQueryService,
useValue: {
definitions: () => ({
queryKey: ['shelves', 'query', 'definitions'] as const,
queryFn: () => Promise.resolve(shelfDefinitions),
}),
},
},
{provide: BookDialogHelperService, useValue: {}},
{provide: ConfirmationService, useValue: {confirm: vi.fn()}},
{provide: MessageService, useValue: {add: vi.fn()}},
],
}).overrideComponent(BookBrowseBulkBarComponent, {
set: {template: '', imports: []},
});
});

it('allows only the metadata actions the user is permitted', () => {
currentUser.set(user({canBulkLockUnlockMetadata: true}));
const bar = mount([book(1)], [1]);

expect(bar['bulkMetadataAvailable']()).toBe(true);
expect(bar['bulkPermissions']()).toMatchObject({
canLockUnlockMetadata: true,
canAutoFetchMetadata: false,
canCustomFetchMetadata: false,
canRegenerateCover: false,
});

currentUser.set(user({
canBulkAutoFetchMetadata: true,
canBulkCustomFetchMetadata: true,
canBulkRegenerateCover: true,
}));

expect(bar['bulkPermissions']()).toMatchObject({
canLockUnlockMetadata: false,
canAutoFetchMetadata: true,
canCustomFetchMetadata: true,
canRegenerateCover: true,
});

currentUser.set(user({}));
expect(bar['bulkMetadataAvailable']()).toBe(false);
});

it('requires permission and one library to attach files, and local disk to organize files', () => {
currentUser.set(user({canManageLibrary: true}));
const bar = mount([book(1, 1), book(2, 2)], [1, 2]);
expect(bar['bulkPermissions']().canAttachFiles).toBe(true);
expect(bar['bulkAttachEligible']()).toBe(false);

fixture.componentRef.setInput('books', [book(1), book(2)]);
expect(bar['bulkAttachEligible']()).toBe(true);

fixture.componentRef.setInput('books', [book(1)]);
expect(bar['bulkAttachEligible']()).toBe(false);

currentUser.set(user({canMoveOrganizeFiles: true}));
expect(bar['bulkPermissions']().canAttachFiles).toBe(false);
appSettings.set({diskType: 'S3'});
expect(bar['bulkPermissions']().canOrganizeFiles).toBe(false);

appSettings.set({diskType: 'LOCAL'});
expect(bar['bulkPermissions']().canOrganizeFiles).toBe(true);

currentUser.set(user({}));
expect(bar['bulkPermissions']().canOrganizeFiles).toBe(false);
});

it('offers only the current user shelves for bulk assignment', async () => {
currentUser.set(user({}, 7));
shelfDefinitions = [
{id: 5, userId: 7, name: 'Mine'},
{id: 6, userId: 9, name: 'Shared by someone else'},
];
const bar = mount([book(1)], [1]);
await flushQueryAsync();

expect(bar['bulkShelves']().map(shelf => shelf.id)).toEqual([5]);

currentUser.set(user({}, 9));
expect(bar['bulkShelves']().map(shelf => shelf.id)).toEqual([6]);
});
});
Loading
Loading