mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(client): modernize the entity manager browse interface
Split the monolithic browse table into focused components for the toolbar, data grid, sortable headers, and pager. Add support for compact and comfortable row densities, improve pagination navigation, and refine the entity type selector layout.
This commit is contained in:
@@ -374,6 +374,14 @@ export function useEntityManager() {
|
||||
selectionAnchorSelected.value = shouldSelect
|
||||
}
|
||||
|
||||
function setSelection(ids: (number | string)[], selected: boolean): void {
|
||||
const newSet = new Set(selectedIds.value)
|
||||
ids.forEach((id) => setItemSelected(newSet, id, selected))
|
||||
selectedIds.value = newSet
|
||||
selectionAnchorId.value = null
|
||||
selectionAnchorSelected.value = false
|
||||
}
|
||||
|
||||
function rangeSelectTo(targetId: number | string): void {
|
||||
if (selectionAnchorId.value === null) {
|
||||
toggleSelection(targetId)
|
||||
@@ -467,6 +475,7 @@ export function useEntityManager() {
|
||||
selectedItemsMap,
|
||||
toggleSelection,
|
||||
rangeSelectTo,
|
||||
setSelection,
|
||||
removeFromSelection,
|
||||
clearSelection,
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
|
||||
import { ENTITY_PAGE_SIZES } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [value: number]
|
||||
'update:pageSize': [value: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const from = computed(() => (props.total === 0 ? 0 : (props.page - 1) * props.pageSize + 1))
|
||||
const to = computed(() => Math.min(props.page * props.pageSize, props.total))
|
||||
|
||||
/** Page numbers around the current page, with -1 marking a collapsed gap. */
|
||||
const pageNumbers = computed<number[]>(() => {
|
||||
const last = props.totalPages
|
||||
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1)
|
||||
|
||||
const around = [props.page - 1, props.page, props.page + 1].filter((p) => p > 1 && p < last)
|
||||
const pages = [1, ...around, last]
|
||||
|
||||
return pages.flatMap((page, index) => {
|
||||
const previous = pages[index - 1]
|
||||
return previous !== undefined && page - previous > 1 ? [-1, page] : [page]
|
||||
})
|
||||
})
|
||||
|
||||
function handlePrevPage(): void {
|
||||
emit('update:page', props.page - 1)
|
||||
}
|
||||
|
||||
function handleNextPage(): void {
|
||||
emit('update:page', props.page + 1)
|
||||
}
|
||||
|
||||
function handleGoToPage(page: number): void {
|
||||
emit('update:page', page)
|
||||
}
|
||||
|
||||
function handlePageSize(size: number): void {
|
||||
emit('update:pageSize', size)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-none flex-wrap items-center gap-x-3 gap-y-2 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="entity-rows-per-page" class="text-xs text-muted-foreground">{{ t('tools.entityManager.browse.rowsPerPage') }}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-card px-2.5 text-xs font-semibold text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-labelledby="entity-rows-per-page"
|
||||
>
|
||||
{{ formatNumber(pageSize) }}
|
||||
<ChevronDown :size="13" class="text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="min-w-20">
|
||||
<DropdownMenuItem v-for="size in ENTITY_PAGE_SIZES" :key="size" @click="handlePageSize(size)">
|
||||
{{ formatNumber(size) }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ t('tools.entityManager.browse.range', { from: formatNumber(from), to: formatNumber(to), total: formatNumber(total) }) }}
|
||||
</p>
|
||||
|
||||
<nav v-if="totalPages > 1" class="ms-auto flex items-center gap-1" :aria-label="t('tools.entityManager.browse.pagination')">
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:disabled="page <= 1"
|
||||
:aria-label="t('tools.entityManager.browse.previousPage')"
|
||||
@click="handlePrevPage"
|
||||
>
|
||||
<ChevronLeft :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<template v-for="(entry, index) in pageNumbers" :key="`${entry}-${index}`">
|
||||
<span v-if="entry === -1" class="px-1 text-xs text-muted-foreground" :aria-label="t('tools.entityManager.browse.morePages')">…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="grid h-8 min-w-8 place-items-center rounded-md border px-2 text-xs font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="
|
||||
entry === page
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
"
|
||||
:aria-label="t('tools.entityManager.browse.goToPage', { page: formatNumber(entry) })"
|
||||
:aria-current="entry === page ? 'page' : undefined"
|
||||
@click="handleGoToPage(entry)"
|
||||
>
|
||||
{{ formatNumber(entry) }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:disabled="page >= totalPages"
|
||||
:aria-label="t('tools.entityManager.browse.nextPage')"
|
||||
@click="handleNextPage"
|
||||
>
|
||||
<ChevronRight :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronLeft, ChevronRight, GitMerge, MoreHorizontal, Pencil, Search, Scissors, Trash2, X } from '@lucide/vue'
|
||||
import type {
|
||||
BrowseEntityBookCountFilter,
|
||||
BrowseEntityItem,
|
||||
@@ -9,9 +7,11 @@ import type {
|
||||
BrowseEntitySortOrder,
|
||||
EntityTypeCapabilities,
|
||||
} from '@bookorbit/types'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
type BrowseSortOption = 'name-asc' | 'name-desc' | 'bookCount-asc' | 'bookCount-desc'
|
||||
import EntityBrowsePager from './EntityBrowsePager.vue'
|
||||
import EntityBrowseToolbar from './EntityBrowseToolbar.vue'
|
||||
import EntityDataGrid from './EntityDataGrid.vue'
|
||||
import type { EntityRowDensity } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
items: BrowseEntityItem[]
|
||||
@@ -23,67 +23,65 @@ const props = defineProps<{
|
||||
sortBy: BrowseEntitySortBy
|
||||
sortOrder: BrowseEntitySortOrder
|
||||
bookCount: BrowseEntityBookCountFilter
|
||||
density: EntityRowDensity
|
||||
loading: boolean
|
||||
selectedIds: Set<number | string>
|
||||
capabilities: EntityTypeCapabilities
|
||||
isInline: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [value: number]
|
||||
'update:pageSize': [value: number]
|
||||
'update:search': [value: string]
|
||||
'update:bookCount': [value: BrowseEntityBookCountFilter]
|
||||
'update:density': [value: EntityRowDensity]
|
||||
sortChange: [sortBy: BrowseEntitySortBy, sortOrder: BrowseEntitySortOrder]
|
||||
select: [id: number | string, event: MouseEvent]
|
||||
toggleAll: [selected: boolean]
|
||||
rename: [item: BrowseEntityItem]
|
||||
delete: [item: BrowseEntityItem]
|
||||
split: [item: BrowseEntityItem]
|
||||
bulkDelete: []
|
||||
bulkMerge: []
|
||||
clearSelection: []
|
||||
split: [item: BrowseEntityItem]
|
||||
refresh: []
|
||||
clearFilters: []
|
||||
}>()
|
||||
|
||||
function handleSearchInput(event: Event): void {
|
||||
emit('update:search', (event.target as HTMLInputElement).value)
|
||||
const hasActiveFilters = computed(() => props.search.length > 0 || props.bookCount === 'empty')
|
||||
|
||||
function handleUpdatePage(value: number): void {
|
||||
emit('update:page', value)
|
||||
}
|
||||
|
||||
function handleSortChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value as BrowseSortOption
|
||||
switch (value) {
|
||||
case 'name-desc':
|
||||
emit('sortChange', 'name', 'desc')
|
||||
break
|
||||
case 'bookCount-asc':
|
||||
emit('sortChange', 'bookCount', 'asc')
|
||||
break
|
||||
case 'bookCount-desc':
|
||||
emit('sortChange', 'bookCount', 'desc')
|
||||
break
|
||||
default:
|
||||
emit('sortChange', 'name', 'asc')
|
||||
break
|
||||
}
|
||||
function handleUpdatePageSize(value: number): void {
|
||||
emit('update:pageSize', value)
|
||||
}
|
||||
|
||||
function handleEmptyOnlyChange(event: Event): void {
|
||||
emit('update:bookCount', (event.target as HTMLInputElement).checked ? 'empty' : 'any')
|
||||
function handleUpdateSearch(value: string): void {
|
||||
emit('update:search', value)
|
||||
}
|
||||
|
||||
function handlePrevPage(): void {
|
||||
emit('update:page', props.page - 1)
|
||||
function handleUpdateBookCount(value: BrowseEntityBookCountFilter): void {
|
||||
emit('update:bookCount', value)
|
||||
}
|
||||
|
||||
function handleNextPage(): void {
|
||||
emit('update:page', props.page + 1)
|
||||
function handleUpdateDensity(value: EntityRowDensity): void {
|
||||
emit('update:density', value)
|
||||
}
|
||||
|
||||
function handleSelectItem(id: number | string, event: MouseEvent): void {
|
||||
function handleSortChange(sortBy: BrowseEntitySortBy, sortOrder: BrowseEntitySortOrder): void {
|
||||
emit('sortChange', sortBy, sortOrder)
|
||||
}
|
||||
|
||||
function handleSelect(id: number | string, event: MouseEvent): void {
|
||||
emit('select', id, event)
|
||||
}
|
||||
|
||||
function handleToggleAll(selected: boolean): void {
|
||||
emit('toggleAll', selected)
|
||||
}
|
||||
|
||||
function handleRename(item: BrowseEntityItem): void {
|
||||
emit('rename', item)
|
||||
}
|
||||
@@ -108,162 +106,56 @@ function handleClearSelection(): void {
|
||||
emit('clearSelection')
|
||||
}
|
||||
|
||||
const hasSelection = computed(() => props.selectedIds.size > 0)
|
||||
const canMerge = computed(() => props.selectedIds.size >= 2)
|
||||
const sortValue = computed<BrowseSortOption>(() => `${props.sortBy}-${props.sortOrder}` as BrowseSortOption)
|
||||
const emptyOnly = computed(() => props.bookCount === 'empty')
|
||||
function handleClearFilters(): void {
|
||||
emit('clearFilters')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full border border-border rounded-lg overflow-hidden">
|
||||
<!-- Sticky header -->
|
||||
<div class="flex-none p-2 border-b border-border">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-3 sm:pr-4">
|
||||
<div class="relative flex-1 sm:max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
:value="search"
|
||||
:placeholder="t('tools.entityManager.browse.searchPlaceholder')"
|
||||
class="w-full h-9 pl-9 pr-3 rounded-lg border border-border bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
:value="sortValue"
|
||||
class="h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="name-asc">{{ t('tools.entityManager.browse.sort.nameAsc') }}</option>
|
||||
<option value="name-desc">{{ t('tools.entityManager.browse.sort.nameDesc') }}</option>
|
||||
<option value="bookCount-asc">{{ t('tools.entityManager.browse.sort.fewestBooks') }}</option>
|
||||
<option value="bookCount-desc">{{ t('tools.entityManager.browse.sort.mostBooks') }}</option>
|
||||
</select>
|
||||
<label v-if="!isInline" class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-2 text-sm text-muted-foreground">
|
||||
<input type="checkbox" class="rounded accent-primary" :checked="emptyOnly" @change="handleEmptyOnlyChange" />
|
||||
<span>{{ t('tools.entityManager.browse.emptyOnly') }}</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="hasSelection"
|
||||
class="inline-flex items-center gap-1 h-8 px-2 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors mr-1"
|
||||
@click="handleClearSelection"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
<span class="text-sm font-medium">{{ t('tools.entityManager.browse.clear') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canMerge"
|
||||
class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
@click="handleBulkMerge"
|
||||
>
|
||||
<GitMerge class="h-3.5 w-3.5" />
|
||||
{{ t('tools.entityManager.browse.mergeCount', { count: selectedIds.size }) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasSelection"
|
||||
class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors"
|
||||
@click="handleBulkDelete"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
{{ t('tools.entityManager.browse.deleteCount', { count: selectedIds.size }) }}
|
||||
</button>
|
||||
<span class="text-sm text-muted-foreground">{{ t('tools.entityManager.browse.totalCount', { count: total }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex-none pb-3">
|
||||
<EntityBrowseToolbar
|
||||
:search="search"
|
||||
:book-count="bookCount"
|
||||
:total="total"
|
||||
:density="density"
|
||||
:selected-count="selectedIds.size"
|
||||
:is-inline="isInline"
|
||||
@update:search="handleUpdateSearch"
|
||||
@update:book-count="handleUpdateBookCount"
|
||||
@update:density="handleUpdateDensity"
|
||||
@bulk-merge="handleBulkMerge"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@clear-selection="handleClearSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content area -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0 divide-y divide-border">
|
||||
<div v-if="loading" class="text-center py-8 text-muted-foreground text-sm">{{ t('common.loading') }}</div>
|
||||
<EntityDataGrid
|
||||
:items="items"
|
||||
:selected-ids="selectedIds"
|
||||
:capabilities="capabilities"
|
||||
:is-inline="isInline"
|
||||
:loading="loading"
|
||||
:density="density"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
@select="handleSelect"
|
||||
@toggle-all="handleToggleAll"
|
||||
@sort-change="handleSortChange"
|
||||
@rename="handleRename"
|
||||
@delete="handleDelete"
|
||||
@split="handleSplit"
|
||||
@clear-filters="handleClearFilters"
|
||||
/>
|
||||
|
||||
<div v-else-if="items.length === 0" class="text-center py-8 text-muted-foreground text-sm">
|
||||
{{ t('tools.entityManager.browse.noEntities') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="item in items" :key="String(item.id)" class="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(item.id)"
|
||||
class="rounded accent-primary shrink-0"
|
||||
@click="handleSelectItem(item.id, $event)"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm font-medium truncate block">{{ item.name }}</span>
|
||||
<div v-if="item.sortName" class="text-xs text-muted-foreground">
|
||||
{{ t('tools.entityManager.browse.sortLabel', { sortName: item.sortName }) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground shrink-0">{{ t('tools.entityManager.bookCount', { count: item.bookCount }) }}</span>
|
||||
<!-- Desktop: inline action buttons -->
|
||||
<div class="hidden sm:flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
class="h-7 px-2 text-xs rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click.stop="handleRename(item)"
|
||||
>
|
||||
{{ t('tools.entityManager.actions.rename') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="capabilities.canSplit && !isInline"
|
||||
class="h-7 px-2 text-xs rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click.stop="handleSplit(item)"
|
||||
>
|
||||
{{ t('tools.entityManager.actions.split') }}
|
||||
</button>
|
||||
<button
|
||||
class="h-7 px-2 text-xs rounded hover:bg-muted text-destructive hover:text-destructive transition-colors"
|
||||
@click.stop="handleDelete(item)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Mobile: dropdown menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="sm:hidden p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0">
|
||||
<MoreHorizontal class="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="handleRename(item)">
|
||||
<Pencil class="size-4 mr-2" />
|
||||
{{ t('tools.entityManager.actions.rename') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="capabilities.canSplit && !isInline" @click="handleSplit(item)">
|
||||
<Scissors class="size-4 mr-2" />
|
||||
{{ t('tools.entityManager.actions.split') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="handleDelete(item)">
|
||||
<Trash2 class="size-4 mr-2" />
|
||||
{{ t('common.delete') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Sticky footer -->
|
||||
<div v-if="totalPages > 1" class="flex-none p-2 border-t border-border">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<button
|
||||
class="h-8 w-8 rounded-md border border-border flex items-center justify-center hover:bg-muted disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
:disabled="page <= 1"
|
||||
@click="handlePrevPage"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="text-sm text-muted-foreground">{{ page }} / {{ totalPages }}</span>
|
||||
<button
|
||||
class="h-8 w-8 rounded-md border border-border flex items-center justify-center hover:bg-muted disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
:disabled="page >= totalPages"
|
||||
@click="handleNextPage"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EntityBrowsePager
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
:total-pages="totalPages"
|
||||
@update:page="handleUpdatePage"
|
||||
@update:page-size="handleUpdatePageSize"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { GitMerge, Rows2, Rows3, Search, Trash2, X } from '@lucide/vue'
|
||||
import type { BrowseEntityBookCountFilter } from '@bookorbit/types'
|
||||
|
||||
import type { EntityRowDensity } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
search: string
|
||||
bookCount: BrowseEntityBookCountFilter
|
||||
total: number
|
||||
density: EntityRowDensity
|
||||
selectedCount: number
|
||||
isInline: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search': [value: string]
|
||||
'update:bookCount': [value: BrowseEntityBookCountFilter]
|
||||
'update:density': [value: EntityRowDensity]
|
||||
bulkMerge: []
|
||||
bulkDelete: []
|
||||
clearSelection: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasSelection = computed(() => props.selectedCount > 0)
|
||||
const canMerge = computed(() => props.selectedCount >= 2)
|
||||
const emptyOnly = computed(() => props.bookCount === 'empty')
|
||||
|
||||
function handleSearchInput(event: Event): void {
|
||||
emit('update:search', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function handleShowAll(): void {
|
||||
emit('update:bookCount', 'any')
|
||||
}
|
||||
|
||||
function handleShowEmpty(): void {
|
||||
emit('update:bookCount', 'empty')
|
||||
}
|
||||
|
||||
function handleComfortable(): void {
|
||||
emit('update:density', 'comfortable')
|
||||
}
|
||||
|
||||
function handleCompact(): void {
|
||||
emit('update:density', 'compact')
|
||||
}
|
||||
|
||||
function handleBulkMerge(): void {
|
||||
emit('bulkMerge')
|
||||
}
|
||||
|
||||
function handleBulkDelete(): void {
|
||||
emit('bulkDelete')
|
||||
}
|
||||
|
||||
function handleClearSelection(): void {
|
||||
emit('clearSelection')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative min-w-52 flex-1 sm:max-w-xs">
|
||||
<Search :size="16" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
:value="search"
|
||||
:placeholder="t('tools.entityManager.browse.searchPlaceholder')"
|
||||
:aria-label="t('tools.entityManager.browse.searchPlaceholder')"
|
||||
class="h-9 w-full rounded-lg border border-border bg-card ps-9 pe-3 text-sm text-foreground transition-shadow placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!isInline" class="inline-flex gap-0.5 rounded-lg bg-secondary p-0.5" role="group">
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 rounded-md px-3 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="emptyOnly ? 'text-muted-foreground hover:text-foreground' : 'bg-card text-foreground shadow-xs'"
|
||||
:aria-pressed="!emptyOnly"
|
||||
@click="handleShowAll"
|
||||
>
|
||||
{{ t('tools.entityManager.browse.filters.all') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 rounded-md px-3 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="emptyOnly ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="emptyOnly"
|
||||
@click="handleShowEmpty"
|
||||
>
|
||||
{{ t('tools.entityManager.browse.filters.noBooks') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ t('tools.entityManager.browse.resultCount', { count: total }) }}
|
||||
</span>
|
||||
|
||||
<div class="ms-auto flex items-center gap-2">
|
||||
<template v-if="hasSelection">
|
||||
<span class="text-xs font-semibold text-foreground tabular-nums">
|
||||
{{ t('tools.entityManager.browse.selectedCount', { count: selectedCount }) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canMerge"
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="handleBulkMerge"
|
||||
>
|
||||
<GitMerge :size="14" aria-hidden="true" />
|
||||
{{ t('tools.entityManager.actions.merge') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-destructive/30 px-3 text-xs font-semibold text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="handleBulkDelete"
|
||||
>
|
||||
<Trash2 :size="14" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('tools.entityManager.browse.clear')"
|
||||
@click="handleClearSelection"
|
||||
>
|
||||
<X :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-else class="inline-flex gap-0.5 rounded-lg bg-secondary p-0.5" role="group" :aria-label="t('tools.entityManager.browse.density.label')">
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 place-items-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="density === 'comfortable' ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="density === 'comfortable'"
|
||||
:aria-label="t('tools.entityManager.browse.density.comfortable')"
|
||||
:title="t('tools.entityManager.browse.density.comfortable')"
|
||||
@click="handleComfortable"
|
||||
>
|
||||
<Rows2 :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 place-items-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="density === 'compact' ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="density === 'compact'"
|
||||
:aria-label="t('tools.entityManager.browse.density.compact')"
|
||||
:title="t('tools.entityManager.browse.density.compact')"
|
||||
@click="handleCompact"
|
||||
>
|
||||
<Rows3 :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CircleSlash, MoreHorizontal, Pencil, Scissors, Search, Trash2 } from '@lucide/vue'
|
||||
import type { BrowseEntityItem, BrowseEntitySortBy, BrowseEntitySortOrder, EntityTypeCapabilities } from '@bookorbit/types'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
|
||||
import EntitySortableHeader from './EntitySortableHeader.vue'
|
||||
import type { EntityRowDensity } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
items: BrowseEntityItem[]
|
||||
selectedIds: Set<number | string>
|
||||
capabilities: EntityTypeCapabilities
|
||||
isInline: boolean
|
||||
loading: boolean
|
||||
density: EntityRowDensity
|
||||
sortBy: BrowseEntitySortBy
|
||||
sortOrder: BrowseEntitySortOrder
|
||||
hasActiveFilters: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: number | string, event: MouseEvent]
|
||||
toggleAll: [selected: boolean]
|
||||
sortChange: [sortBy: BrowseEntitySortBy, sortOrder: BrowseEntitySortOrder]
|
||||
rename: [item: BrowseEntityItem]
|
||||
delete: [item: BrowseEntityItem]
|
||||
split: [item: BrowseEntityItem]
|
||||
clearFilters: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const showSortName = computed(() => props.capabilities.hasSortName)
|
||||
const showStatus = computed(() => !props.isInline)
|
||||
const canSplit = computed(() => props.capabilities.canSplit && !props.isInline)
|
||||
const columnCount = computed(() => 4 + (showSortName.value ? 1 : 0) + (showStatus.value ? 1 : 0))
|
||||
|
||||
const allSelected = computed(() => props.items.length > 0 && props.items.every((item) => props.selectedIds.has(item.id)))
|
||||
const someSelected = computed(() => !allSelected.value && props.items.some((item) => props.selectedIds.has(item.id)))
|
||||
|
||||
const cellPadding = computed(() => (props.density === 'compact' ? 'py-1' : 'py-2'))
|
||||
const rowHeight = computed(() => (props.density === 'compact' ? 'h-8' : 'h-[46px]'))
|
||||
|
||||
function handleSort(field: BrowseEntitySortBy): void {
|
||||
if (props.sortBy === field) {
|
||||
emit('sortChange', field, props.sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
return
|
||||
}
|
||||
emit('sortChange', field, field === 'bookCount' ? 'desc' : 'asc')
|
||||
}
|
||||
|
||||
function handleToggleAll(event: Event): void {
|
||||
emit('toggleAll', (event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
function handleSelect(id: number | string, event: MouseEvent): void {
|
||||
emit('select', id, event)
|
||||
}
|
||||
|
||||
function handleRename(item: BrowseEntityItem): void {
|
||||
emit('rename', item)
|
||||
}
|
||||
|
||||
function handleDelete(item: BrowseEntityItem): void {
|
||||
emit('delete', item)
|
||||
}
|
||||
|
||||
function handleSplit(item: BrowseEntityItem): void {
|
||||
emit('split', item)
|
||||
}
|
||||
|
||||
function handleClearFilters(): void {
|
||||
emit('clearFilters')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<!-- Desktop data grid -->
|
||||
<table class="hidden w-full border-collapse text-sm md:table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="sticky top-0 z-10 w-10 border-b border-border bg-secondary px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-4 rounded accent-primary align-middle"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
:aria-label="t('tools.entityManager.browse.selectAllOnPage')"
|
||||
@change="handleToggleAll"
|
||||
/>
|
||||
</th>
|
||||
<EntitySortableHeader
|
||||
field="name"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
class="sticky top-0 z-10 border-b border-border bg-secondary"
|
||||
@sort="handleSort"
|
||||
>
|
||||
{{ t('tools.entityManager.browse.columns.name') }}
|
||||
</EntitySortableHeader>
|
||||
<th
|
||||
v-if="showSortName"
|
||||
scope="col"
|
||||
class="sticky top-0 z-10 border-b border-border bg-secondary px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{{ t('tools.entityManager.browse.columns.sortName') }}
|
||||
</th>
|
||||
<EntitySortableHeader
|
||||
field="bookCount"
|
||||
align="end"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
class="sticky top-0 z-10 w-24 border-b border-border bg-secondary"
|
||||
@sort="handleSort"
|
||||
>
|
||||
{{ t('tools.entityManager.browse.columns.books') }}
|
||||
</EntitySortableHeader>
|
||||
<th
|
||||
v-if="showStatus"
|
||||
scope="col"
|
||||
class="sticky top-0 z-10 w-32 border-b border-border bg-secondary px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{{ t('tools.entityManager.browse.columns.status') }}
|
||||
</th>
|
||||
<th scope="col" class="sticky top-0 z-10 w-32 border-b border-border bg-secondary px-3 py-2.5">
|
||||
<span class="sr-only">{{ t('tools.entityManager.browse.columns.actions') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr v-if="loading">
|
||||
<td :colspan="columnCount" class="px-3 py-10 text-center text-muted-foreground">{{ t('common.loading') }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="items.length === 0">
|
||||
<td :colspan="columnCount" class="px-3 py-14">
|
||||
<div class="flex flex-col items-center gap-3 text-center">
|
||||
<div class="rounded-full bg-muted p-3">
|
||||
<Search :size="20" class="text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ hasActiveFilters ? t('tools.entityManager.browse.noMatchesTitle') : t('tools.entityManager.browse.noEntities') }}
|
||||
</p>
|
||||
<p class="max-w-xs text-xs text-muted-foreground">
|
||||
{{
|
||||
hasActiveFilters ? t('tools.entityManager.browse.noMatchesDescription') : t('tools.entityManager.browse.noEntitiesDescription')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="handleClearFilters"
|
||||
>
|
||||
{{ t('tools.entityManager.browse.clearFilters') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
v-else
|
||||
:key="String(item.id)"
|
||||
class="group transition-colors hover:bg-accent focus-within:bg-accent"
|
||||
:class="[rowHeight, selectedIds.has(item.id) ? 'bg-primary/8' : '']"
|
||||
>
|
||||
<td class="px-3" :class="cellPadding">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-4 rounded accent-primary align-middle"
|
||||
:checked="selectedIds.has(item.id)"
|
||||
:aria-label="t('tools.entityManager.browse.selectEntity', { name: item.name })"
|
||||
@click="handleSelect(item.id, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td class="max-w-0 px-3 font-medium text-foreground" :class="cellPadding">
|
||||
<span class="block truncate">{{ item.name }}</span>
|
||||
</td>
|
||||
<td v-if="showSortName" class="max-w-0 px-3 text-muted-foreground" :class="cellPadding">
|
||||
<span v-if="item.sortName" class="block truncate">{{ item.sortName }}</span>
|
||||
<span v-else aria-hidden="true">-</span>
|
||||
</td>
|
||||
<td class="px-3 text-end tabular-nums" :class="[cellPadding, item.bookCount === 0 ? 'text-muted-foreground' : 'text-foreground']">
|
||||
{{ formatNumber(item.bookCount) }}
|
||||
</td>
|
||||
<td v-if="showStatus" class="px-3" :class="cellPadding">
|
||||
<span
|
||||
v-if="item.bookCount === 0"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-[var(--pill-warning)]/12 px-2 py-0.5 text-xs font-semibold text-[var(--pill-warning)]"
|
||||
>
|
||||
<CircleSlash :size="11" aria-hidden="true" />
|
||||
{{ t('tools.entityManager.browse.status.noBooks') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3" :class="cellPadding">
|
||||
<div class="flex items-center justify-end gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-surface-4 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('tools.entityManager.actions.rename')"
|
||||
:title="t('tools.entityManager.actions.rename')"
|
||||
@click.stop="handleRename(item)"
|
||||
>
|
||||
<Pencil :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canSplit"
|
||||
type="button"
|
||||
class="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-surface-4 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('tools.entityManager.actions.split')"
|
||||
:title="t('tools.entityManager.actions.split')"
|
||||
@click.stop="handleSplit(item)"
|
||||
>
|
||||
<Scissors :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('common.delete')"
|
||||
:title="t('common.delete')"
|
||||
@click.stop="handleDelete(item)"
|
||||
>
|
||||
<Trash2 :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Mobile card list -->
|
||||
<div class="divide-y divide-border md:hidden">
|
||||
<p v-if="loading" class="px-4 py-10 text-center text-sm text-muted-foreground">{{ t('common.loading') }}</p>
|
||||
<div v-else-if="items.length === 0" class="flex flex-col items-center gap-3 px-4 py-12 text-center">
|
||||
<div class="rounded-full bg-muted p-3">
|
||||
<Search :size="20" class="text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ hasActiveFilters ? t('tools.entityManager.browse.noMatchesTitle') : t('tools.entityManager.browse.noEntities') }}
|
||||
</p>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
@click="handleClearFilters"
|
||||
>
|
||||
{{ t('tools.entityManager.browse.clearFilters') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in items"
|
||||
v-else
|
||||
:key="String(item.id)"
|
||||
class="flex items-center gap-3 px-4 py-3"
|
||||
:class="selectedIds.has(item.id) ? 'bg-primary/8' : ''"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-4 shrink-0 rounded accent-primary"
|
||||
:checked="selectedIds.has(item.id)"
|
||||
:aria-label="t('tools.entityManager.browse.selectEntity', { name: item.name })"
|
||||
@click="handleSelect(item.id, $event)"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ item.name }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
<span v-if="item.sortName">{{ t('tools.entityManager.browse.sortLabel', { sortName: item.sortName }) }} · </span>
|
||||
<span :class="item.bookCount === 0 ? 'text-[var(--pill-warning)]' : ''">
|
||||
{{ t('tools.entityManager.bookCount', { count: item.bookCount }) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('tools.entityManager.browse.actionsFor', { name: item.name })"
|
||||
>
|
||||
<MoreHorizontal :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="handleRename(item)">
|
||||
<Pencil class="mr-2 size-4" aria-hidden="true" />
|
||||
{{ t('tools.entityManager.actions.rename') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="canSplit" @click="handleSplit(item)">
|
||||
<Scissors class="mr-2 size-4" aria-hidden="true" />
|
||||
{{ t('tools.entityManager.actions.split') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="handleDelete(item)">
|
||||
<Trash2 class="mr-2 size-4" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown } from '@lucide/vue'
|
||||
import type { BrowseEntitySortBy, BrowseEntitySortOrder } from '@bookorbit/types'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BrowseEntitySortBy
|
||||
sortBy: BrowseEntitySortBy
|
||||
sortOrder: BrowseEntitySortOrder
|
||||
align?: 'start' | 'end'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ sort: [field: BrowseEntitySortBy] }>()
|
||||
|
||||
const isActive = computed(() => props.sortBy === props.field)
|
||||
const ariaSort = computed(() => (isActive.value ? (props.sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'))
|
||||
const alignClass = computed(() => (props.align === 'end' ? 'justify-end' : 'justify-start'))
|
||||
|
||||
function requestSort(): void {
|
||||
emit('sort', props.field)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th scope="col" class="text-xs font-semibold uppercase tracking-wider" :class="align === 'end' ? 'text-end' : 'text-start'" :aria-sort="ariaSort">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 px-3 py-2.5 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
:class="[alignClass, isActive ? 'text-foreground' : 'text-muted-foreground']"
|
||||
@click="requestSort"
|
||||
>
|
||||
<slot />
|
||||
<ArrowUp v-if="isActive && sortOrder === 'asc'" :size="12" class="text-primary" aria-hidden="true" />
|
||||
<ArrowDown v-else-if="isActive" :size="12" class="text-primary" aria-hidden="true" />
|
||||
<ChevronsUpDown v-else :size="12" class="opacity-45" aria-hidden="true" />
|
||||
</button>
|
||||
</th>
|
||||
</template>
|
||||
@@ -1,13 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Bookmark, Building2, ChevronDown, Check, Languages, Library, Mic, Tag, Users } from '@lucide/vue'
|
||||
import { ALL_ENTITY_TYPES, type EntityType } from '@bookorbit/types'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
defineProps<{ modelValue: EntityType }>()
|
||||
const props = defineProps<{ modelValue: EntityType }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: EntityType] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const icons: Record<EntityType, Component> = {
|
||||
author: Users,
|
||||
genre: Bookmark,
|
||||
tag: Tag,
|
||||
narrator: Mic,
|
||||
publisher: Building2,
|
||||
language: Languages,
|
||||
series: Library,
|
||||
}
|
||||
|
||||
const labels = computed<Record<EntityType, string>>(() => ({
|
||||
author: t('tools.entityTypes.authors'),
|
||||
genre: t('tools.entityTypes.genres'),
|
||||
@@ -18,19 +30,33 @@ const labels = computed<Record<EntityType, string>>(() => ({
|
||||
series: t('tools.entityTypes.series'),
|
||||
}))
|
||||
|
||||
function handleChange(event: Event): void {
|
||||
emit('update:modelValue', (event.target as HTMLSelectElement).value as EntityType)
|
||||
const activeIcon = computed(() => icons[props.modelValue])
|
||||
const activeLabel = computed(() => labels.value[props.modelValue])
|
||||
|
||||
function handleSelect(value: EntityType): void {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
:value="modelValue"
|
||||
class="h-9 rounded-lg border border-border bg-card px-3 text-sm font-medium text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
@change="handleChange"
|
||||
>
|
||||
<option v-for="et in ALL_ENTITY_TYPES" :key="et" :value="et">
|
||||
{{ labels[et] }}
|
||||
</option>
|
||||
</select>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-card px-3 text-sm font-semibold text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('tools.entityManager.browse.entityTypeLabel')"
|
||||
>
|
||||
<component :is="activeIcon" :size="15" class="text-muted-foreground" aria-hidden="true" />
|
||||
{{ activeLabel }}
|
||||
<ChevronDown :size="14" class="text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="min-w-44">
|
||||
<DropdownMenuItem v-for="et in ALL_ENTITY_TYPES" :key="et" @click="handleSelect(et)">
|
||||
<component :is="icons[et]" :size="15" class="mr-2 text-muted-foreground" aria-hidden="true" />
|
||||
{{ labels[et] }}
|
||||
<Check v-if="et === modelValue" :size="14" class="ms-auto text-primary" aria-hidden="true" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import EntityBrowsePager from '../EntityBrowsePager.vue'
|
||||
|
||||
function mountPager(overrides: Partial<InstanceType<typeof EntityBrowsePager>['$props']> = {}) {
|
||||
return mount(EntityBrowsePager, {
|
||||
props: { page: 1, pageSize: 25, total: 428, totalPages: 18, ...overrides },
|
||||
global: {
|
||||
stubs: {
|
||||
DropdownMenu: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { emits: ['click'], template: '<button class="page-size-item" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function pageLinks(wrapper: ReturnType<typeof mountPager>) {
|
||||
return wrapper.findAll('nav button').filter((button) => /^\d+$/.test(button.text().trim()))
|
||||
}
|
||||
|
||||
describe('EntityBrowsePager', () => {
|
||||
it('reports the visible range rather than only the page number', () => {
|
||||
expect(mountPager({ page: 2 }).text()).toContain('Showing 26-50 of 428')
|
||||
})
|
||||
|
||||
it('caps the range at the total on the final page', () => {
|
||||
expect(mountPager({ page: 18 }).text()).toContain('Showing 426-428 of 428')
|
||||
})
|
||||
|
||||
it('reports an empty range when there are no results', () => {
|
||||
expect(mountPager({ page: 1, total: 0, totalPages: 1 }).text()).toContain('Showing 0-0 of 0')
|
||||
})
|
||||
|
||||
it('emits previous and next page updates', async () => {
|
||||
const wrapper = mountPager({ page: 2 })
|
||||
|
||||
await wrapper.find('[aria-label="Previous page"]').trigger('click')
|
||||
await wrapper.find('[aria-label="Next page"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:page')).toEqual([[1], [3]])
|
||||
})
|
||||
|
||||
it('disables paging past either end', () => {
|
||||
const first = mountPager({ page: 1 })
|
||||
const last = mountPager({ page: 18 })
|
||||
|
||||
expect(first.find('[aria-label="Previous page"]').attributes('disabled')).toBeDefined()
|
||||
expect(last.find('[aria-label="Next page"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('collapses long page runs around the current page', () => {
|
||||
const wrapper = mountPager({ page: 9 })
|
||||
|
||||
expect(pageLinks(wrapper).map((button) => button.text())).toEqual(['1', '8', '9', '10', '18'])
|
||||
expect(wrapper.text()).toContain('\u2026')
|
||||
})
|
||||
|
||||
it('lists every page when the run is short enough to fit', () => {
|
||||
const wrapper = mountPager({ page: 1, total: 100, totalPages: 4 })
|
||||
|
||||
expect(pageLinks(wrapper).map((button) => button.text())).toEqual(['1', '2', '3', '4'])
|
||||
})
|
||||
|
||||
it('marks the current page for assistive technology', () => {
|
||||
const wrapper = mountPager({ page: 9 })
|
||||
const current = pageLinks(wrapper).find((button) => button.text() === '9')!
|
||||
|
||||
expect(current.attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('emits a jump to a specific page', async () => {
|
||||
const wrapper = mountPager({ page: 9 })
|
||||
|
||||
await pageLinks(wrapper)
|
||||
.find((button) => button.text() === '18')!
|
||||
.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:page')?.[0]).toEqual([18])
|
||||
})
|
||||
|
||||
it('emits page size updates', async () => {
|
||||
const wrapper = mountPager()
|
||||
|
||||
await wrapper.findAll('.page-size-item')[1]!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:pageSize')?.[0]).toEqual([50])
|
||||
})
|
||||
|
||||
it('hides pagination controls when everything fits on one page', () => {
|
||||
expect(mountPager({ total: 10, totalPages: 1 }).find('nav').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
+55
-149
@@ -1,15 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount, shallowMount } from '@vue/test-utils'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { ENTITY_CAPABILITIES, type BrowseEntityItem } from '@bookorbit/types'
|
||||
|
||||
import EntityBrowseTable from '../EntityBrowseTable.vue'
|
||||
|
||||
function makeItem(id: number | string): BrowseEntityItem {
|
||||
return {
|
||||
id,
|
||||
name: `Entity ${id}`,
|
||||
bookCount: 1,
|
||||
}
|
||||
function makeItem(id: number): BrowseEntityItem {
|
||||
return { id, name: `Entity ${id}`, bookCount: 1 }
|
||||
}
|
||||
|
||||
function mountTable(overrides: Partial<InstanceType<typeof EntityBrowseTable>['$props']> = {}) {
|
||||
@@ -24,6 +20,7 @@ function mountTable(overrides: Partial<InstanceType<typeof EntityBrowseTable>['$
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
bookCount: 'any',
|
||||
density: 'comfortable',
|
||||
loading: false,
|
||||
selectedIds: new Set<number | string>(),
|
||||
capabilities: ENTITY_CAPABILITIES.author,
|
||||
@@ -33,163 +30,72 @@ function mountTable(overrides: Partial<InstanceType<typeof EntityBrowseTable>['$
|
||||
})
|
||||
}
|
||||
|
||||
function mountTableWithDropdown(overrides: Partial<InstanceType<typeof EntityBrowseTable>['$props']> = {}) {
|
||||
return mount(EntityBrowseTable, {
|
||||
props: {
|
||||
items: [makeItem(1)],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
totalPages: 1,
|
||||
search: '',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
bookCount: 'any',
|
||||
loading: false,
|
||||
selectedIds: new Set<number | string>(),
|
||||
capabilities: ENTITY_CAPABILITIES.author,
|
||||
isInline: false,
|
||||
...overrides,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
DropdownMenu: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { emits: ['click'], template: '<button class="dropdown-item" @click="$emit(\'click\')"><slot /></button>' },
|
||||
DropdownMenuSeparator: { template: '<hr />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
function grid(wrapper: ReturnType<typeof mountTable>) {
|
||||
return wrapper.findComponent({ name: 'EntityDataGrid' })
|
||||
}
|
||||
|
||||
describe('EntityBrowseTable', () => {
|
||||
function rowCheckboxes(wrapper: ReturnType<typeof mountTable>) {
|
||||
return wrapper.findAll('input[type="checkbox"]').slice(1)
|
||||
}
|
||||
|
||||
it('emits the checkbox click event so Shift-click selection can be handled by the parent', async () => {
|
||||
it('forwards grid events to the parent', () => {
|
||||
const wrapper = mountTable()
|
||||
const checkbox = rowCheckboxes(wrapper)[2]!
|
||||
const item = makeItem(4)
|
||||
const event = new MouseEvent('click')
|
||||
|
||||
await checkbox.trigger('click', { shiftKey: true })
|
||||
grid(wrapper).vm.$emit('select', 4, event)
|
||||
grid(wrapper).vm.$emit('toggleAll', true)
|
||||
grid(wrapper).vm.$emit('sortChange', 'bookCount', 'desc')
|
||||
grid(wrapper).vm.$emit('rename', item)
|
||||
grid(wrapper).vm.$emit('delete', item)
|
||||
grid(wrapper).vm.$emit('split', item)
|
||||
grid(wrapper).vm.$emit('clearFilters')
|
||||
|
||||
const selectEvent = wrapper.emitted('select')?.[0]
|
||||
expect(selectEvent?.[0]).toBe(3)
|
||||
expect(selectEvent?.[1]).toBeInstanceOf(MouseEvent)
|
||||
expect((selectEvent?.[1] as MouseEvent | undefined)?.shiftKey).toBe(true)
|
||||
expect(wrapper.emitted('select')?.[0]).toEqual([4, event])
|
||||
expect(wrapper.emitted('toggleAll')?.[0]).toEqual([true])
|
||||
expect(wrapper.emitted('sortChange')?.[0]).toEqual(['bookCount', 'desc'])
|
||||
expect(wrapper.emitted('rename')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('delete')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('split')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('clearFilters')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still emits ordinary checkbox clicks without Shift pressed', async () => {
|
||||
it('forwards toolbar events to the parent', () => {
|
||||
const wrapper = mountTable()
|
||||
const checkbox = rowCheckboxes(wrapper)[0]!
|
||||
const toolbar = wrapper.findComponent({ name: 'EntityBrowseToolbar' })
|
||||
|
||||
await checkbox.trigger('click')
|
||||
|
||||
const selectEvent = wrapper.emitted('select')?.[0]
|
||||
expect(selectEvent?.[0]).toBe(1)
|
||||
expect((selectEvent?.[1] as MouseEvent | undefined)?.shiftKey).toBe(false)
|
||||
})
|
||||
|
||||
it('emits sort updates when the sort selector changes', async () => {
|
||||
const wrapper = mountTable()
|
||||
|
||||
await wrapper.find('select').setValue('bookCount-asc')
|
||||
|
||||
expect(wrapper.emitted('sortChange')?.[0]).toEqual(['bookCount', 'asc'])
|
||||
})
|
||||
|
||||
it('emits all supported sort options from the sort selector', async () => {
|
||||
const wrapper = mountTable()
|
||||
const select = wrapper.find('select')
|
||||
|
||||
await select.setValue('name-desc')
|
||||
await select.setValue('bookCount-desc')
|
||||
await select.setValue('name-asc')
|
||||
|
||||
expect(wrapper.emitted('sortChange')).toEqual([
|
||||
['name', 'desc'],
|
||||
['bookCount', 'desc'],
|
||||
['name', 'asc'],
|
||||
])
|
||||
})
|
||||
|
||||
it('emits empty-only filter updates for first-class entities', async () => {
|
||||
const wrapper = mountTable()
|
||||
|
||||
const checkbox = wrapper.find('label input[type="checkbox"]')
|
||||
await checkbox.setValue(true)
|
||||
|
||||
expect(wrapper.emitted('update:bookCount')?.[0]).toEqual(['empty'])
|
||||
})
|
||||
|
||||
it('hides the empty-only filter for inline entity types', () => {
|
||||
const wrapper = mountTable({ isInline: true })
|
||||
|
||||
expect(wrapper.text()).not.toContain('Empty only')
|
||||
})
|
||||
|
||||
it('emits search updates from the search input', async () => {
|
||||
const wrapper = mountTable()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('tolkien')
|
||||
toolbar.vm.$emit('update:search', 'tolkien')
|
||||
toolbar.vm.$emit('update:bookCount', 'empty')
|
||||
toolbar.vm.$emit('update:density', 'compact')
|
||||
toolbar.vm.$emit('bulkMerge')
|
||||
toolbar.vm.$emit('bulkDelete')
|
||||
toolbar.vm.$emit('clearSelection')
|
||||
|
||||
expect(wrapper.emitted('update:search')?.[0]).toEqual(['tolkien'])
|
||||
})
|
||||
|
||||
it('emits previous and next page updates', async () => {
|
||||
const wrapper = mountTable({ items: [], total: 40, page: 2, totalPages: 3 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
await buttons[0]!.trigger('click')
|
||||
await buttons[1]!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:page')).toEqual([[1], [3]])
|
||||
})
|
||||
|
||||
it('emits row action events', async () => {
|
||||
const item = makeItem(7)
|
||||
const wrapper = mountTable({ items: [item] })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
await buttons.find((button) => button.text() === 'Rename')!.trigger('click')
|
||||
await buttons.find((button) => button.text() === 'Split')!.trigger('click')
|
||||
await buttons.find((button) => button.text() === 'Delete')!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('rename')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('split')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('delete')?.[0]).toEqual([item])
|
||||
})
|
||||
|
||||
it('emits mobile dropdown action events', async () => {
|
||||
const item = makeItem(1)
|
||||
const wrapper = mountTableWithDropdown({ items: [item] })
|
||||
|
||||
const dropdownItems = wrapper.findAll('.dropdown-item')
|
||||
await dropdownItems[0]!.trigger('click')
|
||||
await dropdownItems[1]!.trigger('click')
|
||||
await dropdownItems[2]!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('rename')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('split')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('delete')?.[0]).toEqual([item])
|
||||
})
|
||||
|
||||
it('emits bulk action and clear selection events', async () => {
|
||||
const wrapper = mountTable({ selectedIds: new Set([1, 2]) })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
await buttons.find((button) => button.text() === 'Clear')!.trigger('click')
|
||||
await buttons.find((button) => button.text() === 'Merge (2)')!.trigger('click')
|
||||
await buttons.find((button) => button.text() === 'Delete (2)')!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('clearSelection')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:bookCount')?.[0]).toEqual(['empty'])
|
||||
expect(wrapper.emitted('update:density')?.[0]).toEqual(['compact'])
|
||||
expect(wrapper.emitted('bulkMerge')).toHaveLength(1)
|
||||
expect(wrapper.emitted('bulkDelete')).toHaveLength(1)
|
||||
expect(wrapper.emitted('clearSelection')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows loading and empty states', () => {
|
||||
expect(mountTable({ loading: true }).text()).toContain('Loading...')
|
||||
expect(mountTable({ items: [], total: 0 }).text()).toContain('No entities found')
|
||||
it('forwards pager events to the parent', () => {
|
||||
const wrapper = mountTable()
|
||||
const pager = wrapper.findComponent({ name: 'EntityBrowsePager' })
|
||||
|
||||
pager.vm.$emit('update:page', 3)
|
||||
pager.vm.$emit('update:pageSize', 50)
|
||||
|
||||
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
|
||||
expect(wrapper.emitted('update:pageSize')?.[0]).toEqual([50])
|
||||
})
|
||||
|
||||
it('passes the current selection size to the toolbar', () => {
|
||||
const wrapper = mountTable({ selectedIds: new Set([1, 2]) })
|
||||
|
||||
expect(wrapper.findComponent({ name: 'EntityBrowseToolbar' }).props('selectedCount')).toBe(2)
|
||||
})
|
||||
|
||||
it('treats a search term or an empty-only filter as an active filter', () => {
|
||||
expect(grid(mountTable()).props('hasActiveFilters')).toBe(false)
|
||||
expect(grid(mountTable({ search: 'tolkien' })).props('hasActiveFilters')).toBe(true)
|
||||
expect(grid(mountTable({ bookCount: 'empty' })).props('hasActiveFilters')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import EntityBrowseToolbar from '../EntityBrowseToolbar.vue'
|
||||
|
||||
function mountToolbar(overrides: Partial<InstanceType<typeof EntityBrowseToolbar>['$props']> = {}) {
|
||||
return mount(EntityBrowseToolbar, {
|
||||
props: {
|
||||
search: '',
|
||||
bookCount: 'any',
|
||||
total: 3,
|
||||
density: 'comfortable',
|
||||
selectedCount: 0,
|
||||
isInline: false,
|
||||
...overrides,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('EntityBrowseToolbar', () => {
|
||||
it('emits search updates from the search input', async () => {
|
||||
const wrapper = mountToolbar()
|
||||
|
||||
await wrapper.find('input[type="search"]').setValue('tolkien')
|
||||
|
||||
expect(wrapper.emitted('update:search')?.[0]).toEqual(['tolkien'])
|
||||
})
|
||||
|
||||
it('emits book count filter updates from the filter group', async () => {
|
||||
const wrapper = mountToolbar({ bookCount: 'any' })
|
||||
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'No books')!
|
||||
.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:bookCount')?.[0]).toEqual(['empty'])
|
||||
})
|
||||
|
||||
it('emits a reset back to all entities', async () => {
|
||||
const wrapper = mountToolbar({ bookCount: 'empty' })
|
||||
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'All')!
|
||||
.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:bookCount')?.[0]).toEqual(['any'])
|
||||
})
|
||||
|
||||
it('hides the book count filter for inline entity types that cannot be empty', () => {
|
||||
expect(mountToolbar({ isInline: true }).text()).not.toContain('No books')
|
||||
})
|
||||
|
||||
it('emits density updates', async () => {
|
||||
const wrapper = mountToolbar({ density: 'comfortable' })
|
||||
|
||||
await wrapper.find('[aria-label="Compact"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:density')?.[0]).toEqual(['compact'])
|
||||
})
|
||||
|
||||
it('replaces the density control with bulk actions while rows are selected', async () => {
|
||||
const wrapper = mountToolbar({ selectedCount: 2 })
|
||||
|
||||
expect(wrapper.find('[aria-label="Compact"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('2 selected')
|
||||
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Merge')!
|
||||
.trigger('click')
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Delete')!
|
||||
.trigger('click')
|
||||
await wrapper.find('[aria-label="Clear"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('bulkMerge')).toHaveLength(1)
|
||||
expect(wrapper.emitted('bulkDelete')).toHaveLength(1)
|
||||
expect(wrapper.emitted('clearSelection')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('only offers merge once at least two entities are selected', () => {
|
||||
const single = mountToolbar({ selectedCount: 1 })
|
||||
const pair = mountToolbar({ selectedCount: 2 })
|
||||
|
||||
expect(single.findAll('button').some((button) => button.text() === 'Merge')).toBe(false)
|
||||
expect(pair.findAll('button').some((button) => button.text() === 'Merge')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ENTITY_CAPABILITIES, type BrowseEntityItem } from '@bookorbit/types'
|
||||
|
||||
import EntityDataGrid from '../EntityDataGrid.vue'
|
||||
|
||||
function makeItem(id: number | string, overrides: Partial<BrowseEntityItem> = {}): BrowseEntityItem {
|
||||
return { id, name: `Entity ${id}`, bookCount: 1, ...overrides }
|
||||
}
|
||||
|
||||
function mountGrid(overrides: Partial<InstanceType<typeof EntityDataGrid>['$props']> = {}) {
|
||||
return mount(EntityDataGrid, {
|
||||
props: {
|
||||
items: [1, 2, 3].map((id) => makeItem(id)),
|
||||
selectedIds: new Set<number | string>(),
|
||||
capabilities: ENTITY_CAPABILITIES.author,
|
||||
isInline: false,
|
||||
loading: false,
|
||||
density: 'comfortable',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
hasActiveFilters: false,
|
||||
...overrides,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
DropdownMenu: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { emits: ['click'], template: '<button class="dropdown-item" @click="$emit(\'click\')"><slot /></button>' },
|
||||
DropdownMenuSeparator: { template: '<hr />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function rowCheckboxes(wrapper: ReturnType<typeof mountGrid>) {
|
||||
return wrapper.find('table').findAll('tbody input[type="checkbox"]')
|
||||
}
|
||||
|
||||
function headerButton(wrapper: ReturnType<typeof mountGrid>, label: string) {
|
||||
return wrapper
|
||||
.find('table')
|
||||
.findAll('thead button')
|
||||
.find((button) => button.text().trim() === label)!
|
||||
}
|
||||
|
||||
describe('EntityDataGrid', () => {
|
||||
it('emits the checkbox click event so Shift-click selection can be handled by the parent', async () => {
|
||||
const wrapper = mountGrid()
|
||||
|
||||
await rowCheckboxes(wrapper)[2]!.trigger('click', { shiftKey: true })
|
||||
|
||||
const selectEvent = wrapper.emitted('select')?.[0]
|
||||
expect(selectEvent?.[0]).toBe(3)
|
||||
expect((selectEvent?.[1] as MouseEvent | undefined)?.shiftKey).toBe(true)
|
||||
})
|
||||
|
||||
it('still emits ordinary checkbox clicks without Shift pressed', async () => {
|
||||
const wrapper = mountGrid()
|
||||
|
||||
await rowCheckboxes(wrapper)[0]!.trigger('click')
|
||||
|
||||
const selectEvent = wrapper.emitted('select')?.[0]
|
||||
expect(selectEvent?.[0]).toBe(1)
|
||||
expect((selectEvent?.[1] as MouseEvent | undefined)?.shiftKey).toBe(false)
|
||||
})
|
||||
|
||||
it('emits toggleAll when the header checkbox is switched on and off', async () => {
|
||||
const wrapper = mountGrid()
|
||||
const selectAll = wrapper.find('table thead input[type="checkbox"]')
|
||||
|
||||
await selectAll.setValue(true)
|
||||
await selectAll.setValue(false)
|
||||
|
||||
expect(wrapper.emitted('toggleAll')).toEqual([[true], [false]])
|
||||
})
|
||||
|
||||
it('marks the header checkbox as checked only when every row on the page is selected', () => {
|
||||
const allSelected = mountGrid({ selectedIds: new Set([1, 2, 3]) })
|
||||
const someSelected = mountGrid({ selectedIds: new Set([1]) })
|
||||
|
||||
expect((allSelected.find('table thead input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((someSelected.find('table thead input[type="checkbox"]').element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('flips the sort order when the already active column is clicked again', async () => {
|
||||
const wrapper = mountGrid({ sortBy: 'name', sortOrder: 'asc' })
|
||||
|
||||
await headerButton(wrapper, 'Name').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('sortChange')?.[0]).toEqual(['name', 'desc'])
|
||||
})
|
||||
|
||||
it('starts a new book count sort on the most useful direction', async () => {
|
||||
const wrapper = mountGrid({ sortBy: 'name', sortOrder: 'asc' })
|
||||
|
||||
await headerButton(wrapper, 'Books').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('sortChange')?.[0]).toEqual(['bookCount', 'desc'])
|
||||
})
|
||||
|
||||
it('exposes the active sort direction to assistive technology', () => {
|
||||
const wrapper = mountGrid({ sortBy: 'bookCount', sortOrder: 'desc' })
|
||||
const headers = wrapper.find('table').findAll('thead th')
|
||||
|
||||
expect(headers.map((header) => header.attributes('aria-sort'))).toContain('descending')
|
||||
})
|
||||
|
||||
it('hides the sort name column for entity types without sort names', () => {
|
||||
const withSortName = mountGrid({ capabilities: ENTITY_CAPABILITIES.author })
|
||||
const withoutSortName = mountGrid({ capabilities: ENTITY_CAPABILITIES.genre })
|
||||
|
||||
expect(withSortName.find('table').text()).toContain('Sort name')
|
||||
expect(withoutSortName.find('table').text()).not.toContain('Sort name')
|
||||
})
|
||||
|
||||
it('hides the status column for inline entity types that cannot be empty', () => {
|
||||
const wrapper = mountGrid({ isInline: true, capabilities: ENTITY_CAPABILITIES.publisher })
|
||||
|
||||
expect(wrapper.find('table').text()).not.toContain('Status')
|
||||
})
|
||||
|
||||
it('flags entities that are not linked to any book', () => {
|
||||
const wrapper = mountGrid({ items: [makeItem(1, { bookCount: 0 })] })
|
||||
|
||||
expect(wrapper.find('table').text()).toContain('No books')
|
||||
})
|
||||
|
||||
it('does not offer split for entity types that cannot be split', () => {
|
||||
const splittable = mountGrid({ capabilities: ENTITY_CAPABILITIES.author })
|
||||
const notSplittable = mountGrid({ capabilities: ENTITY_CAPABILITIES.series })
|
||||
|
||||
expect(splittable.find('table').find('[aria-label="Split"]').exists()).toBe(true)
|
||||
expect(notSplittable.find('table').find('[aria-label="Split"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits row action events', async () => {
|
||||
const item = makeItem(7)
|
||||
const wrapper = mountGrid({ items: [item] })
|
||||
const table = wrapper.find('table')
|
||||
|
||||
await table.find('[aria-label="Rename"]').trigger('click')
|
||||
await table.find('[aria-label="Split"]').trigger('click')
|
||||
await table.find('[aria-label="Delete"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('rename')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('split')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('delete')?.[0]).toEqual([item])
|
||||
})
|
||||
|
||||
it('emits row action events from the mobile menu', async () => {
|
||||
const item = makeItem(1)
|
||||
const wrapper = mountGrid({ items: [item] })
|
||||
|
||||
const dropdownItems = wrapper.findAll('.dropdown-item')
|
||||
await dropdownItems[0]!.trigger('click')
|
||||
await dropdownItems[1]!.trigger('click')
|
||||
await dropdownItems[2]!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('rename')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('split')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('delete')?.[0]).toEqual([item])
|
||||
})
|
||||
|
||||
it('shows the loading state', () => {
|
||||
expect(mountGrid({ loading: true }).text()).toContain('Loading')
|
||||
})
|
||||
|
||||
it('distinguishes an empty entity type from an over-filtered result', () => {
|
||||
expect(mountGrid({ items: [], hasActiveFilters: false }).text()).toContain('No entities found')
|
||||
expect(mountGrid({ items: [], hasActiveFilters: true }).text()).toContain('No matches')
|
||||
})
|
||||
|
||||
it('offers to clear filters only when filters are actually narrowing the result', async () => {
|
||||
const unfiltered = mountGrid({ items: [], hasActiveFilters: false })
|
||||
const filtered = mountGrid({ items: [], hasActiveFilters: true })
|
||||
|
||||
expect(unfiltered.text()).not.toContain('Clear filters')
|
||||
|
||||
await filtered.find('table').findAll('button').at(-1)!.trigger('click')
|
||||
expect(filtered.emitted('clearFilters')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { storage } from '@/services/storage'
|
||||
|
||||
import { ENTITY_ROW_DENSITIES, type EntityRowDensity } from '../types'
|
||||
|
||||
const STORAGE_KEY = 'bookorbit:entity-manager:density'
|
||||
|
||||
function readStoredDensity(): EntityRowDensity {
|
||||
const stored = storage.get<string>(STORAGE_KEY, 'comfortable')
|
||||
return (ENTITY_ROW_DENSITIES as readonly string[]).includes(stored) ? (stored as EntityRowDensity) : 'comfortable'
|
||||
}
|
||||
|
||||
export function useEntityRowDensity() {
|
||||
const density = ref<EntityRowDensity>(readStoredDensity())
|
||||
|
||||
watch(density, (value) => storage.set(STORAGE_KEY, value))
|
||||
|
||||
return { density }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const ENTITY_ROW_DENSITIES = ['comfortable', 'compact'] as const
|
||||
|
||||
export type EntityRowDensity = (typeof ENTITY_ROW_DENSITIES)[number]
|
||||
|
||||
export const ENTITY_PAGE_SIZES = [25, 50, 100] as const
|
||||
@@ -67,6 +67,7 @@ function makeEntityManagerMock() {
|
||||
selectedItemsMap: ref(new Map<number | string, BrowseEntityItem>()),
|
||||
toggleSelection: mockFn(),
|
||||
rangeSelectTo: mockFn(),
|
||||
setSelection: mockFn(),
|
||||
removeFromSelection: mockFn(),
|
||||
clearSelection: mockFn(),
|
||||
|
||||
@@ -244,6 +245,68 @@ describe('EntityManagerView selection', () => {
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('selects and clears every entity on the current page', () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('toggleAll', true)
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('toggleAll', false)
|
||||
|
||||
expect(entityManager.setSelection).toHaveBeenNthCalledWith(1, [1, 2, 3], true)
|
||||
expect(entityManager.setSelection).toHaveBeenNthCalledWith(2, [1, 2, 3], false)
|
||||
})
|
||||
|
||||
it('returns to the first page when the page size changes so the offset stays valid', () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
entityManager.browsePage.value = 4
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
entityManager.fetchBrowse.mockClear()
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('update:pageSize', 100)
|
||||
|
||||
expect(entityManager.browsePageSize.value).toBe(100)
|
||||
expect(entityManager.browsePage.value).toBe(1)
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the requested page when paging directly', () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
entityManager.fetchBrowse.mockClear()
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('update:page', 6)
|
||||
|
||||
expect(entityManager.browsePage.value).toBe(6)
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears every filter in a single refresh rather than one per filter', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
entityManager.browseSearch.value = 'tolkien'
|
||||
entityManager.browseBookCount.value = 'empty'
|
||||
entityManager.browsePage.value = 3
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
entityManager.fetchBrowse.mockClear()
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('clearFilters')
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(entityManager.browseSearch.value).toBe('')
|
||||
expect(entityManager.browseBookCount.value).toBe('any')
|
||||
expect(entityManager.browsePage.value).toBe(1)
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renames a browsed entity and refreshes results', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Loader
|
||||
import type { BrowseEntityItem, DuplicateCluster } from '@bookorbit/types'
|
||||
|
||||
import { useEntityManager, type EntityManagerMode } from '../../composables/useEntityManager'
|
||||
import { useEntityRowDensity } from '../composables/useEntityRowDensity'
|
||||
import type { EntityRowDensity } from '../types'
|
||||
import DuplicateClusterCard from '../components/DuplicateClusterCard.vue'
|
||||
import DuplicateScanControls from '../components/DuplicateScanControls.vue'
|
||||
import DismissedPairsSection from '../components/DismissedPairsSection.vue'
|
||||
@@ -20,6 +22,7 @@ import BrowseMergeModal from '../components/BrowseMergeModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const em = useEntityManager()
|
||||
const { density } = useEntityRowDensity()
|
||||
|
||||
const renameTarget = ref<BrowseEntityItem | null>(null)
|
||||
const deleteTarget = ref<BrowseEntityItem | null>(null)
|
||||
@@ -34,6 +37,7 @@ const bulkDeleteDefaultMode = computed<'soft' | 'hard'>(() =>
|
||||
)
|
||||
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
let skipNextSearchWatch = false
|
||||
|
||||
function refreshBrowseFromFirstPage(): void {
|
||||
em.browsePage.value = 1
|
||||
@@ -41,6 +45,10 @@ function refreshBrowseFromFirstPage(): void {
|
||||
}
|
||||
|
||||
watch(em.browseSearch, () => {
|
||||
if (skipNextSearchWatch) {
|
||||
skipNextSearchWatch = false
|
||||
return
|
||||
}
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => {
|
||||
refreshBrowseFromFirstPage()
|
||||
@@ -98,6 +106,33 @@ function handleUpdatePage(value: number): void {
|
||||
em.fetchBrowse()
|
||||
}
|
||||
|
||||
function handleUpdatePageSize(value: number): void {
|
||||
em.browsePageSize.value = value
|
||||
refreshBrowseFromFirstPage()
|
||||
}
|
||||
|
||||
function handleUpdateDensity(value: EntityRowDensity): void {
|
||||
density.value = value
|
||||
}
|
||||
|
||||
function handleToggleAll(selected: boolean): void {
|
||||
em.setSelection(
|
||||
em.browseItems.value.map((item) => item.id),
|
||||
selected,
|
||||
)
|
||||
}
|
||||
|
||||
function handleClearFilters(): void {
|
||||
if (searchDebounce) {
|
||||
clearTimeout(searchDebounce)
|
||||
searchDebounce = null
|
||||
}
|
||||
if (em.browseSearch.value !== '') skipNextSearchWatch = true
|
||||
em.browseSearch.value = ''
|
||||
em.browseBookCount.value = 'any'
|
||||
refreshBrowseFromFirstPage()
|
||||
}
|
||||
|
||||
function handleBrowseSortChange(sortBy: 'name' | 'bookCount', sortOrder: 'asc' | 'desc'): void {
|
||||
em.browseSortBy.value = sortBy
|
||||
em.browseSortOrder.value = sortOrder
|
||||
@@ -209,7 +244,7 @@ async function handleBulkDeleteConfirm(mode: 'soft' | 'hard' | 'inline', writeFi
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="flex-none space-y-5 pb-5">
|
||||
<div class="flex-none space-y-4 pb-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<EntityTypeSelector v-model="em.entityType.value" />
|
||||
<ModeSwitcher :model-value="em.mode.value" @update:model-value="handleUpdateMode" />
|
||||
@@ -257,115 +292,116 @@ async function handleBulkDeleteConfirm(mode: 'soft' | 'hard' | 'inline', writeFi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content area -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<!-- Duplicates mode -->
|
||||
<template v-if="em.mode.value === 'duplicates'">
|
||||
<template v-if="!em.scanning.value && em.clusters.value.length === 0 && !em.scanError.value">
|
||||
<div
|
||||
v-if="!em.hasScanned.value"
|
||||
class="rounded-lg border border-border/50 bg-card/40 px-6 py-12 flex flex-col items-center gap-4 text-center"
|
||||
>
|
||||
<div class="rounded-full bg-muted p-4">
|
||||
<Search class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{{ t('tools.entityManager.duplicates.emptyTitle') }}</p>
|
||||
<p class="text-xs text-muted-foreground max-w-xs">{{ t('tools.entityManager.duplicates.emptyDescription') }}</p>
|
||||
</div>
|
||||
<!-- Duplicates mode: page-level scroll -->
|
||||
<div v-if="em.mode.value === 'duplicates'" class="flex-1 overflow-y-auto min-h-0">
|
||||
<template v-if="!em.scanning.value && em.clusters.value.length === 0 && !em.scanError.value">
|
||||
<div
|
||||
v-if="!em.hasScanned.value"
|
||||
class="rounded-lg border border-border/50 bg-card/40 px-6 py-12 flex flex-col items-center gap-4 text-center"
|
||||
>
|
||||
<div class="rounded-full bg-muted p-4">
|
||||
<Search class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div v-else class="rounded-lg border border-border/50 bg-card/40 px-6 py-12 flex flex-col items-center gap-4 text-center">
|
||||
<div class="rounded-full bg-green-500/10 p-4">
|
||||
<CheckCircle2 class="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{{ t('tools.entityManager.duplicates.noneFoundTitle') }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t('tools.entityManager.duplicates.noneFoundDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="em.clusters.value.length > 0" class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('tools.entityManager.duplicates.clustersFound', { count: em.scanTotal.value }) }}
|
||||
</p>
|
||||
<DuplicateClusterCard
|
||||
v-for="(cluster, idx) in em.clusters.value"
|
||||
:key="idx"
|
||||
:cluster="cluster"
|
||||
:capabilities="em.capabilities.value"
|
||||
:operation-loading="em.operationLoading.value"
|
||||
@merge="handleMerge"
|
||||
@dismiss-entity="(entityId) => handleDismissEntity(cluster, entityId)"
|
||||
@dismiss-pair="handleDismissPair"
|
||||
/>
|
||||
|
||||
<div v-if="em.scanTotalPages.value > 1" class="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
class="h-8 w-8 rounded-md border border-border flex items-center justify-center hover:bg-muted disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
:disabled="em.scanPage.value <= 1"
|
||||
@click="handleScanPrevPage"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="text-sm text-muted-foreground">{{ em.scanPage.value }} / {{ em.scanTotalPages.value }}</span>
|
||||
<button
|
||||
class="h-8 w-8 rounded-md border border-border flex items-center justify-center hover:bg-muted disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
:disabled="em.scanPage.value >= em.scanTotalPages.value"
|
||||
@click="handleScanNextPage"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{{ t('tools.entityManager.duplicates.emptyTitle') }}</p>
|
||||
<p class="text-xs text-muted-foreground max-w-xs">{{ t('tools.entityManager.duplicates.emptyDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 pb-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="handleToggleDismissed"
|
||||
>
|
||||
<component :is="em.showDismissed.value ? ChevronUp : ChevronDown" class="h-4 w-4" />
|
||||
<span>
|
||||
{{ em.showDismissed.value ? t('tools.entityManager.dismissed.hidePairs') : t('tools.entityManager.dismissed.showPairs') }}
|
||||
<template v-if="!em.showDismissed.value && em.dismissedPairs.value.length > 0"> ({{ em.dismissedPairs.value.length }}) </template>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="em.showDismissed.value" class="mt-3">
|
||||
<DismissedPairsSection :pairs="em.dismissedPairs.value" :loading="em.dismissedLoading.value" @undismiss="handleUndismiss" />
|
||||
<div v-else class="rounded-lg border border-border/50 bg-card/40 px-6 py-12 flex flex-col items-center gap-4 text-center">
|
||||
<div class="rounded-full bg-green-500/10 p-4">
|
||||
<CheckCircle2 class="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{{ t('tools.entityManager.duplicates.noneFoundTitle') }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t('tools.entityManager.duplicates.noneFoundDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Browse mode -->
|
||||
<template v-if="em.mode.value === 'browse'">
|
||||
<EntityBrowseTable
|
||||
:items="em.browseItems.value"
|
||||
:total="em.browseTotal.value"
|
||||
:page="em.browsePage.value"
|
||||
:page-size="em.browsePageSize.value"
|
||||
:total-pages="em.browseTotalPages.value"
|
||||
:search="em.browseSearch.value"
|
||||
:sort-by="em.browseSortBy.value"
|
||||
:sort-order="em.browseSortOrder.value"
|
||||
:book-count="em.browseBookCount.value"
|
||||
:loading="em.browseLoading.value"
|
||||
:selected-ids="em.selectedIds.value"
|
||||
<div v-if="em.clusters.value.length > 0" class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('tools.entityManager.duplicates.clustersFound', { count: em.scanTotal.value }) }}
|
||||
</p>
|
||||
<DuplicateClusterCard
|
||||
v-for="(cluster, idx) in em.clusters.value"
|
||||
:key="idx"
|
||||
:cluster="cluster"
|
||||
:capabilities="em.capabilities.value"
|
||||
:is-inline="em.isInline.value"
|
||||
@update:page="handleUpdatePage"
|
||||
@update:search="handleUpdateSearch"
|
||||
@update:book-count="handleUpdateBookCount"
|
||||
@sort-change="handleBrowseSortChange"
|
||||
@select="handleSelectItem"
|
||||
@rename="handleRename"
|
||||
@delete="handleDelete"
|
||||
@split="handleSplit"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-merge="handleBulkMerge"
|
||||
@clear-selection="em.clearSelection"
|
||||
@refresh="em.fetchBrowse"
|
||||
:operation-loading="em.operationLoading.value"
|
||||
@merge="handleMerge"
|
||||
@dismiss-entity="(entityId) => handleDismissEntity(cluster, entityId)"
|
||||
@dismiss-pair="handleDismissPair"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="em.scanTotalPages.value > 1" class="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
class="h-8 w-8 rounded-md border border-border flex items-center justify-center hover:bg-muted disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
:disabled="em.scanPage.value <= 1"
|
||||
@click="handleScanPrevPage"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="text-sm text-muted-foreground">{{ em.scanPage.value }} / {{ em.scanTotalPages.value }}</span>
|
||||
<button
|
||||
class="h-8 w-8 rounded-md border border-border flex items-center justify-center hover:bg-muted disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
:disabled="em.scanPage.value >= em.scanTotalPages.value"
|
||||
@click="handleScanNextPage"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 pb-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="handleToggleDismissed"
|
||||
>
|
||||
<component :is="em.showDismissed.value ? ChevronUp : ChevronDown" class="h-4 w-4" />
|
||||
<span>
|
||||
{{ em.showDismissed.value ? t('tools.entityManager.dismissed.hidePairs') : t('tools.entityManager.dismissed.showPairs') }}
|
||||
<template v-if="!em.showDismissed.value && em.dismissedPairs.value.length > 0"> ({{ em.dismissedPairs.value.length }}) </template>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="em.showDismissed.value" class="mt-3">
|
||||
<DismissedPairsSection :pairs="em.dismissedPairs.value" :loading="em.dismissedLoading.value" @undismiss="handleUndismiss" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browse mode: the data grid owns its own scroll so the header can stick -->
|
||||
<div v-if="em.mode.value === 'browse'" class="flex-1 min-h-0 overflow-hidden">
|
||||
<EntityBrowseTable
|
||||
:items="em.browseItems.value"
|
||||
:total="em.browseTotal.value"
|
||||
:page="em.browsePage.value"
|
||||
:page-size="em.browsePageSize.value"
|
||||
:total-pages="em.browseTotalPages.value"
|
||||
:search="em.browseSearch.value"
|
||||
:sort-by="em.browseSortBy.value"
|
||||
:sort-order="em.browseSortOrder.value"
|
||||
:book-count="em.browseBookCount.value"
|
||||
:density="density"
|
||||
:loading="em.browseLoading.value"
|
||||
:selected-ids="em.selectedIds.value"
|
||||
:capabilities="em.capabilities.value"
|
||||
:is-inline="em.isInline.value"
|
||||
@update:page="handleUpdatePage"
|
||||
@update:page-size="handleUpdatePageSize"
|
||||
@update:search="handleUpdateSearch"
|
||||
@update:book-count="handleUpdateBookCount"
|
||||
@update:density="handleUpdateDensity"
|
||||
@sort-change="handleBrowseSortChange"
|
||||
@select="handleSelectItem"
|
||||
@toggle-all="handleToggleAll"
|
||||
@rename="handleRename"
|
||||
@delete="handleDelete"
|
||||
@split="handleSplit"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-merge="handleBulkMerge"
|
||||
@clear-selection="em.clearSelection"
|
||||
@clear-filters="handleClearFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
|
||||
@@ -8134,7 +8134,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"rename": "Rename",
|
||||
"split": "Split"
|
||||
"split": "Split",
|
||||
"merge": "Merge"
|
||||
},
|
||||
"duplicates": {
|
||||
"similarity": "Similarity:",
|
||||
@@ -8178,7 +8179,44 @@
|
||||
"nameDesc": "Name Z-A",
|
||||
"fewestBooks": "Fewest books",
|
||||
"mostBooks": "Most books"
|
||||
}
|
||||
},
|
||||
"sortByLabel": "Sort by",
|
||||
"entityTypeLabel": "Entity type",
|
||||
"resultCount": "{count, plural, =0 {no entities} one {# entity} other {# entities}}",
|
||||
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"sortName": "Sort name",
|
||||
"books": "Books",
|
||||
"status": "Status",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"selectAllOnPage": "Select all entities on this page",
|
||||
"selectEntity": "Select {name}",
|
||||
"actionsFor": "Actions for {name}",
|
||||
"status": {
|
||||
"noBooks": "No books"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"noBooks": "No books"
|
||||
},
|
||||
"density": {
|
||||
"label": "Row height",
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact"
|
||||
},
|
||||
"rowsPerPage": "Rows per page",
|
||||
"range": "Showing {from}-{to} of {total}",
|
||||
"pagination": "Pagination",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"goToPage": "Go to page {page}",
|
||||
"morePages": "More pages",
|
||||
"noEntitiesDescription": "Nothing has been added to this entity type yet.",
|
||||
"noMatchesTitle": "No matches",
|
||||
"noMatchesDescription": "No entities match your current search and filters.",
|
||||
"clearFilters": "Clear filters"
|
||||
},
|
||||
"deleteMode": {
|
||||
"label": "Delete mode",
|
||||
|
||||
Reference in New Issue
Block a user