-
+
+
+
-
-
-
{{ t('common.loading') }}
+
-
- {{ t('tools.entityManager.browse.noEntities') }}
-
-
-
-
-
-
-
{{ item.name }}
-
- {{ t('tools.entityManager.browse.sortLabel', { sortName: item.sortName }) }}
-
-
-
{{ t('tools.entityManager.bookCount', { count: item.bookCount }) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('tools.entityManager.actions.rename') }}
-
-
-
- {{ t('tools.entityManager.actions.split') }}
-
-
-
-
- {{ t('common.delete') }}
-
-
-
-
-
-
-
-
-
-
-
- {{ page }} / {{ totalPages }}
-
-
-
+
diff --git a/client/src/features/tools/entity-manager/components/EntityBrowseToolbar.vue b/client/src/features/tools/entity-manager/components/EntityBrowseToolbar.vue
new file mode 100644
index 000000000..a91536e4d
--- /dev/null
+++ b/client/src/features/tools/entity-manager/components/EntityBrowseToolbar.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tools.entityManager.browse.resultCount', { count: total }) }}
+
+
+
+
+
+ {{ t('tools.entityManager.browse.selectedCount', { count: selectedCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/tools/entity-manager/components/EntityDataGrid.vue b/client/src/features/tools/entity-manager/components/EntityDataGrid.vue
new file mode 100644
index 000000000..4b8d33381
--- /dev/null
+++ b/client/src/features/tools/entity-manager/components/EntityDataGrid.vue
@@ -0,0 +1,309 @@
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+
+ {{ t('tools.entityManager.browse.columns.name') }}
+
+
+ {{ t('tools.entityManager.browse.columns.sortName') }}
+ |
+
+ {{ t('tools.entityManager.browse.columns.books') }}
+
+
+ {{ t('tools.entityManager.browse.columns.status') }}
+ |
+
+ {{ t('tools.entityManager.browse.columns.actions') }}
+ |
+
+
+
+
+ | {{ t('common.loading') }} |
+
+
+
+
+
+
+
+
+
+ {{ hasActiveFilters ? t('tools.entityManager.browse.noMatchesTitle') : t('tools.entityManager.browse.noEntities') }}
+
+
+ {{
+ hasActiveFilters ? t('tools.entityManager.browse.noMatchesDescription') : t('tools.entityManager.browse.noEntitiesDescription')
+ }}
+
+
+
+
+ |
+
+
+ |
+
+ |
+
+ {{ item.name }}
+ |
+
+ {{ item.sortName }}
+ -
+ |
+
+ {{ formatNumber(item.bookCount) }}
+ |
+
+
+
+ {{ t('tools.entityManager.browse.status.noBooks') }}
+
+ |
+
+
+ |
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+ {{ hasActiveFilters ? t('tools.entityManager.browse.noMatchesTitle') : t('tools.entityManager.browse.noEntities') }}
+
+
+
+
+
+
+
{{ item.name }}
+
+ {{ t('tools.entityManager.browse.sortLabel', { sortName: item.sortName }) }} ·
+
+ {{ t('tools.entityManager.bookCount', { count: item.bookCount }) }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('tools.entityManager.actions.rename') }}
+
+
+
+ {{ t('tools.entityManager.actions.split') }}
+
+
+
+
+ {{ t('common.delete') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/features/tools/entity-manager/components/EntitySortableHeader.vue b/client/src/features/tools/entity-manager/components/EntitySortableHeader.vue
new file mode 100644
index 000000000..c674b966c
--- /dev/null
+++ b/client/src/features/tools/entity-manager/components/EntitySortableHeader.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+ |
+
diff --git a/client/src/features/tools/entity-manager/components/EntityTypeSelector.vue b/client/src/features/tools/entity-manager/components/EntityTypeSelector.vue
index e2bed8722..cbee05475 100644
--- a/client/src/features/tools/entity-manager/components/EntityTypeSelector.vue
+++ b/client/src/features/tools/entity-manager/components/EntityTypeSelector.vue
@@ -1,13 +1,25 @@
-
+
+
+
+
+
+
+
+ {{ labels[et] }}
+
+
+
+
diff --git a/client/src/features/tools/entity-manager/components/__tests__/EntityBrowsePager.spec.ts b/client/src/features/tools/entity-manager/components/__tests__/EntityBrowsePager.spec.ts
new file mode 100644
index 000000000..c7b08b2c5
--- /dev/null
+++ b/client/src/features/tools/entity-manager/components/__tests__/EntityBrowsePager.spec.ts
@@ -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
['$props']> = {}) {
+ return mount(EntityBrowsePager, {
+ props: { page: 1, pageSize: 25, total: 428, totalPages: 18, ...overrides },
+ global: {
+ stubs: {
+ DropdownMenu: { template: '
' },
+ DropdownMenuTrigger: { template: '
' },
+ DropdownMenuContent: { template: '
' },
+ DropdownMenuItem: { emits: ['click'], template: '' },
+ },
+ },
+ })
+}
+
+function pageLinks(wrapper: ReturnType) {
+ 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)
+ })
+})
diff --git a/client/src/features/tools/entity-manager/components/__tests__/EntityBrowseTable.spec.ts b/client/src/features/tools/entity-manager/components/__tests__/EntityBrowseTable.spec.ts
index 9d12161bd..af8bcf1a2 100644
--- a/client/src/features/tools/entity-manager/components/__tests__/EntityBrowseTable.spec.ts
+++ b/client/src/features/tools/entity-manager/components/__tests__/EntityBrowseTable.spec.ts
@@ -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['$props']> = {}) {
@@ -24,6 +20,7 @@ function mountTable(overrides: Partial['$
sortBy: 'name',
sortOrder: 'asc',
bookCount: 'any',
+ density: 'comfortable',
loading: false,
selectedIds: new Set(),
capabilities: ENTITY_CAPABILITIES.author,
@@ -33,163 +30,72 @@ function mountTable(overrides: Partial['$
})
}
-function mountTableWithDropdown(overrides: Partial['$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(),
- capabilities: ENTITY_CAPABILITIES.author,
- isInline: false,
- ...overrides,
- },
- global: {
- stubs: {
- DropdownMenu: { template: '
' },
- DropdownMenuTrigger: { template: '
' },
- DropdownMenuContent: { template: '
' },
- DropdownMenuItem: { emits: ['click'], template: '' },
- DropdownMenuSeparator: { template: '
' },
- },
- },
- })
+function grid(wrapper: ReturnType) {
+ return wrapper.findComponent({ name: 'EntityDataGrid' })
}
describe('EntityBrowseTable', () => {
- function rowCheckboxes(wrapper: ReturnType) {
- 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)
})
})
diff --git a/client/src/features/tools/entity-manager/components/__tests__/EntityBrowseToolbar.spec.ts b/client/src/features/tools/entity-manager/components/__tests__/EntityBrowseToolbar.spec.ts
new file mode 100644
index 000000000..64091f6dd
--- /dev/null
+++ b/client/src/features/tools/entity-manager/components/__tests__/EntityBrowseToolbar.spec.ts
@@ -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['$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)
+ })
+})
diff --git a/client/src/features/tools/entity-manager/components/__tests__/EntityDataGrid.spec.ts b/client/src/features/tools/entity-manager/components/__tests__/EntityDataGrid.spec.ts
new file mode 100644
index 000000000..8887524c6
--- /dev/null
+++ b/client/src/features/tools/entity-manager/components/__tests__/EntityDataGrid.spec.ts
@@ -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 {
+ return { id, name: `Entity ${id}`, bookCount: 1, ...overrides }
+}
+
+function mountGrid(overrides: Partial['$props']> = {}) {
+ return mount(EntityDataGrid, {
+ props: {
+ items: [1, 2, 3].map((id) => makeItem(id)),
+ selectedIds: new Set(),
+ capabilities: ENTITY_CAPABILITIES.author,
+ isInline: false,
+ loading: false,
+ density: 'comfortable',
+ sortBy: 'name',
+ sortOrder: 'asc',
+ hasActiveFilters: false,
+ ...overrides,
+ },
+ global: {
+ stubs: {
+ DropdownMenu: { template: '
' },
+ DropdownMenuTrigger: { template: '
' },
+ DropdownMenuContent: { template: '
' },
+ DropdownMenuItem: { emits: ['click'], template: '' },
+ DropdownMenuSeparator: { template: '
' },
+ },
+ },
+ })
+}
+
+function rowCheckboxes(wrapper: ReturnType) {
+ return wrapper.find('table').findAll('tbody input[type="checkbox"]')
+}
+
+function headerButton(wrapper: ReturnType, 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)
+ })
+})
diff --git a/client/src/features/tools/entity-manager/composables/useEntityRowDensity.ts b/client/src/features/tools/entity-manager/composables/useEntityRowDensity.ts
new file mode 100644
index 000000000..a22874a28
--- /dev/null
+++ b/client/src/features/tools/entity-manager/composables/useEntityRowDensity.ts
@@ -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(STORAGE_KEY, 'comfortable')
+ return (ENTITY_ROW_DENSITIES as readonly string[]).includes(stored) ? (stored as EntityRowDensity) : 'comfortable'
+}
+
+export function useEntityRowDensity() {
+ const density = ref(readStoredDensity())
+
+ watch(density, (value) => storage.set(STORAGE_KEY, value))
+
+ return { density }
+}
diff --git a/client/src/features/tools/entity-manager/types.ts b/client/src/features/tools/entity-manager/types.ts
new file mode 100644
index 000000000..798377096
--- /dev/null
+++ b/client/src/features/tools/entity-manager/types.ts
@@ -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
diff --git a/client/src/features/tools/entity-manager/views/EntityManagerView.spec.ts b/client/src/features/tools/entity-manager/views/EntityManagerView.spec.ts
index 64554e2d4..5e2c3dca4 100644
--- a/client/src/features/tools/entity-manager/views/EntityManagerView.spec.ts
+++ b/client/src/features/tools/entity-manager/views/EntityManagerView.spec.ts
@@ -67,6 +67,7 @@ function makeEntityManagerMock() {
selectedItemsMap: ref(new Map()),
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)
diff --git a/client/src/features/tools/entity-manager/views/EntityManagerView.vue b/client/src/features/tools/entity-manager/views/EntityManagerView.vue
index 967b74e4c..e6ef2b7df 100644
--- a/client/src/features/tools/entity-manager/views/EntityManagerView.vue
+++ b/client/src/features/tools/entity-manager/views/EntityManagerView.vue
@@ -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(null)
const deleteTarget = ref(null)
@@ -34,6 +37,7 @@ const bulkDeleteDefaultMode = computed<'soft' | 'hard'>(() =>
)
let searchDebounce: ReturnType | 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
-
+
@@ -257,115 +292,116 @@ async function handleBulkDeleteConfirm(mode: 'soft' | 'hard' | 'inline', writeFi
-
-
-
-
-
-
-
-
-
-
-
{{ t('tools.entityManager.duplicates.emptyTitle') }}
-
{{ t('tools.entityManager.duplicates.emptyDescription') }}
-
+
+
+
+
+
+
-
-
-
-
-
-
{{ t('tools.entityManager.duplicates.noneFoundTitle') }}
-
{{ t('tools.entityManager.duplicates.noneFoundDescription') }}
-
-
-
-
-
-
- {{ t('tools.entityManager.duplicates.clustersFound', { count: em.scanTotal.value }) }}
-
-
handleDismissEntity(cluster, entityId)"
- @dismiss-pair="handleDismissPair"
- />
-
-
-
-
{{ em.scanPage.value }} / {{ em.scanTotalPages.value }}
-
+
+
{{ t('tools.entityManager.duplicates.emptyTitle') }}
+
{{ t('tools.entityManager.duplicates.emptyDescription') }}
-
-
-
-
-
+
+
+
+
+
+
{{ t('tools.entityManager.duplicates.noneFoundTitle') }}
+
{{ t('tools.entityManager.duplicates.noneFoundDescription') }}
-
-
-
+
+ {{ t('tools.entityManager.duplicates.clustersFound', { count: em.scanTotal.value }) }}
+
+ handleDismissEntity(cluster, entityId)"
+ @dismiss-pair="handleDismissPair"
/>
-
+
+
+
+ {{ em.scanPage.value }} / {{ em.scanTotalPages.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index aeecdb3a0..5a5e4f578 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -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",