From faee78370d84fdb3fdec15cb085000262a3c2e27 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:04:00 +0000 Subject: [PATCH] feat(library): rebuild the folder picker as a tree with a live plan Pick folders from a lazily loaded tree with find-by-name and typed paths, beside a plan showing the books each organization mode would import, overlaps with other libraries, and folders no library reads yet. New ManageLibraries /path routes walk folders within entry and time bounds. Fix the scanner filing a disc-named library folder such as /mnt/disk1 under the folder above it, and move books already filed there onto their primary file on the next scan. --- .../library/components/FolderPickerModal.vue | 791 ++++++++++++------ .../components/LibraryCreatorFolders.vue | 40 +- .../components/LibraryCreatorLocalFolders.vue | 23 +- .../components/LibraryCreatorModal.vue | 5 + .../__tests__/FolderPickerModal.spec.ts | 420 ++++++++-- .../folder-picker/FolderFormatBar.vue | 27 + .../folder-picker/FolderFormatLegend.vue | 31 + .../folder-picker/FolderPathLine.vue | 22 + .../folder-picker/FolderPickerDivider.vue | 101 +++ .../folder-picker/FolderPickerField.vue | 413 +++++++++ .../FolderPickerNewFolderRow.vue | 82 ++ .../folder-picker/FolderPickerPlan.vue | 126 +++ .../folder-picker/FolderPickerPlanRow.vue | 178 ++++ .../folder-picker/FolderPickerPreview.vue | 168 ++++ .../folder-picker/FolderPickerToolbar.vue | 98 +++ .../folder-picker/FolderPickerTotals.vue | 83 ++ .../folder-picker/FolderPickerTree.vue | 270 ++++++ .../folder-picker/FolderPickerTreeRow.vue | 184 ++++ .../components/folder-picker/PickerKbd.vue | 7 + .../__tests__/folder-picker-rules.spec.ts | 146 ++++ .../__tests__/useFolderPickerData.spec.ts | 148 ++++ .../useFolderPickerSelection.spec.ts | 169 ++++ .../__tests__/useFolderPickerTree.spec.ts | 157 ++++ .../folder-picker/folder-picker-context.ts | 50 ++ .../folder-picker/folder-picker-rules.ts | 150 ++++ .../folder-picker/recent-folders.ts | 26 + .../folder-picker/useFolderPickerData.ts | 261 ++++++ .../folder-picker/useFolderPickerSelection.ts | 158 ++++ .../folder-picker/useFolderPickerTree.ts | 198 +++++ .../folder-picker/usePickerPlanWidth.ts | 33 + .../library/composables/useFolderBrowser.ts | 260 ------ client/src/locales/en.json | 188 +++++ packages/types/src/path.ts | 92 ++ .../modules/path/dto/folder-path-query.dto.ts | 8 + .../modules/path/dto/folder-summaries.dto.ts | 10 + .../path/dto/search-folders-query.dto.ts | 18 + .../path/folder-insight.service.test.ts | 196 +++++ .../modules/path/folder-insight.service.ts | 309 +++++++ server/src/modules/path/folder-walk.test.ts | 259 ++++++ server/src/modules/path/folder-walk.ts | 269 ++++++ .../modules/path/path-policy.service.test.ts | 18 +- .../src/modules/path/path-policy.service.ts | 14 + .../src/modules/path/path.controller.test.ts | 60 +- server/src/modules/path/path.controller.ts | 64 +- server/src/modules/path/path.module.test.ts | 6 +- server/src/modules/path/path.module.ts | 4 +- server/src/modules/path/path.repository.ts | 33 + .../modules/path/path.service.folders.test.ts | 191 +++++ server/src/modules/path/path.service.test.ts | 3 +- server/src/modules/path/path.service.ts | 212 ++++- server/src/modules/scanner/lib/walk.test.ts | 22 + server/src/modules/scanner/lib/walk.ts | 6 +- .../src/modules/scanner/scanner.repository.ts | 32 + server/src/modules/scanner/scanner.service.ts | 34 + .../route-inventory/manifest.json | 4 +- .../route-inventory/routes-05.json | 84 ++ .../route-inventory/uncovered-routes.json | 1 - server/test/scanner-scenarios.e2e-spec.ts | 69 ++ 58 files changed, 6389 insertions(+), 642 deletions(-) create mode 100644 client/src/features/library/components/folder-picker/FolderFormatBar.vue create mode 100644 client/src/features/library/components/folder-picker/FolderFormatLegend.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPathLine.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerDivider.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerField.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerNewFolderRow.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerPlan.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerPlanRow.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerPreview.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerToolbar.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerTotals.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerTree.vue create mode 100644 client/src/features/library/components/folder-picker/FolderPickerTreeRow.vue create mode 100644 client/src/features/library/components/folder-picker/PickerKbd.vue create mode 100644 client/src/features/library/composables/folder-picker/__tests__/folder-picker-rules.spec.ts create mode 100644 client/src/features/library/composables/folder-picker/__tests__/useFolderPickerData.spec.ts create mode 100644 client/src/features/library/composables/folder-picker/__tests__/useFolderPickerSelection.spec.ts create mode 100644 client/src/features/library/composables/folder-picker/__tests__/useFolderPickerTree.spec.ts create mode 100644 client/src/features/library/composables/folder-picker/folder-picker-context.ts create mode 100644 client/src/features/library/composables/folder-picker/folder-picker-rules.ts create mode 100644 client/src/features/library/composables/folder-picker/recent-folders.ts create mode 100644 client/src/features/library/composables/folder-picker/useFolderPickerData.ts create mode 100644 client/src/features/library/composables/folder-picker/useFolderPickerSelection.ts create mode 100644 client/src/features/library/composables/folder-picker/useFolderPickerTree.ts create mode 100644 client/src/features/library/composables/folder-picker/usePickerPlanWidth.ts delete mode 100644 client/src/features/library/composables/useFolderBrowser.ts create mode 100644 server/src/modules/path/dto/folder-path-query.dto.ts create mode 100644 server/src/modules/path/dto/folder-summaries.dto.ts create mode 100644 server/src/modules/path/dto/search-folders-query.dto.ts create mode 100644 server/src/modules/path/folder-insight.service.test.ts create mode 100644 server/src/modules/path/folder-insight.service.ts create mode 100644 server/src/modules/path/folder-walk.test.ts create mode 100644 server/src/modules/path/folder-walk.ts create mode 100644 server/src/modules/path/path.repository.ts create mode 100644 server/src/modules/path/path.service.folders.test.ts diff --git a/client/src/features/library/components/FolderPickerModal.vue b/client/src/features/library/components/FolderPickerModal.vue index 56121c93b..5c2f9d728 100644 --- a/client/src/features/library/components/FolderPickerModal.vue +++ b/client/src/features/library/components/FolderPickerModal.vue @@ -1,334 +1,585 @@ - - diff --git a/client/src/features/library/components/LibraryCreatorFolders.vue b/client/src/features/library/components/LibraryCreatorFolders.vue index ae9179998..b518cf794 100644 --- a/client/src/features/library/components/LibraryCreatorFolders.vue +++ b/client/src/features/library/components/LibraryCreatorFolders.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { CircleCheck, CircleX, FolderOpen, FolderPlus, FolderSearch, Info, Loader2, Plus, RefreshCw, Trash2, TriangleAlert } from '@lucide/vue' -import type { LibraryStats, LibraryType } from '@bookorbit/types' +import type { LibraryStats, LibraryType, OrganizationMode } from '@bookorbit/types' import { formatBytes } from '@/lib/formatting' import LibraryFormatBar from '@/features/settings/libraries/components/LibraryFormatBar.vue' import type { FolderCheck } from '../composables/useLibraryCreator' @@ -20,14 +20,28 @@ const props = withDefaults( libraryType: LibraryType checks?: Record stats?: LibraryStats | null + libraryName?: string + libraryIcon?: string | null + libraryId?: number | null + organizationMode?: OrganizationMode + organizationModeLocked?: boolean }>(), - { checks: () => ({}), stats: null }, + { + checks: () => ({}), + stats: null, + libraryName: '', + libraryIcon: null, + libraryId: null, + organizationMode: 'book_per_folder', + organizationModeLocked: false, + }, ) const emit = defineEmits<{ 'update:folders': [value: string[]] 'update:localFolders': [value: string[]] 'update:pickerOpen': [value: boolean] + 'update:organizationMode': [value: OrganizationMode] check: [paths: string[]] }>() @@ -111,6 +125,10 @@ function handleNestedPickerChange(value: boolean) { function addBrowsedFolders(paths: string[]) { addSelectedFolders(podcast.value ? paths.slice(0, 1) : paths) } + +function handleOrganizationMode(value: OrganizationMode) { + emit('update:organizationMode', value) +} diff --git a/client/src/features/library/components/LibraryCreatorLocalFolders.vue b/client/src/features/library/components/LibraryCreatorLocalFolders.vue index 047a3d4ed..76032f518 100644 --- a/client/src/features/library/components/LibraryCreatorLocalFolders.vue +++ b/client/src/features/library/components/LibraryCreatorLocalFolders.vue @@ -6,9 +6,15 @@ import FolderPickerModal from './FolderPickerModal.vue' const { t } = useI18n() -const props = defineProps<{ - localFolders: string[] -}>() +const props = withDefaults( + defineProps<{ + localFolders: string[] + libraryName?: string + libraryIcon?: string | null + libraryId?: number | null + }>(), + { libraryName: '', libraryIcon: null, libraryId: null }, +) const emit = defineEmits<{ 'update:localFolders': [value: string[]] @@ -130,5 +136,14 @@ const { - + diff --git a/client/src/features/library/components/LibraryCreatorModal.vue b/client/src/features/library/components/LibraryCreatorModal.vue index d6d16658a..e4cf8a02c 100644 --- a/client/src/features/library/components/LibraryCreatorModal.vue +++ b/client/src/features/library/components/LibraryCreatorModal.vue @@ -272,6 +272,11 @@ const sectionProps = computed(() => ({ libraryType: form.type, checks: folderChecks.value, stats: stats.value, + libraryName: form.name, + libraryIcon: form.icon, + libraryId: editingLibraryId.value, + organizationMode: form.organizationMode, + organizationModeLocked: !creating.value, }, scanner: { organizationMode: form.organizationMode, diff --git a/client/src/features/library/components/__tests__/FolderPickerModal.spec.ts b/client/src/features/library/components/__tests__/FolderPickerModal.spec.ts index 1de2acab8..c8e86dfac 100644 --- a/client/src/features/library/components/__tests__/FolderPickerModal.spec.ts +++ b/client/src/features/library/components/__tests__/FolderPickerModal.spec.ts @@ -1,112 +1,372 @@ -import { mount, flushPromises } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import FolderPickerModal from '../FolderPickerModal.vue' const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>()) +vi.mock('@/lib/api', () => ({ api: apiMock })) -vi.mock('@/lib/api', () => ({ - api: apiMock, -})) +const entry = (path: string, extra: Record = {}) => ({ + name: path.slice(path.lastIndexOf('/') + 1), + path, + mount: false, + system: false, + appData: false, + hasChildren: false, + ...extra, +}) +const LISTINGS: Record = { + '/': [entry('/books', { mount: true, hasChildren: true }), entry('/data', { appData: true }), entry('/usr', { system: true, hasChildren: true })], + '/books': [entry('/books/Fiction', { hasChildren: true }), entry('/books/Science Fiction'), entry('/books/Unsorted')], +} +const FILES: Record = { '/books': 300, '/books/Fiction': 139, '/books/Science Fiction': 45, '/books/Unsorted': 12, '/data': 0 } -const tooltipStubs = { +function json(body: unknown, status = 200) { + return Promise.resolve(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })) +} + +function summary(path: string) { + const files = FILES[path] ?? 0 + return { + path, + state: 'ok', + files, + folderBooks: files, + formats: files ? { epub: files } : {}, + folders: 0, + mergedFolders: path === '/books/Unsorted' ? 2 : 0, + splitAudioFolders: 0, + truncated: false, + } +} + +function server(input: RequestInfo | URL, init?: RequestInit) { + const url = new URL(String(input), 'http://localhost') + const path = url.searchParams.get('path') ?? '' + if (url.pathname === '/api/v1/path/places') + return json({ + root: '/', + mounts: ['/books'], + appDataPath: '/data', + libraries: [{ id: 7, name: 'Novels', icon: 'BookOpen', type: 'books', folders: ['/books/Fiction'] }], + }) + if (url.pathname === '/api/v1/path/listing') + return json({ path, state: path === '/proc' ? 'blocked' : 'ok', entries: LISTINGS[path] ?? [], truncated: false }) + if (url.pathname === '/api/v1/path/search') return json({ results: ['/books/Fiction', '/books/Unsorted'], truncated: false }) + if (url.pathname === '/api/v1/path/summaries') return json((JSON.parse(String(init?.body)).paths as string[]).map(summary)) + if (url.pathname === '/api/v1/path/inspect') return json({ ...summary(path), bytes: 2048, samples: ['Ann Leckie/Ancillary Justice.epub'] }) + if (url.pathname === '/api/v1/path/unclaimed') + return json({ folders: [{ path: '/books/Science Fiction', files: 45, formats: { epub: 45 } }], truncated: false }) + if (url.pathname === '/api/v1/path' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) + return json({ name: body.name, path: `${body.parentPath}/${body.name}` }) + } + return json({}, 404) +} + +const stubs = { Teleport: true, - Tooltip: { template: '
' }, - TooltipTrigger: { template: '
' }, - TooltipContent: { template: '
' }, + RecycleScroller: { + props: ['items'], + template: '
', + }, +} + +function useViewport(desktop: boolean) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: desktop && query.includes('min-width: 640px'), + media: query, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + })), + ) +} + +function pressEscape(target: EventTarget = window) { + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })) +} + +function mountPicker(props: Record = {}) { + return mount(FolderPickerModal, { props: { libraryName: 'Science Fiction', ...props }, global: { stubs }, attachTo: document.body }) +} + +const row = (wrapper: ReturnType, path: string) => wrapper.find(`[role="treeitem"][data-path="${path}"]`) +const button = (wrapper: ReturnType, text: string) => { + const found = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text) + if (!found) throw new Error(`Button not found: ${text}`) + return found +} + +async function settle(ms = 60) { + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, ms)) + await flushPromises() } describe('FolderPickerModal', () => { beforeEach(() => { apiMock.mockReset() - apiMock.mockImplementation(async (input, init) => { - const url = String(input) - if (url === '/api/v1/path/config') return jsonResponse({ root: '/books' }) - if (url === '/api/v1/path?path=%2Fbooks') return jsonResponse([{ name: 'Sci-Fi', path: '/books/Sci-Fi' }]) - if (url === '/api/v1/path?path=%2Fbooks%2FSci-Fi') return jsonResponse([]) - if (url === '/api/v1/path' && init?.method === 'POST') return jsonResponse({ name: 'New', path: '/books/New' }) - return jsonResponse({}, 404) - }) + apiMock.mockImplementation(server) + localStorage.clear() + useViewport(true) }) - it('loads the configured browse root before listing folders', async () => { + afterEach(() => { + vi.unstubAllGlobals() + document.body.innerHTML = '' + }) + + it('opens on the first mount, with library landmarks, the data folder marked and system folders hidden', async () => { const wrapper = mountPicker() - await flushPromises() + await settle() - expect(apiMock).toHaveBeenNthCalledWith(1, '/api/v1/path/config') - expect(apiMock).toHaveBeenNthCalledWith(2, '/api/v1/path?path=%2Fbooks') - expect(wrapper.text()).toContain('/books') - expect(wrapper.text()).toContain('Sci-Fi') + expect(wrapper.find('h2').text()).toBe('Choose folders') + expect(wrapper.text()).toContain('For Science Fiction · Folder as Book') + expect(row(wrapper, '/books').attributes('aria-expanded')).toBe('true') + expect(row(wrapper, '/books/Fiction').text()).toContain('Novels') + expect(row(wrapper, '/data').text()).toContain('BookOrbit data') + expect(row(wrapper, '/usr').exists()).toBe(false) + expect(wrapper.text()).toContain('1 system folder hidden at /') + expect(wrapper.text()).toContain('Not in any library yet') }) - it('does not navigate above the configured browse root', async () => { + it('adds the ticked folders and remembers where they were', async () => { const wrapper = mountPicker() - await flushPromises() + await settle() + expect(button(wrapper, 'Add folders').attributes('disabled')).toBeDefined() - await wrapper.get('button[aria-label="Open Sci-Fi"]').trigger('click') - await flushPromises() - await (wrapper.vm as unknown as { goUp: () => Promise }).goUp() + await row(wrapper, '/books/Science Fiction').find('input').trigger('click') + await settle() + await button(wrapper, 'Add 1 folder').trigger('click') - expect((wrapper.vm as unknown as { currentPath: string }).currentPath).toBe('/books') + expect(wrapper.emitted('select')).toEqual([[['/books/Science Fiction']]]) + expect(JSON.parse(localStorage.getItem('bookorbit:folder-picker:recent') ?? '[]')).toEqual(['/books']) }) - it('selects multiple folders without navigating into them', async () => { + it('warns about an overlap on a ticked folder and offers to leave the other library’s folder out', async () => { const wrapper = mountPicker() - await flushPromises() + await settle() - await folderRow(wrapper, 'Sci-Fi').get('input[type="checkbox"]').setValue(true) - await buttonByText(wrapper, 'Add 1 folder').trigger('click') + await row(wrapper, '/books').find('input').trigger('click') + await settle() - expect(wrapper.emitted('select')).toEqual([[['/books/Sci-Fi']]]) - expect((wrapper.vm as unknown as { currentPath: string }).currentPath).toBe('/books') + expect(wrapper.text()).toContain('Contains Fiction, which Novels reads.') + await button(wrapper, 'Leave out Fiction').trigger('click') + await settle() + await button(wrapper, 'Add 2 folders').trigger('click') + expect(wrapper.emitted('select')).toEqual([[['/books/Science Fiction', '/books/Unsorted']]]) }) - it('marks folders that are already part of the library', async () => { - const wrapper = mountPicker(['/books/Sci-Fi']) - await flushPromises() - - const row = folderRow(wrapper, 'Sci-Fi') - expect(row.text()).toContain('Already added') - expect(row.get('input[type="checkbox"]').attributes('disabled')).toBeDefined() - }) - - it('creates folders under the configured browse root', async () => { + it('shows the books each mode would import and switches the library’s mode', async () => { const wrapper = mountPicker() - await flushPromises() + await settle() + await row(wrapper, '/books/Unsorted').find('input').trigger('click') + await settle() - await buttonByText(wrapper, 'New folder').trigger('click') - await wrapper.get('input[placeholder="New folder name"]').setValue('New') - await buttonByText(wrapper, 'Create').trigger('click') - await flushPromises() + expect(wrapper.text()).toContain('2 folders here hold several books') + await button(wrapper, 'Use File as Book').trigger('click') - const createCall = apiMock.mock.calls.find(([url, init]) => String(url) === '/api/v1/path' && init?.method === 'POST') - expect(JSON.parse(createCall?.[1]?.body as string)).toEqual({ parentPath: '/books', name: 'New' }) + expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_file']]) + expect(wrapper.text()).toContain('Switched to File as Book') + await button(wrapper, 'Undo').trigger('click') + expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_file'], ['book_per_folder']]) + }) + + it('puts the organization back when the picker is cancelled after switching it', async () => { + const wrapper = mountPicker() + await settle() + await row(wrapper, '/books/Unsorted').find('input').trigger('click') + await settle() + await button(wrapper, 'Use File as Book').trigger('click') + + await button(wrapper, 'Cancel').trigger('click') + + expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_file'], ['book_per_folder']]) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('closes the View menu with Escape, not the picker', async () => { + const wrapper = mountPicker() + await settle() + await wrapper.find('[aria-label="View options"]').trigger('click') + await settle() + expect(wrapper.find('[aria-label="View options"]').attributes('aria-expanded')).toBe('true') + + pressEscape() + await settle() + + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('on a phone, closes the plan sheet with Escape, not the picker', async () => { + useViewport(false) + const wrapper = mountPicker() + await settle() + await wrapper + .findAll('button[aria-expanded]') + .find((candidate) => candidate.text().includes('See the plan'))! + .trigger('click') + expect(wrapper.find('[role="dialog"] [role="dialog"]').exists()).toBe(true) + + pressEscape(wrapper.find('[role="dialog"] [role="dialog"]').element) + await settle() + + expect(wrapper.find('[role="dialog"] [role="dialog"]').exists()).toBe(false) + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('ticks a Shift range only across the folders a find shows', async () => { + const wrapper = mountPicker() + await settle() + await wrapper.find('#folder-picker-field').setValue('o') + await new Promise((resolve) => setTimeout(resolve, 260)) + await settle() + + await row(wrapper, '/books/Fiction').find('input').trigger('click') + await row(wrapper, '/books/Unsorted').find('input').trigger('click', { shiftKey: true }) + await settle() + + expect(row(wrapper, '/books/Science Fiction').exists()).toBe(false) + await button(wrapper, 'Add 2 folders').trigger('click') + expect(wrapper.emitted('select')).toEqual([[['/books/Fiction', '/books/Unsorted']]]) + }) + + it('leaves Enter on the hidden-system-folders button to that button, not the focused folder', async () => { + const wrapper = mountPicker() + await settle() + await row(wrapper, '/books/Unsorted').find('[data-row-focus]').trigger('click') + + await button(wrapper, '1 system folder hidden at /').trigger('keydown', { key: 'Enter' }) + await settle() + + expect(button(wrapper, 'Add folders').attributes('disabled')).toBeDefined() + }) + + it('makes the first folder reachable with Tab before any is focused', async () => { + const wrapper = mountPicker() + await settle() + + const stops = wrapper.findAll('[role="treeitem"] [data-row-focus]').filter((focus) => focus.attributes('tabindex') === '0') + expect(stops.map((focus) => focus.element.closest('[role="treeitem"]')!.getAttribute('data-path'))).toEqual(['/books']) + }) + + it('shows the mode as fixed when editing a library', async () => { + const wrapper = mountPicker({ organizationModeLocked: true, libraryName: 'Novels', selectedPaths: ['/books/Fiction'], libraryId: 7 }) + await settle() + await row(wrapper, '/books/Unsorted').find('input').trigger('click') + await settle() + + expect(wrapper.text()).toContain('Folder as Book, fixed since Novels was created') + expect(wrapper.text()).toContain('Organization is fixed once a library exists.') + expect(wrapper.findAll('button').some((candidate) => candidate.text().includes('Use File as Book'))).toBe(false) + expect(row(wrapper, '/books/Fiction').text()).toContain('This library') + }) + + it('previews a focused folder before it is ticked', async () => { + const wrapper = mountPicker() + await settle() + + await row(wrapper, '/books/Science Fiction').find('[data-row-focus]').trigger('click') + await settle(200) + + expect(wrapper.text()).toContain('Looking at') + expect(wrapper.text()).toContain('Would add 45 books') + expect(wrapper.text()).toContain('Ann Leckie/Ancillary Justice.epub') + }) + + it('chooses one storage folder for podcasts', async () => { + const wrapper = mountPicker({ kind: 'podcastStorage', multiple: false, libraryName: 'History Podcasts' }) + await settle() + + expect(wrapper.find('h2').text()).toBe('Choose a storage folder') + expect(row(wrapper, '/books/Unsorted').find('input').attributes('type')).toBe('radio') + await row(wrapper, '/books/Unsorted').find('input').trigger('click') + await row(wrapper, '/books/Science Fiction').find('input').trigger('click') + await settle() + await button(wrapper, 'Use folder').trigger('click') + + expect(wrapper.emitted('select')).toEqual([[['/books/Science Fiction']]]) + }) + + it('completes a typed path with Tab and closes the list with Escape without closing the dialog', async () => { + const wrapper = mountPicker() + await settle() + const field = wrapper.find('#folder-picker-field') + + await field.setValue('/books/Sc') + await settle() + await field.trigger('keydown', { key: 'Tab' }) + await settle() + expect((field.element as HTMLInputElement).value).toBe('/books/Science Fiction/') + + await field.trigger('keydown', { key: 'Escape' }) + expect(wrapper.find('[role="listbox"]').exists()).toBe(false) + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('says a typed system path is never opened instead of jumping to it', async () => { + const wrapper = mountPicker() + await settle() + const field = wrapper.find('#folder-picker-field') + + await field.setValue('/proc') + await field.trigger('keydown', { key: 'Enter' }) + await settle() + + expect(wrapper.text()).toContain('BookOrbit never opens /proc: it is a system folder.') + expect(field.attributes('aria-activedescendant')).toBeUndefined() + }) + + it('makes a new folder inside the focused one and ticks it', async () => { + const wrapper = mountPicker() + await settle() + await row(wrapper, '/books/Fiction').find('[data-row-focus]').trigger('click') + await settle() + + await button(wrapper, 'New folder').trigger('click') + await settle() + const input = wrapper.find('#folder-picker-new-folder') + await input.setValue('Fantasy') + await input.trigger('keydown', { key: 'Enter' }) + await settle() + + const create = apiMock.mock.calls.find(([url, init]) => String(url) === '/api/v1/path' && init?.method === 'POST') + expect(JSON.parse(String(create?.[1]?.body))).toEqual({ parentPath: '/books/Fiction', name: 'Fantasy' }) + expect(wrapper.text()).toContain('Made /books/Fiction/Fantasy and ticked it.') + expect(button(wrapper, 'Add 1 folder').exists()).toBe(true) + }) + + it('says a new folder inside this library’s folder was made, without claiming it was ticked', async () => { + const wrapper = mountPicker({ selectedPaths: ['/books/Fiction'], libraryId: 7, libraryName: 'Novels' }) + await settle() + await row(wrapper, '/books/Fiction').find('[data-row-focus]').trigger('click') + await settle() + + await button(wrapper, 'New folder').trigger('click') + await settle() + const input = wrapper.find('#folder-picker-new-folder') + await input.setValue('Fantasy') + await input.trigger('keydown', { key: 'Enter' }) + await settle() + + expect(wrapper.text()).toContain('Made /books/Fiction/Fantasy.') + expect(wrapper.text()).not.toContain('and ticked it') + }) + + it('refuses a folder name with a slash before asking the server', async () => { + const wrapper = mountPicker() + await settle() + await button(wrapper, 'New folder').trigger('click') + await settle() + const input = wrapper.find('#folder-picker-new-folder') + await input.setValue('a/b') + await input.trigger('keydown', { key: 'Enter' }) + await settle() + + expect(wrapper.text()).toContain('Use one folder name, without slashes or a leading dot.') + expect(apiMock.mock.calls.some(([url, init]) => String(url) === '/api/v1/path' && init?.method === 'POST')).toBe(false) }) }) - -function mountPicker(selectedPaths: string[] = []) { - return mount(FolderPickerModal, { - props: { selectedPaths }, - global: { - stubs: tooltipStubs, - }, - }) -} - -function buttonByText(wrapper: ReturnType, text: string) { - const button = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text) - if (!button) throw new Error(`Button not found: ${text}`) - return button -} - -function folderRow(wrapper: ReturnType, text: string) { - const row = wrapper.findAll('label').find((candidate) => candidate.text().includes(text)) - if (!row) throw new Error(`Folder row not found: ${text}`) - return row -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }) -} diff --git a/client/src/features/library/components/folder-picker/FolderFormatBar.vue b/client/src/features/library/components/folder-picker/FolderFormatBar.vue new file mode 100644 index 000000000..866efce79 --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderFormatBar.vue @@ -0,0 +1,27 @@ + + + diff --git a/client/src/features/library/components/folder-picker/FolderFormatLegend.vue b/client/src/features/library/components/folder-picker/FolderFormatLegend.vue new file mode 100644 index 000000000..febf89e9e --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderFormatLegend.vue @@ -0,0 +1,31 @@ + + + diff --git a/client/src/features/library/components/folder-picker/FolderPathLine.vue b/client/src/features/library/components/folder-picker/FolderPathLine.vue new file mode 100644 index 000000000..48deff7ad --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderPathLine.vue @@ -0,0 +1,22 @@ + + + diff --git a/client/src/features/library/components/folder-picker/FolderPickerDivider.vue b/client/src/features/library/components/folder-picker/FolderPickerDivider.vue new file mode 100644 index 000000000..b57906d2c --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderPickerDivider.vue @@ -0,0 +1,101 @@ + + + diff --git a/client/src/features/library/components/folder-picker/FolderPickerField.vue b/client/src/features/library/components/folder-picker/FolderPickerField.vue new file mode 100644 index 000000000..64ceb8ac6 --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderPickerField.vue @@ -0,0 +1,413 @@ + + + diff --git a/client/src/features/library/components/folder-picker/FolderPickerNewFolderRow.vue b/client/src/features/library/components/folder-picker/FolderPickerNewFolderRow.vue new file mode 100644 index 000000000..55ec21f92 --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderPickerNewFolderRow.vue @@ -0,0 +1,82 @@ + + + diff --git a/client/src/features/library/components/folder-picker/FolderPickerPlan.vue b/client/src/features/library/components/folder-picker/FolderPickerPlan.vue new file mode 100644 index 000000000..5293acc42 --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderPickerPlan.vue @@ -0,0 +1,126 @@ + + + diff --git a/client/src/features/library/components/folder-picker/FolderPickerPlanRow.vue b/client/src/features/library/components/folder-picker/FolderPickerPlanRow.vue new file mode 100644 index 000000000..57d1c1131 --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderPickerPlanRow.vue @@ -0,0 +1,178 @@ + + + diff --git a/client/src/features/library/components/folder-picker/FolderPickerPreview.vue b/client/src/features/library/components/folder-picker/FolderPickerPreview.vue new file mode 100644 index 000000000..0a3cf4f5b --- /dev/null +++ b/client/src/features/library/components/folder-picker/FolderPickerPreview.vue @@ -0,0 +1,168 @@ + + +