diff --git a/vue/src/components/AppNav.vue b/vue/src/components/AppNav.vue index 4a6699b..eeb7f61 100644 --- a/vue/src/components/AppNav.vue +++ b/vue/src/components/AppNav.vue @@ -4,7 +4,7 @@ import { RouterLink, useRouter } from 'vue-router' import { useFeeds } from '@/composables/useFeeds' const router = useRouter() -const { sync, showModal, viewMode, toggleViewMode } = useFeeds() +const { sync, showModal, viewMode, toggleViewMode, markAllRead } = useFeeds() const menuOpen = ref(false) @@ -28,6 +28,11 @@ function handleSync() { closeMenu() } +function handleMarkAllRead() { + markAllRead() + closeMenu() +} + function openAddModal() { showModal.value = true closeMenu() @@ -70,6 +75,7 @@ function handleToggleViewMode() { {{ viewMode === 'list' ? 'Article view' : 'List view' }} + diff --git a/vue/src/components/__tests__/AppNav.spec.js b/vue/src/components/__tests__/AppNav.spec.js index 4b699a0..604101d 100644 --- a/vue/src/components/__tests__/AppNav.spec.js +++ b/vue/src/components/__tests__/AppNav.spec.js @@ -15,11 +15,13 @@ describe('AppNav', () => { localStorage.setItem('user-id', '7') vi.clearAllMocks() - const { feeds, showMessage, message, showModal } = useFeeds() + const { feeds, showMessage, message, showModal, viewMode, currentIndex } = useFeeds() feeds.value = [] showMessage.value = false message.value = '' showModal.value = false + viewMode.value = 'list' + currentIndex.value = 0 router = createRouter({ history: createWebHistory(), @@ -90,4 +92,57 @@ describe('AppNav', () => { expect(showModal.value).toBe(true) }) + + it('switches the view mode from the menu and closes it', async () => { + const wrapper = await mountWithMenuOpen() + const { viewMode } = useFeeds() + + const viewButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Article view') + await viewButton.trigger('click') + + expect(viewMode.value).toBe('article') + expect(wrapper.find('.app-nav__menu').exists()).toBe(false) + }) + + it('marks all articles as read from the menu after confirmation', async () => { + const { feeds } = useFeeds() + feeds.value = [ + { id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' }, + { id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' }, + ] + axios.put.mockResolvedValue({ status: 200 }) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + const wrapper = await mountWithMenuOpen() + const markAllButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Mark all as read') + await markAllButton.trigger('click') + await flushPromises() + + expect(confirmSpy).toHaveBeenCalled() + expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything()) + expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything()) + expect(feeds.value).toHaveLength(0) + expect(wrapper.find('.app-nav__menu').exists()).toBe(false) + + confirmSpy.mockRestore() + }) + + it('does not mark articles as read when the confirmation is dismissed', async () => { + const { feeds } = useFeeds() + feeds.value = [ + { id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' }, + ] + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + + const wrapper = await mountWithMenuOpen() + const markAllButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Mark all as read') + await markAllButton.trigger('click') + await flushPromises() + + expect(confirmSpy).toHaveBeenCalled() + expect(axios.put).not.toHaveBeenCalled() + expect(feeds.value).toHaveLength(1) + + confirmSpy.mockRestore() + }) }) diff --git a/vue/src/components/__tests__/RssFeeds.spec.js b/vue/src/components/__tests__/RssFeeds.spec.js index 7575254..637c006 100644 --- a/vue/src/components/__tests__/RssFeeds.spec.js +++ b/vue/src/components/__tests__/RssFeeds.spec.js @@ -23,11 +23,13 @@ describe('RssFeeds', () => { // useFeeds() returns module-level singleton refs shared across the whole // app (and this spec file) — reset them so state doesn't leak between tests. - const { feeds, showMessage, message, showModal } = useFeeds() + const { feeds, showMessage, message, showModal, viewMode, currentIndex } = useFeeds() feeds.value = [] showMessage.value = false message.value = '' showModal.value = false + viewMode.value = 'list' + currentIndex.value = 0 }) it('fetches the current user articles and shows the empty state', async () => { @@ -177,7 +179,9 @@ describe('RssFeeds', () => { const wrapper = mount(RssFeeds) await flushPromises() - await wrapper.find('.view-toggle__btn').trigger('click') + // The view-toggle button now lives in AppNav's hamburger menu, not here — + // switch modes directly through the shared composable, as AppNav would. + useFeeds().toggleViewMode() await flushPromises() expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one') diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js index 895df9a..9470dd6 100644 --- a/vue/src/composables/useFeeds.js +++ b/vue/src/composables/useFeeds.js @@ -159,6 +159,18 @@ function setInitialLoad(value) { initialLoad = value } +async function markAllRead() { + if (feeds.value.length === 0) return + if (!window.confirm('Mark all articles as read?')) return + + const ids = feeds.value.map(feed => feed.id) + feeds.value = [] + currentIndex.value = 0 + // markRead swallows its own errors, so Promise.all can't reject here. + await Promise.all(ids.map(id => markRead(id))) + showMessageForXSeconds('All articles marked as read.', 5) +} + function markCurrentArticleRead() { const feed = feeds.value[currentIndex.value] // Marking read here (rather than via removeFeed, as the scroll-based list @@ -203,6 +215,7 @@ export function useFeeds() { sync, getReadable, markRead, + markAllRead, showMessageForXSeconds, setupIntersectionObserver, removeFeed,