feat(smart-scope): add toggle to collapse series in smart scopes

Provide a series-collapse toggle in the Smart Scopes view to reduce clutter from large series. Use the existing series collapse preferences logic to allow users to toggle this setting per Smart Scope, maintaining consistency with Library and Collection views.

Closes #609
This commit is contained in:
neon
2026-07-09 22:20:54 +00:00
parent 0f37e197ad
commit 910ce5a92e
21 changed files with 1082 additions and 2586 deletions
@@ -509,6 +509,7 @@ defineOptions({ name: 'AuthorsView' })
<button
v-if="!isDefaultSort"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-destructive hover:bg-muted"
aria-label="Reset sort to default"
@click="resetSort"
>
<X :size="13" />
@@ -547,6 +548,7 @@ defineOptions({ name: 'AuthorsView' })
? 'border-primary text-primary bg-primary/10'
: 'border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
"
aria-label="Show author controls"
@click="toggleMobileControls"
>
<SlidersHorizontal :size="14" />
@@ -49,6 +49,7 @@ describe('useSeriesCollapsePreference', () => {
expect(getEffectivePreference({})).toBe(false)
expect(getEffectivePreference({ libraryId: 1 })).toBe(false)
expect(getEffectivePreference({ collectionId: 5 })).toBe(false)
expect(getEffectivePreference({ smartScopeId: 8 })).toBe(false)
})
it('returns false when user is null', () => {
@@ -110,6 +111,20 @@ describe('useSeriesCollapsePreference', () => {
expect(getEffectivePreference({ collectionId: 5, libraryId: 1 })).toBe(false)
})
it('returns smart scope override over the global preference', () => {
const user = ref(
makeUser({
seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, smartScopes: { '8': true } },
}),
)
mockUseAuth.mockReturnValue({ user } as ReturnType<typeof useAuth>)
const { getEffectivePreference } = useSeriesCollapsePreference()
expect(getEffectivePreference({ smartScopeId: 8 })).toBe(true)
expect(getEffectivePreference({ smartScopeId: 99 })).toBe(false)
})
})
describe('setPreference', () => {
@@ -197,6 +212,24 @@ describe('useSeriesCollapsePreference', () => {
)
})
it('sets a smart scope override and calls the API', async () => {
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: false, libraries: {}, collections: {} } }))
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
const { setPreference, prefs } = useSeriesCollapsePreference()
await setPreference({ smartScopeId: 8 }, true)
expect(prefs.value!.smartScopes).toEqual({ '8': true })
expect(mockApi).toHaveBeenCalledWith(
'/api/v1/users/me/series-collapse-preferences',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ smartScopes: { '8': true } }),
}),
)
})
it('initialises preferences from defaults when user has none', async () => {
const userRef = ref(makeUser({}))
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
@@ -205,7 +238,7 @@ describe('useSeriesCollapsePreference', () => {
await setPreference('global', true)
expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {} })
expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {}, smartScopes: {} })
})
it('preserves existing library overrides when setting global', async () => {
@@ -11,22 +11,30 @@ export function useSeriesCollapsePreference() {
return user.value?.settings.seriesCollapsePreferences
})
function getEffectivePreference(ctx: { libraryId?: number; collectionId?: number }): boolean {
function getEffectivePreference(ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number }): boolean {
return resolveCollapsePreference(prefs.value, ctx)
}
let pendingUpdate: Promise<void> = Promise.resolve()
async function setPreference(ctx: { libraryId?: number; collectionId?: number } | 'global', value: boolean | null): Promise<void> {
async function setPreference(
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number } | 'global',
value: boolean | null,
): Promise<void> {
const op = () => doSetPreference(ctx, value)
pendingUpdate = pendingUpdate.then(op, op)
return pendingUpdate
}
async function doSetPreference(ctx: { libraryId?: number; collectionId?: number } | 'global', value: boolean | null): Promise<void> {
async function doSetPreference(
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number } | 'global',
value: boolean | null,
): Promise<void> {
let body: Record<string, unknown>
if (ctx === 'global') {
body = { global: value === null ? false : value }
} else if (ctx.smartScopeId !== undefined) {
body = { smartScopes: { [String(ctx.smartScopeId)]: value } }
} else if (ctx.collectionId !== undefined) {
body = { collections: { [String(ctx.collectionId)]: value } }
} else if (ctx.libraryId !== undefined) {
@@ -35,9 +43,10 @@ export function useSeriesCollapsePreference() {
body = { global: value === null ? false : value }
}
const current = prefs.value ?? { global: false, libraries: {}, collections: {} }
const current = prefs.value ?? { global: false, libraries: {}, collections: {}, smartScopes: {} }
const nextLibraries = { ...current.libraries, ...(body.libraries as Record<string, boolean | null>) }
const nextCollections = { ...current.collections, ...(body.collections as Record<string, boolean | null>) }
const nextSmartScopes = { ...current.smartScopes, ...(body.smartScopes as Record<string, boolean | null>) }
// Remove null entries (deletion)
for (const [k, v] of Object.entries(nextLibraries)) {
@@ -46,11 +55,15 @@ export function useSeriesCollapsePreference() {
for (const [k, v] of Object.entries(nextCollections)) {
if (v === null) delete nextCollections[k]
}
for (const [k, v] of Object.entries(nextSmartScopes)) {
if (v === null) delete nextSmartScopes[k]
}
const updated: SeriesCollapsePreferences = {
global: body.global !== undefined ? (body.global as boolean) : (current.global ?? false),
libraries: nextLibraries as Record<string, boolean>,
collections: nextCollections as Record<string, boolean>,
smartScopes: nextSmartScopes as Record<string, boolean>,
}
const previous = user.value?.settings ? { ...user.value.settings } : undefined
@@ -342,6 +342,7 @@ defineOptions({ name: 'SeriesView' })
<button
v-if="!isDefaultSort"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-destructive hover:bg-muted"
aria-label="Reset sort to default"
@click="resetSort"
>
<X :size="13" />
@@ -380,6 +381,7 @@ defineOptions({ name: 'SeriesView' })
? 'border-primary text-primary bg-primary/10'
: 'border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
"
aria-label="Show series controls"
@click="toggleMobileControls"
>
<SlidersHorizontal :size="14" />
@@ -67,7 +67,7 @@ function setThumbnailClickAction(action: BookThumbnailClickAction) {
<div class="min-w-0">
<p class="settings-label">Collapse series by default</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Group books in the same series into a single card in library and collection views
Group books in the same series into a single card in library, collection, and Smart Scope views
</p>
</div>
<ToggleSwitch :model-value="globalCollapseEnabled" @update:model-value="handleGlobalCollapseToggle" />
+4 -12
View File
@@ -448,6 +448,7 @@ defineOptions({ name: 'CollectionView' })
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseEnabledRef ? 'Expand series' : 'Collapse series'"
@click="handleToggleCollapse"
>
<Layers :size="14" />
@@ -461,6 +462,7 @@ defineOptions({ name: 'CollectionView' })
<button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border border-input text-muted-foreground bg-background transition-colors hover:text-foreground hover:bg-muted"
aria-label="Export metadata"
@click="openMetadataExport"
>
<FileSpreadsheet :size="14" />
@@ -474,6 +476,7 @@ defineOptions({ name: 'CollectionView' })
<button
v-if="collection"
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Edit collection"
@click="openCollectionEditor"
>
<Pencil :size="14" />
@@ -484,6 +487,7 @@ defineOptions({ name: 'CollectionView' })
<button
class="sm:hidden flex h-8 w-8 items-center justify-center rounded-md border border-input text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Show collection controls"
@click="toggleMobileControls"
>
<SlidersHorizontal :size="14" />
@@ -540,18 +544,6 @@ defineOptions({ name: 'CollectionView' })
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors"
:class="
collapseEnabledRef
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground hover:bg-muted hover:text-foreground'
"
@click="handleToggleCollapse"
>
<Layers :size="13" />
<span>{{ collapseEnabledRef ? 'Expanded' : 'Collapse series' }}</span>
</button>
<button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
class="flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+4
View File
@@ -595,6 +595,7 @@ defineOptions({ name: 'HomeView' })
<button
v-if="!isDefaultSort"
@click="handleResetSort"
aria-label="Reset sort to default"
class="h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground/70 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X :size="13" />
@@ -613,6 +614,7 @@ defineOptions({ name: 'HomeView' })
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseEnabledRef ? 'Expand series' : 'Collapse series'"
@click="handleToggleCollapse"
>
<Layers :size="14" />
@@ -625,6 +627,7 @@ defineOptions({ name: 'HomeView' })
<button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border border-input text-muted-foreground bg-background transition-colors hover:text-foreground hover:bg-muted"
aria-label="Export metadata"
@click="openMetadataExport('all-matching')"
>
<FileSpreadsheet :size="14" />
@@ -666,6 +669,7 @@ defineOptions({ name: 'HomeView' })
? 'border-primary text-primary bg-primary/10'
: 'border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
"
aria-label="Show library controls"
@click="toggleMobileControls"
>
<SlidersHorizontal :size="14" />
+115 -40
View File
@@ -9,9 +9,13 @@ import {
ChevronUp,
ArrowUpDown,
Aperture,
CheckSquare,
FileSpreadsheet,
Filter,
Layers,
Search,
SlidersHorizontal,
Square,
X,
} from '@lucide/vue'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
@@ -20,6 +24,8 @@ import VirtualBookTable from '@/features/book/components/VirtualBookTable.vue'
import TableColumnPanel from '@/features/book/components/TableColumnPanel.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SmartScopeEditorPanel from '@/features/smart-scope/components/SmartScopeEditorPanel.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
@@ -30,6 +36,7 @@ import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import JumpRail from '@/features/book/components/JumpRail.vue'
import { toast } from 'vue-sonner'
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useEffectiveViewMode } from '@/composables/useEffectiveViewMode'
@@ -63,6 +70,17 @@ const { coverSize, gridGap } = useViewDisplaySettings('smartScope', smartScopeId
const { tableDensity } = useDisplaySettings()
const { allSavedViews, saveView, renameView, deleteView, duplicateView, toggleFavorite, importViews } = useSavedViews('smartScope', smartScopeId)
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
const collapseEnabledRef = ref(getEffectivePreference({ smartScopeId: smartScopeId.value }))
watch(smartScopeId, (id) => {
collapseEnabledRef.value = getEffectivePreference({ smartScopeId: id })
})
watch(prefs, () => {
collapseEnabledRef.value = getEffectivePreference({ smartScopeId: smartScopeId.value })
})
const { searchQuery, debouncedQuery, clearSearch } = useViewSearch()
const {
booksProxy: books,
@@ -93,6 +111,7 @@ const {
listEndpoint: (id) => `/api/v1/smart-scopes/${id}/books/query`,
bucketsEndpoint: (id) => `/api/v1/smart-scopes/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
})
const { setBookContext } = useBookNavigation()
@@ -314,6 +333,12 @@ function openMetadataExport() {
collapseMobileControlsIfNeeded()
}
async function handleToggleCollapse() {
const next = !collapseEnabledRef.value
collapseEnabledRef.value = next
await setPreference({ smartScopeId: smartScopeId.value }, next)
}
function onSaved() {
resetBooks()
refreshBuckets()
@@ -450,51 +475,101 @@ defineOptions({ name: 'SmartScopeView' })
v-model:searchQuery="searchQuery"
@toggle-selection="toggleSelectionMode"
>
<template #actions>
<template #toolbar>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="smartScope?.filter || sortChip"
class="hidden sm:flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors"
:class="
filterExpanded
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="filterExpanded ? 'Hide filter summary' : 'Show filter summary'"
@click="toggleFilterSummary"
>
<Filter :size="13" />
<span>Filter</span>
</button>
</TooltipTrigger>
<TooltipContent>{{ filterExpanded ? 'Hide filter summary' : 'Show filter summary' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
collapseEnabledRef
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseEnabledRef ? 'Expand series' : 'Collapse series'"
@click="handleToggleCollapse"
>
<Layers :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ collapseEnabledRef ? 'Expand series' : 'Collapse series' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border border-input text-muted-foreground bg-background transition-colors hover:text-foreground hover:bg-muted"
aria-label="Export metadata"
@click="openMetadataExport"
>
<FileSpreadsheet :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Export metadata</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="hidden sm:flex h-8 items-center gap-1.5 rounded-md border border-input px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Edit Smart Scope"
@click="openEditor"
>
<Settings2 :size="13" />
<span>Edit</span>
</button>
</TooltipTrigger>
<TooltipContent>Edit Smart Scope</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="deleting"
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
confirmSmartScopeDelete
? 'border-destructive text-destructive bg-destructive/10 hover:bg-destructive/20'
: 'border-input text-muted-foreground hover:text-destructive hover:border-destructive'
"
:aria-label="confirmSmartScopeDelete ? 'Confirm delete Smart Scope' : 'Delete Smart Scope'"
@click="handleDelete"
>
<Trash2 :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ confirmSmartScopeDelete ? 'Confirm delete' : 'Delete Smart Scope' }}</TooltipContent>
</Tooltip>
<button
class="sm:hidden flex h-8 w-8 items-center justify-center rounded-md border border-input text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Show Smart Scope controls"
@click="toggleMobileControls"
>
<SlidersHorizontal :size="14" />
</button>
<button
v-if="smartScope?.filter || sortChip"
@click="filterExpanded = !filterExpanded"
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:title="filterExpanded ? 'Hide filter' : 'Show filter'"
>
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
<span>Filter</span>
</button>
<button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
@click="openMetadataExport"
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<FileSpreadsheet :size="13" />
<span>Export</span>
</button>
<button
@click="openEditor"
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Settings2 :size="13" />
<span>Edit</span>
</button>
<button
@click="handleDelete"
:disabled="deleting"
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border text-sm transition-colors"
:class="
confirmSmartScopeDelete
? 'border-destructive text-destructive bg-destructive/10 hover:bg-destructive/20'
: 'border-input text-muted-foreground hover:text-destructive hover:border-destructive'
"
>
<Trash2 :size="13" />
<span>{{ confirmSmartScopeDelete ? 'Confirm?' : 'Delete' }}</span>
</button>
</template>
<template #mobile-menu>
<DropdownMenuItem @click="handleToggleCollapse">
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
<Square v-else :size="14" class="mr-2" />
Collapse series
</DropdownMenuItem>
</template>
<template v-if="effectiveViewMode === 'table'" #columns>
<TableColumnPanel
@@ -546,7 +621,7 @@ defineOptions({ name: 'SmartScopeView' })
class="flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
<span>{{ filterExpanded ? 'Hide Filter' : 'Show Filter' }}</span>
<span>{{ filterExpanded ? 'Hide filter' : 'Show filter' }}</span>
</button>
<button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
+9 -1
View File
@@ -2,6 +2,7 @@ export type SeriesCollapsePreferences = {
global: boolean;
libraries: Record<string, boolean>;
collections: Record<string, boolean>;
smartScopes?: Record<string, boolean>;
};
export type CollapsedSeriesInfo = {
@@ -15,8 +16,15 @@ export type CollapsedSeriesInfo = {
firstUnreadBookId?: number | null;
};
export function resolveCollapsePreference(prefs: SeriesCollapsePreferences | undefined, ctx: { libraryId?: number; collectionId?: number }): boolean {
export function resolveCollapsePreference(
prefs: SeriesCollapsePreferences | undefined,
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number },
): boolean {
if (!prefs) return false;
if (ctx.smartScopeId !== undefined) {
const override = prefs.smartScopes?.[String(ctx.smartScopeId)];
if (override !== undefined) return override;
}
if (ctx.collectionId !== undefined) {
const override = prefs.collections?.[String(ctx.collectionId)];
if (override !== undefined) return override;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1399,13 +1399,13 @@ describe('seriesStatusRuleToSql', () => {
});
});
describe('BookQueryBuilder.hasSeriesFilter', () => {
describe('BookQueryBuilder.hasSeriesSelectionFilter', () => {
it('returns false for undefined', () => {
expect(BookQueryBuilder.hasSeriesFilter(undefined)).toBe(false);
expect(BookQueryBuilder.hasSeriesSelectionFilter(undefined)).toBe(false);
});
it('returns false for a group with no rules', () => {
expect(BookQueryBuilder.hasSeriesFilter({ type: 'group', join: 'AND', rules: [] })).toBe(false);
expect(BookQueryBuilder.hasSeriesSelectionFilter({ type: 'group', join: 'AND', rules: [] })).toBe(false);
});
it('returns false when no series rule is present', () => {
@@ -1417,12 +1417,17 @@ describe('BookQueryBuilder.hasSeriesFilter', () => {
{ type: 'rule' as const, field: 'author' as never, operator: 'contains' as never, value: 'Frank' },
],
};
expect(BookQueryBuilder.hasSeriesFilter(node)).toBe(false);
expect(BookQueryBuilder.hasSeriesSelectionFilter(node)).toBe(false);
});
it('returns true for a direct series rule', () => {
it('returns true for a direct series selection rule', () => {
const node = { type: 'rule' as const, field: 'series' as never, operator: 'contains' as never, value: 'Dune' };
expect(BookQueryBuilder.hasSeriesFilter(node)).toBe(true);
expect(BookQueryBuilder.hasSeriesSelectionFilter(node)).toBe(true);
});
it.each(['isEmpty', 'isNotEmpty'] as const)('returns false for a series presence rule: %s', (operator) => {
const node = { type: 'rule' as const, field: 'series' as never, operator };
expect(BookQueryBuilder.hasSeriesSelectionFilter(node)).toBe(false);
});
it('returns true when series rule is inside a nested group', () => {
@@ -1432,7 +1437,7 @@ describe('BookQueryBuilder.hasSeriesFilter', () => {
rules: [{ type: 'rule' as const, field: 'series' as never, operator: 'equals' as never, value: 'Mistborn' }],
};
const outer = { type: 'group' as const, join: 'AND' as const, rules: [inner] };
expect(BookQueryBuilder.hasSeriesFilter(outer)).toBe(true);
expect(BookQueryBuilder.hasSeriesSelectionFilter(outer)).toBe(true);
});
it('returns true when series rule is alongside other rules in a group', () => {
@@ -1444,7 +1449,7 @@ describe('BookQueryBuilder.hasSeriesFilter', () => {
{ type: 'rule' as const, field: 'series' as never, operator: 'equals' as never, value: 'Stormlight' },
],
};
expect(BookQueryBuilder.hasSeriesFilter(node)).toBe(true);
expect(BookQueryBuilder.hasSeriesSelectionFilter(node)).toBe(true);
});
});
@@ -913,10 +913,10 @@ export class BookQueryBuilder {
}
}
static hasSeriesFilter(node: GroupRule | Rule | undefined): boolean {
static hasSeriesSelectionFilter(node: GroupRule | Rule | undefined): boolean {
if (!node) return false;
if (node.type === 'rule') return node.field === 'series';
return node.rules.some((r) => BookQueryBuilder.hasSeriesFilter(r));
if (node.type === 'rule') return node.field === 'series' && node.operator !== 'isEmpty' && node.operator !== 'isNotEmpty';
return node.rules.some((r) => BookQueryBuilder.hasSeriesSelectionFilter(r));
}
static buildCollapseOrderBy(sort: SortSpec[], userId: number): string {
+40 -3
View File
@@ -2673,6 +2673,21 @@ describe('BookService', () => {
expect(bookRepo.findJumpBucketsCollapsed).not.toHaveBeenCalled();
});
it('executeJumpBucketsQuery keeps collapseSeries for a series presence filter', async () => {
const { service, bookRepo } = makeService();
bookRepo.findJumpBucketsCollapsed.mockResolvedValue({ buckets: [], total: 0 });
await service.executeJumpBucketsQuery(9, undefined, {
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
collapseSeries: true,
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'series', operator: 'isNotEmpty' }] },
} as never);
expect(bookRepo.findJumpBucketsCollapsed).toHaveBeenCalled();
expect(bookRepo.findJumpBuckets).not.toHaveBeenCalled();
});
it('queryForLibrary uses normal findCards when collapseSeries is false', async () => {
const { service, queryBuilder, bookRepo } = makeService();
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
@@ -4204,7 +4219,7 @@ describe('BookService', () => {
it('returns results from findCardsCollapsed when collapseSeries is true', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const hasSeriesFilterSpy = vi.spyOn(BookQueryBuilder, 'hasSeriesFilter').mockReturnValue(false);
const hasSeriesSelectionFilterSpy = vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
const query: BookQuery = {
pagination: { page: 1, size: 25 },
sort: [{ field: 'title', dir: 'asc' }],
@@ -4215,7 +4230,7 @@ describe('BookService', () => {
const result = await service.executeBooksQuery(12, 'where' as never, query);
expect(hasSeriesFilterSpy).toHaveBeenCalledWith(undefined);
expect(hasSeriesSelectionFilterSpy).toHaveBeenCalledWith(undefined);
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith({
where: 'where',
sort: query.sort,
@@ -4228,6 +4243,28 @@ describe('BookService', () => {
expect(result).toEqual({ items: [], total: 0, page: 1, size: 25 });
});
it('returns collapsed results when the filter only requires a series', async () => {
const { service, bookRepo } = makeService();
bookRepo.findCardsCollapsed.mockResolvedValue(emptyCardQueryResult);
const filter = {
type: 'group' as const,
join: 'AND' as const,
rules: [{ type: 'rule' as const, field: 'series' as const, operator: 'isNotEmpty' as const }],
};
await service.executeBooksQuery(12, 'where' as never, {
pagination: { page: 0, size: 50 },
sort: [{ field: 'title', dir: 'asc' }],
filter,
collapseSeries: true,
});
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith(
expect.objectContaining({ where: 'where', sort: [{ field: 'title', dir: 'asc' }], userId: 12 }),
);
expect(bookRepo.findCards).not.toHaveBeenCalled();
});
it('does not call logger.warn when query completes fast', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: Logger }).logger, 'warn').mockImplementation(() => undefined);
@@ -4269,7 +4306,7 @@ describe('BookService', () => {
const { service, bookRepo } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: Logger }).logger, 'warn').mockImplementation(() => undefined);
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(600);
vi.spyOn(BookQueryBuilder, 'hasSeriesFilter').mockReturnValue(false);
vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
bookRepo.findCardsCollapsed.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(12, undefined, {
+2 -2
View File
@@ -1032,7 +1032,7 @@ export class BookService {
async executeBooksQuery(userId: number, where: SQL | undefined, query: BookQuery): Promise<BooksPage> {
const start = Date.now();
const { page, size } = query.pagination;
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesFilter(query.filter);
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesSelectionFilter(query.filter);
if (shouldCollapse) {
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
@@ -1124,7 +1124,7 @@ export class BookService {
const event = 'book.jump_buckets';
const kind = jumpBucketKindForSort(query.sort);
const primaryField = (query.sort[0] ?? { field: 'title', dir: 'asc' }).field;
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesFilter(query.filter);
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesSelectionFilter(query.filter);
const bucketExpr = shouldCollapse ? collapsedJumpBucketExpr(primaryField) : flatJumpBucketExpr(primaryField);
if (!kind || !bucketExpr) throw new BadRequestException('jump buckets are not available for this sort');
@@ -7,6 +7,7 @@ describe('resolveCollapsePreference', () => {
expect(resolveCollapsePreference(undefined, {})).toBe(false);
expect(resolveCollapsePreference(undefined, { libraryId: 1 })).toBe(false);
expect(resolveCollapsePreference(undefined, { collectionId: 5 })).toBe(false);
expect(resolveCollapsePreference(undefined, { smartScopeId: 8 })).toBe(false);
});
it('returns global value when no context is provided', () => {
@@ -37,6 +38,12 @@ describe('resolveCollapsePreference', () => {
expect(resolveCollapsePreference(prefs, { collectionId: 7 })).toBe(true);
});
it('returns smart scope override when present', () => {
const prefs: SeriesCollapsePreferences = { global: false, libraries: {}, collections: {}, smartScopes: { '8': true } };
expect(resolveCollapsePreference(prefs, { smartScopeId: 8 })).toBe(true);
expect(resolveCollapsePreference(prefs, { smartScopeId: 9 })).toBe(false);
});
it('prefers collection override over library override', () => {
const prefs: SeriesCollapsePreferences = {
global: false,
@@ -354,6 +354,7 @@ describe('SmartScopeService', () => {
sort: [{ field: 'author', dir: 'desc' }],
pagination: { page: 0, size: 50 },
q: 'needle',
collapseSeries: true,
});
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(
@@ -373,6 +374,7 @@ describe('SmartScopeService', () => {
sort: [{ field: 'author', dir: 'desc' }],
pagination: { page: 0, size: 50 },
q: 'needle',
collapseSeries: true,
});
});
@@ -33,6 +33,12 @@ describe('UpdateSeriesCollapsePreferencesDto', () => {
expect(errors).toHaveLength(0);
});
it('accepts smart scope overrides', async () => {
const dto = toDto({ smartScopes: { '8': true, '9': null } });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('rejects non-boolean/non-null values in libraries', async () => {
const dto = toDto({ libraries: { '1': 'yes' } });
const errors = await validate(dto);
@@ -45,6 +51,12 @@ describe('UpdateSeriesCollapsePreferencesDto', () => {
expect(errors.length).toBeGreaterThan(0);
});
it('rejects non-boolean values in smart scope overrides', async () => {
const dto = toDto({ smartScopes: { '8': 'yes' } });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('rejects array for libraries', async () => {
const dto = toDto({ libraries: [true, false] });
const errors = await validate(dto);
@@ -32,4 +32,8 @@ export class UpdateSeriesCollapsePreferencesDto {
@IsOptional()
@IsBooleanOrNullRecord()
collections?: Record<string, boolean | null>;
@IsOptional()
@IsBooleanOrNullRecord()
smartScopes?: Record<string, boolean | null>;
}
+21 -4
View File
@@ -614,7 +614,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: {
seriesCollapsePreferences: { global: true, libraries: {}, collections: {} },
seriesCollapsePreferences: { global: true, libraries: {}, collections: {}, smartScopes: {} },
},
});
});
@@ -631,7 +631,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: {
seriesCollapsePreferences: { global: true, libraries: { '3': true }, collections: { '7': false } },
seriesCollapsePreferences: { global: true, libraries: { '3': true }, collections: { '7': false }, smartScopes: {} },
},
});
});
@@ -648,7 +648,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: {
seriesCollapsePreferences: { global: false, libraries: { '1': true, '2': false }, collections: {} },
seriesCollapsePreferences: { global: false, libraries: { '1': true, '2': false }, collections: {}, smartScopes: {} },
},
});
});
@@ -665,7 +665,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: {
seriesCollapsePreferences: { global: true, libraries: {}, collections: { '5': false, '9': true } },
seriesCollapsePreferences: { global: true, libraries: {}, collections: { '5': false, '9': true }, smartScopes: {} },
},
});
});
@@ -689,4 +689,21 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
}),
);
});
it('merges smart scope overrides and removes null entries', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({
id: 1,
settings: {
seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, smartScopes: { '3': false, '8': true } },
},
});
await service.updateSeriesCollapsePreferences(1, { smartScopes: { '3': true, '8': null } });
expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: {
seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, smartScopes: { '3': true } },
},
});
});
});
+10 -1
View File
@@ -335,11 +335,17 @@ export class UserService {
async updateSeriesCollapsePreferences(userId: number, dto: UpdateSeriesCollapsePreferencesDto): Promise<void> {
const existing = await this.userRepo.findByIdWithPermissions(userId);
if (!existing) throw new NotFoundException('User not found');
const currentPrefs = (existing.settings as UserSettings)?.seriesCollapsePreferences ?? { global: false, libraries: {}, collections: {} };
const currentPrefs = (existing.settings as UserSettings)?.seriesCollapsePreferences ?? {
global: false,
libraries: {},
collections: {},
smartScopes: {},
};
const merged = {
global: dto.global !== undefined ? dto.global : currentPrefs.global,
libraries: { ...currentPrefs.libraries, ...(dto.libraries ?? {}) },
collections: { ...currentPrefs.collections, ...(dto.collections ?? {}) },
smartScopes: { ...(currentPrefs.smartScopes ?? {}), ...(dto.smartScopes ?? {}) },
};
// Remove entries set to null (deletion of overrides)
@@ -349,6 +355,9 @@ export class UserService {
for (const [k, v] of Object.entries(merged.collections)) {
if (v === null) delete merged.collections[k];
}
for (const [k, v] of Object.entries(merged.smartScopes)) {
if (v === null) delete merged.smartScopes[k];
}
await this.userRepo.update(userId, { settings: { seriesCollapsePreferences: merged } });
}