refactor(books): remove bulk tags dialog and add individual edit action

The standalone BulkUpdateTagsDialog and its related state have been
removed from the table shell and views. In its place, a new
handleEditIndividually action was added, allowing users to select
multiple books and open the first one in the detail view for sequential
editing. The SelectionActionBar was also updated to support this action
and display query-scoped selection states.
This commit is contained in:
neon
2026-06-13 05:05:33 +00:00
parent b48dac0e30
commit a0e6850d69
13 changed files with 255 additions and 363 deletions
+140 -104
View File
@@ -13,20 +13,36 @@ import {
MoreHorizontal,
Pencil,
RefreshCw,
SquareArrowOutUpRight,
SquarePen,
Star,
Tag,
Trash2,
Unlock,
X,
} from 'lucide-vue-next'
import InputWithSuggestions from '@/components/ui/InputWithSuggestions.vue'
import { usePublisherSearch, useSeriesNameSearch, useLanguageSearch } from '@/features/book/composables/useMetadataFieldSearch'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { STATUS_ICONS, STATUS_OPTIONS } from '@/features/book/composables/useBookStatus'
import type { ReadStatus } from '@bookorbit/types'
import type { BulkEditableField, BulkEditableValue, InFlightOp } from '@/features/book/composables/useBookBulkActions'
import {
BULK_EDITABLE_ARRAY_FIELDS,
BULK_EDITABLE_FIELD_OPTIONS,
type BulkEditableField,
type BulkEditableValue,
type InFlightOp,
} from '@/features/book/composables/useBookBulkActions'
export type ExportScope = 'primary' | 'all' | 'audio'
@@ -49,12 +65,15 @@ const props = defineProps<{
visible: boolean
inCollection?: boolean
inFlight?: InFlightOp | null
// Selection is query-scoped (all matching across pages), so individual ids aren't enumerable.
queryScoped?: boolean
}>()
const emit = defineEmits<{
'add-to-collection': []
'remove-from-collection': []
edit: []
'edit-individually': []
send: []
download: [scope: ExportScope]
'export-metadata': []
@@ -62,7 +81,6 @@ const emit = defineEmits<{
're-extract-cover': []
'set-status': [status: ReadStatus]
'set-rating': [rating: number | null]
'edit-tags': []
'set-field': [field: BulkEditableField, value: BulkEditableValue]
'lock-metadata': [locked: boolean]
delete: []
@@ -86,8 +104,9 @@ const canBulkActions = computed(() => !isDemoRestrictedAccount.value)
const canDownload = computed(() => hasPermission('library_download') && canBulkActions.value)
const canEditMetadata = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value)
const canShowMoreMenu = computed(() => canDownload.value || canEditMetadata.value)
const canShare = computed(() => hasPermission('email_send') || canDownload.value)
const numericFieldSelected = computed(() => bulkField.value === 'publishedYear')
const arrayFieldSelected = computed(() => ['authors', 'genres', 'tags', 'narrators'].includes(bulkField.value))
const arrayFieldSelected = computed(() => (BULK_EDITABLE_ARRAY_FIELDS as readonly string[]).includes(bulkField.value))
const fieldValuePlaceholder = computed(() => (arrayFieldSelected.value ? 'Comma-separated (leave blank to clear)' : 'Leave blank to clear'))
const typeaheadSearchFn = computed<((q: string) => Promise<string[]>) | null>(() => {
if (bulkField.value === 'seriesName') return searchSeriesName
@@ -146,11 +165,6 @@ function openFieldEditor() {
fieldMenuOpen.value = true
}
function onEditTags() {
if (props.count === 0) return
emit('edit-tags')
}
function onRefreshMetadata() {
if (props.count === 0) return
emit('refresh-metadata')
@@ -239,75 +253,6 @@ watch(
<div :class="DIVIDER" />
<Tooltip v-if="hasPermission('email_send')">
<TooltipTrigger as-child>
<button
data-testid="action-send-email"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('send')"
>
<Mail :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Send via email</TooltipContent>
</Tooltip>
<Tooltip v-if="canDownload">
<TooltipTrigger as-child>
<button
data-testid="action-download-files"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="exportMenuOpen = true"
>
<Download :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Download files as ZIP</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
data-testid="action-add-to-collection"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('add-to-collection')"
>
<FolderPlus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Add to collection</TooltipContent>
</Tooltip>
<Tooltip v-if="inCollection">
<TooltipTrigger as-child>
<button
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_DESTRUCTIVE : BTN_DISABLED]"
@click="emit('remove-from-collection')"
>
<FolderMinus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Remove from collection</TooltipContent>
</Tooltip>
<Tooltip v-if="canEditMetadata">
<TooltipTrigger as-child>
<button
data-testid="action-bulk-edit-metadata"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('edit')"
>
<Pencil :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Open metadata editor</TooltipContent>
</Tooltip>
<Tooltip v-if="canBulkActions">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0">
@@ -348,6 +293,95 @@ watch(
<TooltipContent side="top">Set rating</TooltipContent>
</Tooltip>
<div v-if="canEditMetadata" :class="DIVIDER" />
<Tooltip v-if="canEditMetadata">
<TooltipTrigger as-child>
<button
data-testid="action-bulk-edit-metadata"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('edit')"
>
<Pencil :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Open metadata editor</TooltipContent>
</Tooltip>
<Tooltip v-if="canEditMetadata && !queryScoped">
<TooltipTrigger as-child>
<button
data-testid="action-edit-individually"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('edit-individually')"
>
<SquareArrowOutUpRight :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Edit books individually</TooltipContent>
</Tooltip>
<div v-if="canBulkActions" :class="DIVIDER" />
<Tooltip>
<TooltipTrigger as-child>
<button
data-testid="action-add-to-collection"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('add-to-collection')"
>
<FolderPlus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Add to collection</TooltipContent>
</Tooltip>
<Tooltip v-if="inCollection">
<TooltipTrigger as-child>
<button
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_DESTRUCTIVE : BTN_DISABLED]"
@click="emit('remove-from-collection')"
>
<FolderMinus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Remove from collection</TooltipContent>
</Tooltip>
<div v-if="canShare" :class="DIVIDER" />
<Tooltip v-if="hasPermission('email_send')">
<TooltipTrigger as-child>
<button
data-testid="action-send-email"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('send')"
>
<Mail :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Send via email</TooltipContent>
</Tooltip>
<Tooltip v-if="canDownload">
<TooltipTrigger as-child>
<button
data-testid="action-download-files"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="exportMenuOpen = true"
>
<Download :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Download files as ZIP</TooltipContent>
</Tooltip>
<div v-if="canShowMoreMenu" :class="DIVIDER" />
<Tooltip v-if="canShowMoreMenu">
@@ -365,19 +399,12 @@ watch(
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" class="w-56">
<DropdownMenuItem v-if="canDownload" data-testid="action-export-metadata" @click="emit('export-metadata')">
<FileSpreadsheet :size="14" />
<span>Export metadata</span>
</DropdownMenuItem>
<template v-if="canEditMetadata">
<DropdownMenuItem data-testid="action-bulk-set-field" @click="openFieldEditor">
<Pencil :size="14" />
<SquarePen :size="14" />
<span>Set field</span>
</DropdownMenuItem>
<DropdownMenuItem data-testid="action-bulk-edit-tags" @click="onEditTags">
<Tag :size="14" />
<span>Edit tags</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem data-testid="action-bulk-refresh-metadata" @click="onRefreshMetadata">
<RefreshCw :size="14" />
<span>Refresh metadata</span>
@@ -386,13 +413,29 @@ watch(
<ImageDown :size="14" />
<span>Re-extract cover</span>
</DropdownMenuItem>
<DropdownMenuItem data-testid="action-bulk-lock-metadata" @click="lockAll">
<Lock :size="14" />
<span>Lock metadata</span>
</DropdownMenuItem>
<DropdownMenuItem data-testid="action-bulk-unlock-metadata" @click="unlockAll">
<Unlock :size="14" />
<span>Unlock metadata</span>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger data-testid="action-bulk-metadata-lock">
<Lock :size="14" />
<span>Metadata lock</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem data-testid="action-bulk-lock-metadata" @click="lockAll">
<Lock :size="14" />
<span>Lock all</span>
</DropdownMenuItem>
<DropdownMenuItem data-testid="action-bulk-unlock-metadata" @click="unlockAll">
<Unlock :size="14" />
<span>Unlock all</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</template>
<template v-if="canDownload">
<DropdownMenuSeparator v-if="canEditMetadata" />
<DropdownMenuItem data-testid="action-export-metadata" @click="emit('export-metadata')">
<FileSpreadsheet :size="14" />
<span>Export metadata</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
@@ -402,7 +445,7 @@ watch(
<TooltipContent side="top">More actions</TooltipContent>
</Tooltip>
<div :class="DIVIDER" />
<div v-if="hasPermission('library_delete_books')" :class="DIVIDER" />
<Tooltip v-if="hasPermission('library_delete_books')">
<TooltipTrigger as-child>
@@ -459,14 +502,7 @@ watch(
v-model="bulkField"
class="h-8 rounded-full border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="seriesName">Series Name</option>
<option value="publisher">Publisher</option>
<option value="language">Language</option>
<option value="publishedYear">Published Year</option>
<option value="authors">Authors</option>
<option value="genres">Genres</option>
<option value="tags">Tags</option>
<option value="narrators">Narrators</option>
<option v-for="opt in BULK_EDITABLE_FIELD_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<InputWithSuggestions
v-if="typeaheadSearchFn"
@@ -31,6 +31,10 @@ const globalStubs = {
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<button><slot /></button>' },
DropdownMenuSeparator: { template: '<div />' },
DropdownMenuSub: { template: '<div><slot /></div>' },
DropdownMenuSubTrigger: { template: '<button><slot /></button>' },
DropdownMenuSubContent: { template: '<div><slot /></div>' },
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
@@ -62,6 +66,7 @@ describe('SelectionActionBar demo restriction', () => {
expect(wrapper.find('[data-testid="action-download-files"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="action-export-metadata"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="action-bulk-edit-metadata"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="action-edit-individually"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="action-bulk-set-status"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="action-bulk-set-rating"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="action-bulk-metadata-menu"]').exists()).toBe(true)
@@ -72,6 +77,16 @@ describe('SelectionActionBar demo restriction', () => {
expect(wrapper.find('[data-testid="action-bulk-unlock-metadata"]').exists()).toBe(true)
})
it('hides edit-individually while the selection is query-scoped but keeps the bulk editor', () => {
const wrapper = mount(SelectionActionBar, {
props: { visible: true, count: 5000, inCollection: false, inFlight: null, queryScoped: true },
global: globalStubs,
})
expect(wrapper.find('[data-testid="action-edit-individually"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="action-bulk-edit-metadata"]').exists()).toBe(true)
})
it('keeps metadata export available in the more menu with download-only permission', () => {
permissionState.allowed = new Set(['library_download'])
const wrapper = mountBar()
@@ -98,12 +113,15 @@ describe('SelectionActionBar demo restriction', () => {
expect(wrapper.find('[data-testid="action-add-to-collection"]').exists()).toBe(true)
})
it('emits add/send/download/metadata-export/status/rating/tag actions from default controls', async () => {
it('emits add/send/download/metadata-export/status/rating actions from default controls', async () => {
const wrapper = mountBar()
await wrapper.find('[data-testid="action-add-to-collection"]').trigger('click')
expect(wrapper.emitted('add-to-collection')).toHaveLength(1)
await wrapper.find('[data-testid="action-edit-individually"]').trigger('click')
expect(wrapper.emitted('edit-individually')).toHaveLength(1)
await wrapper.find('[data-testid="action-send-email"]').trigger('click')
await wrapper.find('[data-testid="action-export-metadata"]').trigger('click')
await wrapper.find('[data-testid="action-download-files"]').trigger('click')
@@ -129,9 +147,6 @@ describe('SelectionActionBar demo restriction', () => {
.find((btn) => btn.text() === '5')
?.trigger('click')
expect(wrapper.emitted('set-rating')?.[0]).toEqual([5])
await wrapper.find('[data-testid="action-bulk-edit-tags"]').trigger('click')
expect(wrapper.emitted('edit-tags')).toHaveLength(1)
})
it('emits bulk metadata actions from Metadata menu', async () => {
@@ -8,6 +8,14 @@ import { useGenreSearch, useTagSearch } from '@/features/book/composables/useTag
import { useNarratorSearch } from '@/features/book/composables/useNarratorSearch'
import { usePublisherSearch, useSeriesNameSearch, useLanguageSearch } from '@/features/book/composables/useMetadataFieldSearch'
import type { BulkEditFields, ArrayMode } from '@/features/book/composables/useBulkEditMetadata'
import {
BULK_EDITABLE_ARRAY_FIELDS,
BULK_EDITABLE_SCALAR_FIELDS,
BULK_EDITABLE_FIELD_LABELS,
type BulkEditableArrayField,
type BulkEditableScalarField,
type BulkEditableField,
} from '@/features/book/composables/useBookBulkActions'
type UIArrayMode = ArrayMode | 'clear'
@@ -22,23 +30,14 @@ const emit = defineEmits<{
confirm: [fields: BulkEditFields]
}>()
const ARRAY_FIELDS = ['authors', 'genres', 'tags', 'narrators'] as const
const SCALAR_FIELDS = ['seriesName', 'publisher', 'language', 'publishedYear'] as const
const ARRAY_FIELDS = BULK_EDITABLE_ARRAY_FIELDS
const SCALAR_FIELDS = BULK_EDITABLE_SCALAR_FIELDS
type ArrayFieldKey = (typeof ARRAY_FIELDS)[number]
type ScalarFieldKey = (typeof SCALAR_FIELDS)[number]
type FieldKey = ArrayFieldKey | ScalarFieldKey
type ArrayFieldKey = BulkEditableArrayField
type ScalarFieldKey = BulkEditableScalarField
type FieldKey = BulkEditableField
const FIELD_LABELS: Record<FieldKey, string> = {
authors: 'Authors',
genres: 'Genres',
tags: 'Tags',
narrators: 'Narrators',
seriesName: 'Series',
publisher: 'Publisher',
language: 'Language',
publishedYear: 'Year',
}
const FIELD_LABELS = BULK_EDITABLE_FIELD_LABELS
const MODE_LABELS: Record<UIArrayMode, string> = {
add: 'Add',
@@ -1,124 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
type TagMode = 'add' | 'remove' | 'replace'
const props = defineProps<{
open: boolean
bookCount: number
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
confirm: [mode: TagMode, tags: string[]]
}>()
const mode = ref<TagMode>('add')
const tagInput = ref('')
const parsedTags = computed(() =>
tagInput.value
.split(',')
.map((t) => t.trim())
.filter(Boolean),
)
const canConfirm = computed(() => mode.value === 'replace' || parsedTags.value.length > 0)
const modeLabel: Record<TagMode, string> = {
add: 'Add tags',
remove: 'Remove tags',
replace: 'Replace all',
}
const modeDescription: Record<TagMode, string> = {
add: 'Merge these tags into existing tags on each selected book.',
remove: 'Remove these tags from each selected book where they exist.',
replace: 'Replace all existing tags on each selected book with these tags.',
}
function handleConfirm() {
if (!canConfirm.value) return
emit('confirm', mode.value, parsedTags.value)
handleClose()
}
function resetForm() {
tagInput.value = ''
mode.value = 'add'
}
function handleClose() {
emit('update:open', false)
resetForm()
}
watch(
() => props.open,
(open) => {
if (!open) resetForm()
},
)
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="handleClose" />
<div class="relative z-10 w-full max-w-sm mx-4 bg-card border border-border rounded-lg shadow-2xl p-6">
<h2 class="text-base font-semibold text-foreground mb-4">Edit tags - {{ bookCount }} book{{ bookCount === 1 ? '' : 's' }}</h2>
<div class="space-y-4">
<div class="flex gap-2">
<button
v-for="m in ['add', 'remove', 'replace'] as TagMode[]"
:key="m"
type="button"
:class="[
'flex-1 py-1.5 rounded-md text-sm font-medium transition-colors',
mode === m ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground',
]"
@click="mode = m"
>
{{ modeLabel[m] }}
</button>
</div>
<p class="text-sm text-muted-foreground">{{ modeDescription[mode] }}</p>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground" for="bulk-tag-input">Tags (comma-separated)</label>
<input
id="bulk-tag-input"
v-model="tagInput"
class="w-full h-9 rounded-md border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
:placeholder="mode === 'replace' ? 'Leave empty to clear all tags, or enter tags to replace them' : 'sci-fi, classics, favorites'"
/>
<p v-if="parsedTags.length > 0" class="text-xs text-muted-foreground">
{{ parsedTags.length }} tag{{ parsedTags.length === 1 ? '' : 's' }}: {{ parsedTags.join(', ') }}
</p>
<p v-else-if="mode === 'replace'" class="text-xs text-muted-foreground">Apply with no tags to clear all tags on the selected books.</p>
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button
type="button"
class="h-9 px-4 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="handleClose"
>
Cancel
</button>
<button
type="button"
:disabled="!canConfirm"
class="h-9 px-4 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
@click="handleConfirm"
>
Apply
</button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,52 +0,0 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import BulkUpdateTagsDialog from '../BulkUpdateTagsDialog.vue'
describe('BulkUpdateTagsDialog', () => {
it('allows replace mode to submit with no tags to clear all tags', async () => {
const wrapper = mount(BulkUpdateTagsDialog, {
props: {
open: true,
bookCount: 2,
},
global: {
stubs: {
Teleport: true,
},
},
})
const replaceButton = wrapper.findAll('button').find((button) => button.text() === 'Replace all')
await replaceButton!.trigger('click')
await nextTick()
const applyButton = wrapper.findAll('button').find((button) => button.text() === 'Apply')
await applyButton!.trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['replace', []]])
expect(wrapper.emitted('update:open')).toEqual([[false]])
})
it('resets draft state when the dialog closes externally', async () => {
const wrapper = mount(BulkUpdateTagsDialog, {
props: {
open: true,
bookCount: 1,
},
global: {
stubs: {
Teleport: true,
},
},
})
const input = wrapper.get('#bulk-tag-input')
await input.setValue('sci-fi')
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(wrapper.get('#bulk-tag-input').element).toHaveProperty('value', '')
})
})
@@ -326,28 +326,6 @@ describe('useBookBulkActions', () => {
expect(mocks.toastSuccess).toHaveBeenCalledWith('Rated 500 books 4/5')
})
it('sends bulk tag updates using query selection payloads', async () => {
mocks.api.mockResolvedValue({ ok: true })
const selectedIds = ref(new Set<number>())
const querySelection = ref<QuerySelectionState | null>(makeQuerySelection())
const { handleBulkUpdateTags } = useBookBulkActions(selectedIds, vi.fn(), undefined, undefined, querySelection)
await handleBulkUpdateTags('add', ['favorite'])
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/books/bulk-update-tags',
expect.objectContaining({
body: JSON.stringify({
query: { libraryId: 5, filter: { type: 'group', join: 'AND', rules: [] }, q: 'space opera' },
mode: 'add',
tags: ['favorite'],
}),
}),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('Added tags to 500 books')
})
it('sends bulk field updates using query selection payloads', async () => {
mocks.api.mockResolvedValue({ ok: true })
const selectedIds = ref(new Set<number>())
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
promptDelete: vi.fn<(id: number) => void>(),
cancelDelete: vi.fn<() => void>(),
confirmDelete: vi.fn<() => void>(),
routerPush: vi.fn<(to: unknown) => void>(),
setBookContext: vi.fn<(ids: number[], total: number) => void>(),
useBookBulkActions: vi.fn<(...args: unknown[]) => object>(() => ({
handleBulkRefreshMetadata: vi.fn<() => void>(),
handleBulkReExtractCover: vi.fn<() => void>(),
@@ -19,7 +21,6 @@ const mocks = vi.hoisted(() => ({
handleBulkSetRating: vi.fn<() => void>(),
handleBulkSetField: vi.fn<() => void>(),
handleBulkSetMetadataLock: vi.fn<() => void>(),
handleBulkUpdateTags: vi.fn<() => void>(),
handleDeleteSelected: vi.fn<() => void>(),
handleDownloadFiles: vi.fn<() => void>(),
inFlight: { value: null },
@@ -27,6 +28,14 @@ const mocks = vi.hoisted(() => ({
useDeleteBookCallback: null as ((id: number) => void) | null,
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mocks.routerPush }),
}))
vi.mock('../useBookNavigation', () => ({
useBookNavigation: () => ({ setBookContext: mocks.setBookContext }),
}))
vi.mock('../useTableViewControls', () => ({
useTableViewControls: () => ({ tableRef: { value: null } }),
}))
@@ -104,6 +113,8 @@ describe('useBookTableShell', () => {
mocks.cancelDelete.mockClear()
mocks.confirmDelete.mockClear()
mocks.useBookBulkActions.mockClear()
mocks.routerPush.mockClear()
mocks.setBookContext.mockClear()
})
it('wires useBookBulkActions without an onBulkRefreshCompleted callback', () => {
@@ -186,6 +197,32 @@ describe('useBookTableShell', () => {
})
})
describe('handleEditIndividually', () => {
it('opens the topmost selected book on the details tab with the selection as context', () => {
mockSelectedIds.value = new Set([30, 10])
const books = ref([makeBook({ id: 10 }), makeBook({ id: 20 }), makeBook({ id: 30 })])
const { handleEditIndividually } = useBookTableShell({ books })
handleEditIndividually()
expect(mocks.setBookContext).toHaveBeenCalledWith([10, 30], 2)
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 10 }, query: { tab: 'details' } })
expect(mocks.exitSelectionMode).toHaveBeenCalledOnce()
})
it('does nothing when no selected book is loaded in the list', () => {
mockSelectedIds.value = new Set([999])
const books = ref([makeBook({ id: 10 })])
const { handleEditIndividually } = useBookTableShell({ books })
handleEditIndividually()
expect(mocks.setBookContext).not.toHaveBeenCalled()
expect(mocks.routerPush).not.toHaveBeenCalled()
expect(mocks.exitSelectionMode).not.toHaveBeenCalled()
})
})
describe('handleTableBookUpdate', () => {
it('replaces the matching book in the list', () => {
const books = ref([makeBook({ id: 1, title: 'Original' }), makeBook({ id: 2, title: 'Other' })])
@@ -279,7 +316,6 @@ describe('useBookTableShell', () => {
const shell = useBookTableShell({ books })
expect(shell.addToCollectionOpen.value).toBe(false)
expect(shell.bulkTagsOpen.value).toBe(false)
expect(shell.sendBookOpen.value).toBe(false)
expect(shell.quickViewOpen.value).toBe(false)
expect(shell.quickViewBookId.value).toBeNull()
@@ -84,7 +84,7 @@ describe('useBookViewContext', () => {
makeBook(5002),
])
const total = ref(5002)
const loadMore = vi.fn(async () => {})
const loadMore = vi.fn<() => Promise<void>>(async () => {})
const Harness = defineComponent({
setup() {
@@ -19,8 +19,30 @@ import { useBookRefreshFeedback } from './useBookRefreshFeedback'
import { detectChangedColumns, mergeBookCardWithDetail } from '@/features/book/lib/book-card-mapper'
export type ExportScope = 'primary' | 'all' | 'audio'
export type BulkEditableField = 'seriesName' | 'publisher' | 'language' | 'publishedYear' | 'authors' | 'genres' | 'tags' | 'narrators'
export const BULK_EDITABLE_ARRAY_FIELDS = ['authors', 'genres', 'tags', 'narrators'] as const
export const BULK_EDITABLE_SCALAR_FIELDS = ['seriesName', 'publisher', 'language', 'publishedYear'] as const
export type BulkEditableArrayField = (typeof BULK_EDITABLE_ARRAY_FIELDS)[number]
export type BulkEditableScalarField = (typeof BULK_EDITABLE_SCALAR_FIELDS)[number]
export type BulkEditableField = BulkEditableArrayField | BulkEditableScalarField
export type BulkEditableValue = string | number | string[] | null
export const BULK_EDITABLE_FIELD_LABELS: Record<BulkEditableField, string> = {
seriesName: 'Series',
publisher: 'Publisher',
language: 'Language',
publishedYear: 'Year',
authors: 'Authors',
genres: 'Genres',
tags: 'Tags',
narrators: 'Narrators',
}
export const BULK_EDITABLE_FIELD_OPTIONS: { value: BulkEditableField; label: string }[] = [
...BULK_EDITABLE_SCALAR_FIELDS,
...BULK_EDITABLE_ARRAY_FIELDS,
].map((value) => ({ value, label: BULK_EDITABLE_FIELD_LABELS[value] }))
const BULK_FIELD_LOCK_FIELD: Record<BulkEditableField, BookMetadataLockField> = {
seriesName: 'seriesName',
publisher: 'publisher',
@@ -296,23 +318,6 @@ export function useBookBulkActions(
toast.success(label)
}
async function handleBulkUpdateTags(mode: 'add' | 'remove' | 'replace', tags: string[]) {
if (!hasSelection()) return
const ids = querySelection?.value ? [] : [...selectedIds.value]
const res = await api('/api/v1/books/bulk-update-tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...getSelectionPayload(), mode, tags }),
})
if (!res.ok) {
toast.error('Failed to update tags')
return
}
const verb = mode === 'add' ? 'Added tags to' : mode === 'remove' ? 'Removed tags from' : 'Replaced tags on'
const count = querySelection?.value ? querySelection.value.total : ids.length
toast.success(`${verb} ${count} book${count === 1 ? '' : 's'}`)
}
async function handleBulkSetField(field: BulkEditableField, value: BulkEditableValue) {
if (!hasSelection()) return
const ids = querySelection?.value ? [] : [...selectedIds.value]
@@ -415,7 +420,6 @@ export function useBookBulkActions(
handleDownloadFiles,
handleBulkSetStatus,
handleBulkSetRating,
handleBulkUpdateTags,
handleBulkSetField,
handleBulkSetMetadataLock,
handleDeleteSelected,
@@ -1,8 +1,10 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import { useRouter } from 'vue-router'
import type { BookCard } from '@bookorbit/types'
import { useTableViewControls } from './useTableViewControls'
import { useBookViewSelection } from './useBookViewSelection'
import { useBookNavigation } from './useBookNavigation'
import { useDeleteBook } from './useDeleteBook'
import { useBookBulkActions, type QuerySelectionState } from './useBookBulkActions'
@@ -18,6 +20,8 @@ interface BookTableShellOptions {
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
export function useBookTableShell({ books, querySelection }: BookTableShellOptions) {
const router = useRouter()
const { setBookContext } = useBookNavigation()
const tableControls = useTableViewControls()
const selection = useBookViewSelection(books)
@@ -44,7 +48,6 @@ export function useBookTableShell({ books, querySelection }: BookTableShellOptio
)
const addToCollectionOpen = ref(false)
const bulkTagsOpen = ref(false)
const bulkEditOpen = ref(false)
const sendBookOpen = ref(false)
const quickViewBookId = ref<number | null>(null)
@@ -78,6 +81,14 @@ export function useBookTableShell({ books, querySelection }: BookTableShellOptio
books.value = books.value.map((book, currentIndex) => (currentIndex === index ? updated : book))
}
function handleEditIndividually(): void {
const orderedIds = books.value.filter((book) => selection.selectedIds.value.has(book.id)).map((book) => book.id)
if (orderedIds.length === 0) return
setBookContext(orderedIds, orderedIds.length)
router.push({ name: 'book-detail', params: { bookId: orderedIds[0] }, query: { tab: 'details' } })
selection.exitSelectionMode()
}
return {
...tableControls,
...selection,
@@ -88,12 +99,12 @@ export function useBookTableShell({ books, querySelection }: BookTableShellOptio
confirmDelete,
...bulk,
addToCollectionOpen,
bulkTagsOpen,
bulkEditOpen,
sendBookOpen,
quickViewBookId,
quickViewOpen,
handleBookAction,
handleTableBookUpdate,
handleEditIndividually,
}
}
+2 -6
View File
@@ -12,7 +12,6 @@ import BookQuickView from '@/features/book/components/BookQuickView.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import BulkUpdateTagsDialog from '@/features/book/components/BulkUpdateTagsDialog.vue'
import BulkEditMetadataDialog from '@/features/book/components/BulkEditMetadataDialog.vue'
import MetadataExportDialog from '@/features/book/components/MetadataExportDialog.vue'
import EditCollectionDialog from '@/features/collection/components/EditCollectionDialog.vue'
@@ -195,18 +194,17 @@ const {
handleDownloadFiles,
handleBulkSetStatus,
handleBulkSetRating,
handleBulkUpdateTags,
handleBulkSetField,
handleBulkSetMetadataLock,
handleDeleteSelected,
addToCollectionOpen,
bulkTagsOpen,
bulkEditOpen,
sendBookOpen,
quickViewBookId,
quickViewOpen,
handleBookAction,
handleTableBookUpdate,
handleEditIndividually,
} = useBookTableShell({
books,
})
@@ -365,11 +363,11 @@ watch(collectionId, async () => {
@add-to-collection="addToCollectionOpen = true"
@remove-from-collection="handleRemoveFromCollection"
@edit="handleEditSelected"
@edit-individually="handleEditIndividually"
@refresh-metadata="handleBulkRefreshMetadata"
@re-extract-cover="handleBulkReExtractCover"
@set-status="handleBulkSetStatus"
@set-rating="handleBulkSetRating"
@edit-tags="bulkTagsOpen = true"
@set-field="handleBulkSetField"
@lock-metadata="handleBulkSetMetadataLock"
@delete="handleDeleteSelected"
@@ -394,8 +392,6 @@ watch(collectionId, async () => {
@update:open="addToCollectionOpen = $event"
@done="exitSelectionMode"
/>
<BulkUpdateTagsDialog :open="bulkTagsOpen" :book-count="selectedCount" @update:open="bulkTagsOpen = $event" @confirm="handleBulkUpdateTags" />
<BulkEditMetadataDialog
:open="bulkEditOpen"
:book-count="bulkEditCount"
+3 -6
View File
@@ -28,7 +28,6 @@ import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import ViewHeader from '@/components/ViewHeader.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import BulkUpdateTagsDialog from '@/features/book/components/BulkUpdateTagsDialog.vue'
import BulkEditMetadataDialog from '@/features/book/components/BulkEditMetadataDialog.vue'
import { useBulkEditMetadata } from '@/features/book/composables/useBulkEditMetadata'
import type { BulkEditFields } from '@/features/book/composables/useBulkEditMetadata'
@@ -413,18 +412,17 @@ const {
handleDownloadFiles,
handleBulkSetStatus,
handleBulkSetRating,
handleBulkUpdateTags,
handleBulkSetField,
handleBulkSetMetadataLock,
handleDeleteSelected,
addToCollectionOpen,
bulkTagsOpen,
bulkEditOpen,
sendBookOpen,
quickViewBookId,
quickViewOpen,
handleBookAction,
handleTableBookUpdate,
handleEditIndividually,
} = useBookTableShell({
books,
querySelection,
@@ -960,16 +958,17 @@ async function handleToggleCollapse() {
:visible="selectionMode"
:count="querySelection ? querySelection.total : selectedCount"
:in-flight="inFlight"
:query-scoped="querySelection !== null"
@send="sendBookOpen = true"
@download="handleDownloadFiles"
@export-metadata="openMetadataExport(querySelection ? 'all-matching' : 'selected')"
@add-to-collection="addToCollectionOpen = true"
@edit="handleEditSelected"
@edit-individually="handleEditIndividually"
@refresh-metadata="handleBulkRefreshMetadata"
@re-extract-cover="handleBulkReExtractCover"
@set-status="handleBulkSetStatus"
@set-rating="handleBulkSetRating"
@edit-tags="bulkTagsOpen = true"
@set-field="handleBulkSetField"
@lock-metadata="handleBulkSetMetadataLock"
@delete="handleDeleteSelected"
@@ -996,8 +995,6 @@ async function handleToggleCollapse() {
@done="exitSelectionMode"
/>
<BulkUpdateTagsDialog :open="bulkTagsOpen" :book-count="selectedCount" @update:open="bulkTagsOpen = $event" @confirm="handleBulkUpdateTags" />
<BulkEditMetadataDialog
:open="bulkEditOpen"
:book-count="bulkEditCount"
+2 -6
View File
@@ -23,7 +23,6 @@ import ViewHeader from '@/components/ViewHeader.vue'
import SmartScopeEditorPanel from '@/features/smart-scope/components/SmartScopeEditorPanel.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import BulkUpdateTagsDialog from '@/features/book/components/BulkUpdateTagsDialog.vue'
import BulkEditMetadataDialog from '@/features/book/components/BulkEditMetadataDialog.vue'
import MetadataExportDialog from '@/features/book/components/MetadataExportDialog.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
@@ -202,18 +201,17 @@ const {
handleDownloadFiles,
handleBulkSetStatus,
handleBulkSetRating,
handleBulkUpdateTags,
handleBulkSetField,
handleBulkSetMetadataLock,
handleDeleteSelected,
addToCollectionOpen,
bulkTagsOpen,
bulkEditOpen,
sendBookOpen,
quickViewBookId,
quickViewOpen,
handleBookAction,
handleTableBookUpdate,
handleEditIndividually,
} = useBookTableShell({
books,
})
@@ -384,11 +382,11 @@ watch(smartScopeId, async () => {
@export-metadata="openMetadataExport"
@add-to-collection="addToCollectionOpen = true"
@edit="handleEditSelected"
@edit-individually="handleEditIndividually"
@refresh-metadata="handleBulkRefreshMetadata"
@re-extract-cover="handleBulkReExtractCover"
@set-status="handleBulkSetStatus"
@set-rating="handleBulkSetRating"
@edit-tags="bulkTagsOpen = true"
@set-field="handleBulkSetField"
@lock-metadata="handleBulkSetMetadataLock"
@delete="handleDeleteSelected"
@@ -413,8 +411,6 @@ watch(smartScopeId, async () => {
@update:open="addToCollectionOpen = $event"
@done="exitSelectionMode"
/>
<BulkUpdateTagsDialog :open="bulkTagsOpen" :book-count="selectedCount" @update:open="bulkTagsOpen = $event" @confirm="handleBulkUpdateTags" />
<BulkEditMetadataDialog
:open="bulkEditOpen"
:book-count="bulkEditCount"