diff --git a/client/src/features/tools/composables/useEntityManager.ts b/client/src/features/tools/composables/useEntityManager.ts index 6a55b51a2..869d75171 100644 --- a/client/src/features/tools/composables/useEntityManager.ts +++ b/client/src/features/tools/composables/useEntityManager.ts @@ -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, diff --git a/client/src/features/tools/entity-manager/components/EntityBrowsePager.vue b/client/src/features/tools/entity-manager/components/EntityBrowsePager.vue new file mode 100644 index 000000000..8346d8b8b --- /dev/null +++ b/client/src/features/tools/entity-manager/components/EntityBrowsePager.vue @@ -0,0 +1,126 @@ + + + diff --git a/client/src/features/tools/entity-manager/components/EntityBrowseTable.vue b/client/src/features/tools/entity-manager/components/EntityBrowseTable.vue index 33019f01a..dc6479fe0 100644 --- a/client/src/features/tools/entity-manager/components/EntityBrowseTable.vue +++ b/client/src/features/tools/entity-manager/components/EntityBrowseTable.vue @@ -1,7 +1,5 @@ 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 @@ + + + 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 @@ + + + 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 @@ 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