mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user