Merge pull request 'Replace the feed select with a multi-feed filter modal' (#4) from feature/feed-filter-modal into master
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useFeeds } from '../composables/useFeeds'
|
||||||
|
|
||||||
|
// router/index.js's route definitions lazily import(...) the view components,
|
||||||
|
// which importing the real module would still be safe to do (the imports
|
||||||
|
// themselves aren't eagerly evaluated) — but mocking it keeps this test from
|
||||||
|
// depending on the app's actual routes and lets currentRoute/push be
|
||||||
|
// controlled directly.
|
||||||
|
vi.mock('../router', () => ({
|
||||||
|
default: {
|
||||||
|
currentRoute: { value: { name: 'feeds' } },
|
||||||
|
push: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import router from '../router'
|
||||||
|
import { handleUnauthorizedResponse } from '../httpAuthInterceptor'
|
||||||
|
|
||||||
|
describe('handleUnauthorizedResponse', () => {
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('user-token', 'abc123')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(['Feed A']))
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
router.currentRoute.value.name = 'feeds'
|
||||||
|
router.push.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the session and the feed filter, and redirects to login, on a 401 from a non-login route', async () => {
|
||||||
|
const error = { response: { status: 401 } }
|
||||||
|
|
||||||
|
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||||
|
|
||||||
|
expect(localStorage.getItem('user-token')).toBeNull()
|
||||||
|
expect(localStorage.getItem('user-id')).toBeNull()
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBeNull()
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still clears the session on a 401 but does not push again when already on the login route', async () => {
|
||||||
|
router.currentRoute.value.name = 'login'
|
||||||
|
const error = { response: { status: 401 } }
|
||||||
|
|
||||||
|
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||||
|
|
||||||
|
expect(localStorage.getItem('user-token')).toBeNull()
|
||||||
|
expect(router.push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the session and filter untouched, and still rejects, on a non-401 error response', async () => {
|
||||||
|
const error = { response: { status: 500 } }
|
||||||
|
|
||||||
|
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||||
|
|
||||||
|
expect(localStorage.getItem('user-token')).toBe('abc123')
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A'])
|
||||||
|
expect(router.push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the session untouched and still rejects on an error with no response (e.g. a network error)', async () => {
|
||||||
|
const error = new Error('network')
|
||||||
|
|
||||||
|
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||||
|
|
||||||
|
expect(localStorage.getItem('user-token')).toBe('abc123')
|
||||||
|
expect(router.push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,29 +2,27 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||||
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||||
|
import FeedFilter from './modal/FeedFilter.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
|
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
|
||||||
|
|
||||||
const headerRef = ref(null)
|
const headerRef = ref(null)
|
||||||
|
const filterBtnRef = ref(null)
|
||||||
|
const filterModalOpen = ref(false)
|
||||||
|
|
||||||
// Native <select>/<option> has no "is open" state — the closed box just shows
|
function openFilterModal() {
|
||||||
// the selected option's text verbatim, so per-option unread counts can only be
|
|
||||||
// toggled based on focus, since the popup is only ever shown while focused.
|
|
||||||
// @focus ~ opening (shows counts); @change (picking an option closes the
|
|
||||||
// popup but keeps focus, so no blur fires) and @blur (focus left without
|
|
||||||
// picking) ~ closed (counts hidden, so the closed box just shows the name).
|
|
||||||
const filterOpen = ref(false)
|
|
||||||
|
|
||||||
function onFilterFocus() {
|
|
||||||
filterOpen.value = true
|
|
||||||
refreshUnreadDisplay()
|
refreshUnreadDisplay()
|
||||||
|
filterModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFilterChange(event) {
|
// setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
|
||||||
setFeedFilter(event.target.value || null)
|
// so there's nothing to check here before applying it.
|
||||||
filterOpen.value = false
|
async function handleFilterClose(selection) {
|
||||||
|
filterModalOpen.value = false
|
||||||
|
await setFeedFilter(selection)
|
||||||
|
filterBtnRef.value?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
||||||
@@ -166,18 +164,31 @@ function handleToggleLayout() {
|
|||||||
class="app-nav__sync-spinner"
|
class="app-nav__sync-spinner"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
<select
|
<button
|
||||||
v-if="onFeedsPage && feedTitles.length"
|
v-if="onFeedsPage && feedTitles.length"
|
||||||
class="app-nav__filter"
|
ref="filterBtnRef"
|
||||||
:value="feedFilter ?? ''"
|
class="app-nav__filter-btn"
|
||||||
aria-label="Filter by feed"
|
:class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
|
||||||
@focus="onFilterFocus"
|
type="button"
|
||||||
@change="onFilterChange"
|
aria-haspopup="dialog"
|
||||||
@blur="filterOpen = false"
|
:aria-expanded="filterModalOpen"
|
||||||
|
:aria-label="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
|
||||||
|
:title="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
|
||||||
|
@click="openFilterModal"
|
||||||
>
|
>
|
||||||
<option value="">All feeds</option>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
|
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
|
||||||
</select>
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Teleport to="body">
|
||||||
|
<FeedFilter
|
||||||
|
:show="filterModalOpen"
|
||||||
|
:titles="feedTitles"
|
||||||
|
:counts="displayedFeedUnreadCounts"
|
||||||
|
:selected="feedFilter"
|
||||||
|
@close="handleFilterClose"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
<button
|
<button
|
||||||
class="app-nav__hamburger"
|
class="app-nav__hamburger"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -283,25 +294,37 @@ function handleToggleLayout() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__filter {
|
.app-nav__filter-btn {
|
||||||
flex-shrink: 1;
|
display: inline-flex;
|
||||||
min-width: 0;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
max-width: clamp(90px, 40vw, 220px);
|
min-width: 44px;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__filter:hover {
|
.app-nav__filter-btn:hover {
|
||||||
border-color: var(--color-border-hover);
|
border-color: var(--color-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-nav__filter-btn--active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.app-nav__hamburger {
|
.app-nav__hamburger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AppNav from '../AppNav.vue'
|
import AppNav from '../AppNav.vue'
|
||||||
|
import FeedFilter from '../modal/FeedFilter.vue'
|
||||||
import { useFeeds } from '../../composables/useFeeds'
|
import { useFeeds } from '../../composables/useFeeds'
|
||||||
|
|
||||||
vi.mock('axios')
|
vi.mock('axios')
|
||||||
@@ -218,7 +219,7 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a feed filter with an "All feeds" option plus one per distinct feed', async () => {
|
it('opens a feed filter modal listing "All feeds" plus one entry per distinct feed', async () => {
|
||||||
const { allItems } = useFeeds()
|
const { allItems } = useFeeds()
|
||||||
allItems.value = [
|
allItems.value = [
|
||||||
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
|
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
|
||||||
@@ -228,41 +229,21 @@ describe('AppNav', () => {
|
|||||||
|
|
||||||
const wrapper = mountNav()
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const select = wrapper.find('.app-nav__filter')
|
|
||||||
|
|
||||||
// Counts only show while the dropdown is open (focused) — see AppNav.vue.
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
await select.trigger('focus')
|
|
||||||
|
|
||||||
const options = select.findAll('option').map(o => o.text())
|
const modal = wrapper.findComponent(FeedFilter)
|
||||||
// "All feeds" first, then distinct titles sorted alphabetically, each with
|
expect(modal.props('show')).toBe(true)
|
||||||
// its unread count.
|
// Distinct titles, sorted alphabetically, with per-feed unread counts.
|
||||||
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
|
expect(modal.props('titles')).toEqual(['Feed A', 'Feed B'])
|
||||||
})
|
expect(modal.props('counts')).toEqual({ 'Feed A': 2, 'Feed B': 1 })
|
||||||
|
|
||||||
it('hides the per-option unread count once the feed filter is closed', async () => {
|
|
||||||
const { allItems } = useFeeds()
|
|
||||||
allItems.value = [
|
|
||||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
|
||||||
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const wrapper = mountNav()
|
|
||||||
await flushPromises()
|
|
||||||
const select = wrapper.find('.app-nav__filter')
|
|
||||||
|
|
||||||
await select.trigger('focus')
|
|
||||||
await select.setValue('Feed A') // fires change, which closes the popup but keeps focus
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
const feedAOption = select.findAll('option').find(o => o.element.value === 'Feed A')
|
|
||||||
expect(feedAOption.text()).toBe('Feed A')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render the feed filter when there are no feeds', async () => {
|
it('does not render the feed filter when there are no feeds', async () => {
|
||||||
const wrapper = mountNav()
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
|
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
|
||||||
@@ -275,14 +256,16 @@ describe('AppNav', () => {
|
|||||||
const wrapper = mountNav()
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const select = wrapper.find('.app-nav__filter')
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
await select.setValue('Feed A')
|
const modal = wrapper.findComponent(FeedFilter)
|
||||||
|
modal.vm.$emit('close', ['Feed A'])
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(feedFilter.value).toBe('Feed A')
|
expect(feedFilter.value).toEqual(['Feed A'])
|
||||||
expect(feeds.value.map(f => f.id)).toEqual([1])
|
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||||
|
|
||||||
await select.setValue('')
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
modal.vm.$emit('close', null)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(feedFilter.value).toBeNull()
|
expect(feedFilter.value).toBeNull()
|
||||||
@@ -303,13 +286,128 @@ describe('AppNav', () => {
|
|||||||
// No filter → global total (3).
|
// No filter → global total (3).
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
|
||||||
|
|
||||||
await wrapper.find('.app-nav__filter').setValue('Feed A')
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// Filtered → unread in Feed A only (2).
|
// Filtered → unread in Feed A only (2).
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sums the unread count across every selected feed in the title badge', async () => {
|
||||||
|
const { allItems, feedFilter } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
{ id: 3, feedTitle: 'Feed C', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||||
|
]
|
||||||
|
feedFilter.value = ['Feed A', 'Feed B']
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the filter button active and swaps its label/title when a filter is set', async () => {
|
||||||
|
const { allItems, feedFilter } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const button = wrapper.find('.app-nav__filter-btn')
|
||||||
|
|
||||||
|
expect(button.classes()).not.toContain('app-nav__filter-btn--active')
|
||||||
|
expect(button.attributes('aria-label')).toBe('Filter feeds')
|
||||||
|
expect(button.attributes('title')).toBe('Filter feeds')
|
||||||
|
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(button.classes()).toContain('app-nav__filter-btn--active')
|
||||||
|
expect(button.attributes('aria-label')).toBe('Filter feeds (active)')
|
||||||
|
expect(button.attributes('title')).toBe('Filter feeds (active)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks the modal\'s open state via aria-expanded on the filter button', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const button = wrapper.find('.app-nav__filter-btn')
|
||||||
|
|
||||||
|
expect(button.attributes('aria-expanded')).toBe('false')
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(button.attributes('aria-expanded')).toBe('true')
|
||||||
|
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(button.attributes('aria-expanded')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not re-apply the filter when the emitted selection is order-insensitively unchanged', async () => {
|
||||||
|
const { allItems, feedFilter } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
]
|
||||||
|
feedFilter.value = ['Feed A', 'Feed B']
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const scrollToSpy = vi.spyOn(window, 'scrollTo')
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed B', 'Feed A'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// setFeedFilter() always scrollTo(0, 0)s — its absence proves the
|
||||||
|
// no-op path (sameFeedSelection) was taken instead.
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not re-apply the filter when null is emitted while already unfiltered', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const scrollToSpy = vi.spyOn(window, 'scrollTo')
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns focus to the filter button once the modal closes', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav({ attachTo: document.body, global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
const button = wrapper.find('.app-nav__filter-btn')
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(button.element)
|
||||||
|
})
|
||||||
|
|
||||||
describe('sync spinner', () => {
|
describe('sync spinner', () => {
|
||||||
it('does not render the spinner, and reserves no space for it, when idle', async () => {
|
it('does not render the spinner, and reserves no space for it, when idle', async () => {
|
||||||
const wrapper = mountNav()
|
const wrapper = mountNav()
|
||||||
@@ -351,7 +449,7 @@ describe('AppNav', () => {
|
|||||||
const syncPromise = sync(true)
|
const syncPromise = sync(true)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
|
||||||
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
||||||
|
|
||||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup>
|
||||||
|
import '@/assets/modal.css';
|
||||||
|
import { ref, watch, onUnmounted, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
titles: { type: Array, default: () => [] },
|
||||||
|
counts: { type: Object, default: () => ({}) },
|
||||||
|
selected: { type: Array, default: null },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
// Component-local draft: seeded from `selected` each time the modal opens
|
||||||
|
// (including on initial mount if it's already open — see the `immediate`
|
||||||
|
// watcher below) and only reported back (via `close`) once it's dismissed —
|
||||||
|
// see AppNav.vue for how the emitted selection is applied.
|
||||||
|
const draft = ref(null)
|
||||||
|
const allFeedsCheckboxRef = ref(null)
|
||||||
|
|
||||||
|
function isChecked(title) {
|
||||||
|
return draft.value !== null && draft.value.includes(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleAll(event) {
|
||||||
|
if (event.target.checked) {
|
||||||
|
draft.value = null
|
||||||
|
} else {
|
||||||
|
// The selection is never allowed to be empty — "All feeds" is the
|
||||||
|
// fallback state, so it can't be unchecked without checking a feed first.
|
||||||
|
event.target.checked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleFeed(title, checked) {
|
||||||
|
if (checked) {
|
||||||
|
draft.value = draft.value === null ? [title] : [...draft.value, title]
|
||||||
|
} else {
|
||||||
|
const remaining = (draft.value ?? []).filter(t => t !== title)
|
||||||
|
// Unchecking the last selected feed falls back to "All feeds" — see
|
||||||
|
// onToggleAll, the selection is never allowed to be empty.
|
||||||
|
draft.value = remaining.length > 0 ? remaining : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('close', draft.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event) {
|
||||||
|
if (event.key === 'Escape') handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the modal is open, scrolling the page behind it would run through
|
||||||
|
// list view's IntersectionObserver and mark articles read out from under the
|
||||||
|
// user — lock it, and restore whatever it was before. `''` is a legitimate
|
||||||
|
// saved overflow value, so `isLocked` (not a null-check on the saved value)
|
||||||
|
// tracks whether there's actually anything to restore — otherwise unlocking
|
||||||
|
// on unmount without ever having locked would stomp a value this component
|
||||||
|
// never touched.
|
||||||
|
let previousBodyOverflow = ''
|
||||||
|
let isLocked = false
|
||||||
|
function lockBodyScroll() {
|
||||||
|
previousBodyOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
isLocked = true
|
||||||
|
}
|
||||||
|
function unlockBodyScroll() {
|
||||||
|
if (!isLocked) return
|
||||||
|
document.body.style.overflow = previousBodyOverflow
|
||||||
|
isLocked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.show, async (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
draft.value = props.selected ? [...props.selected] : null
|
||||||
|
lockBodyScroll()
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
await nextTick()
|
||||||
|
allFeedsCheckboxRef.value?.focus()
|
||||||
|
} else {
|
||||||
|
unlockBodyScroll()
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unlockBodyScroll()
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="show" class="modal-mask" @click.self="handleClose">
|
||||||
|
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="feed-filter-heading">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="feed-filter-heading">Filter feeds</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="feed-filter__option">
|
||||||
|
<input
|
||||||
|
ref="allFeedsCheckboxRef"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="draft === null"
|
||||||
|
@change="onToggleAll"
|
||||||
|
/>
|
||||||
|
All feeds
|
||||||
|
</label>
|
||||||
|
<label v-for="title in titles" :key="title" class="feed-filter__option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isChecked(title)"
|
||||||
|
@change="onToggleFeed(title, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
{{ title }} ({{ counts[title] ?? 0 }})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" @click="handleClose">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feed-filter__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* modal.css's global `input` rule (display: block, width: 100%, min-height:
|
||||||
|
44px, ...) is meant for the text inputs in AddUrl.vue's form — reset it
|
||||||
|
here so checkboxes don't render full-width and oversized. */
|
||||||
|
.feed-filter__option input[type='checkbox'] {
|
||||||
|
display: inline-block;
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import FeedFilter from '../FeedFilter.vue'
|
||||||
|
|
||||||
|
describe('FeedFilter', () => {
|
||||||
|
const previousBodyOverflow = document.body.style.overflow
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.style.overflow = previousBodyOverflow
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when show is false', () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: ['Feed A'], counts: { 'Feed A': 2 }, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.modal-mask').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders "All feeds" plus one checkbox per title, defaulting a missing count to 0', () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: { 'Feed A': 2 }, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = wrapper.findAll('.feed-filter__option')
|
||||||
|
expect(options).toHaveLength(3)
|
||||||
|
expect(options[0].text()).toBe('All feeds')
|
||||||
|
expect(options[1].text()).toBe('Feed A (2)')
|
||||||
|
expect(options[2].text()).toBe('Feed B (0)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks "All feeds" and leaves every feed unchecked (but enabled) when nothing is selected', () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes[0].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(false)
|
||||||
|
expect(checkboxes[1].element.disabled).toBe(false)
|
||||||
|
expect(checkboxes[2].element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checking a feed unchecks "All feeds"', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
|
||||||
|
await checkboxes[1].setValue(true)
|
||||||
|
|
||||||
|
expect(checkboxes[0].element.checked).toBe(false)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[2].element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checking a second feed keeps both checked, with "All feeds" still unchecked', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
|
||||||
|
await checkboxes[2].setValue(true)
|
||||||
|
|
||||||
|
expect(checkboxes[0].element.checked).toBe(false)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[2].element.checked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unchecking the last selected feed falls back to "All feeds"', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
|
||||||
|
await checkboxes[1].setValue(false)
|
||||||
|
|
||||||
|
expect(checkboxes[0].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checking "All feeds" clears every selected feed', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes[0].element.checked).toBe(false)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true)
|
||||||
|
|
||||||
|
await checkboxes[0].setValue(true)
|
||||||
|
|
||||||
|
expect(checkboxes[0].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unchecking "All feeds" while it is active leaves it checked — the selection is never empty', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
const allCheckbox = wrapper.findAll('input[type="checkbox"]')[0]
|
||||||
|
|
||||||
|
await allCheckbox.setValue(false)
|
||||||
|
|
||||||
|
expect(allCheckbox.element.checked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close with the current draft when Done is clicked', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
await wrapper.findAll('input[type="checkbox"]')[1].setValue(true)
|
||||||
|
|
||||||
|
await wrapper.find('.modal-footer button').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close with the current draft on Escape, but not again once closed', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.emitted('close')).toEqual([[null]])
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close on a backdrop click, but not on a click inside the modal container', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('.modal-header').trigger('click')
|
||||||
|
expect(wrapper.emitted('close')).toBeUndefined()
|
||||||
|
|
||||||
|
await wrapper.find('.modal-mask').trigger('click')
|
||||||
|
expect(wrapper.emitted('close')).toEqual([[null]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reseeds the draft from `selected` on reopen, discarding an abandoned edit', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
// Edit the draft without confirming via Done/close.
|
||||||
|
await wrapper.findAll('input[type="checkbox"]')[2].setValue(true)
|
||||||
|
expect(wrapper.findAll('input[type="checkbox"]')[2].element.checked).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true) // Feed A, from `selected`
|
||||||
|
expect(checkboxes[2].element.checked).toBe(false) // Feed B, abandoned edit dropped
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks body scroll while open and restores the prior value on close', async () => {
|
||||||
|
document.body.style.overflow = 'scroll'
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: [], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
expect(document.body.style.overflow).toBe('scroll')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the prior body scroll value on unmount while still open', async () => {
|
||||||
|
document.body.style.overflow = 'auto'
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: [], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
expect(document.body.style.overflow).toBe('auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the "All feeds" checkbox after opening', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(wrapper.findAll('input[type="checkbox"]')[0].element)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds the draft, locks body scroll, and wires Escape when mounted already open', async () => {
|
||||||
|
document.body.style.overflow = 'auto'
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true) // Feed A, seeded from `selected`
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a pre-set body overflow untouched when mounted closed and then unmounted', () => {
|
||||||
|
document.body.style.overflow = 'scroll'
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: [], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
expect(document.body.style.overflow).toBe('scroll')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders dialog accessibility attributes tied to the heading', () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialog = wrapper.find('.modal-container')
|
||||||
|
expect(dialog.attributes('role')).toBe('dialog')
|
||||||
|
expect(dialog.attributes('aria-modal')).toBe('true')
|
||||||
|
const labelledby = dialog.attributes('aria-labelledby')
|
||||||
|
expect(wrapper.find(`#${labelledby}`).text()).toBe('Filter feeds')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// loadPersistedFeedFilter() (see useFeeds.js) reads localStorage at module
|
||||||
|
// import time, not lazily — every case here has to vi.resetModules() and
|
||||||
|
// dynamically re-import so the module-scope ref actually re-initializes
|
||||||
|
// against the localStorage value set up beforehand. Mirrors the pattern in
|
||||||
|
// viewModeBoot.spec.js.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
|
describe('feedFilter boot persistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots with the persisted array when it is valid', async () => {
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(['Feed A', 'Feed B']))
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots into "All feeds" when the key is absent', async () => {
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots into "All feeds" when the persisted array is empty', async () => {
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify([]))
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a plain object', JSON.stringify({ title: 'Feed A' })],
|
||||||
|
['a bare string', JSON.stringify('Feed A')],
|
||||||
|
])('boots into "All feeds" when the persisted value is not an array (%s)', async (_label, raw) => {
|
||||||
|
localStorage.setItem('feedFilter', raw)
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots into "All feeds" when the persisted value is not valid JSON', async () => {
|
||||||
|
localStorage.setItem('feedFilter', 'not json')
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['numbers', [1]],
|
||||||
|
['null', [null]],
|
||||||
|
['a mix of strings and non-strings', ['a', 2]],
|
||||||
|
])('boots into "All feeds" when the array contains non-string elements (%s)', async (_label, value) => {
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(value))
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useFeeds } from '../useFeeds'
|
import { useFeeds, logout, clearFeedFilter } from '../useFeeds'
|
||||||
|
|
||||||
vi.mock('axios')
|
vi.mock('axios')
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ describe('useFeeds', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
localStorage.setItem('user-id', '7')
|
localStorage.setItem('user-id', '7')
|
||||||
|
localStorage.removeItem('feedFilter')
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
@@ -118,9 +119,9 @@ describe('useFeeds', () => {
|
|||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
|
it('refreshes the filter modal\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
|
||||||
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
|
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
|
||||||
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
|
// useFeeds.js) — AppNav's filter modal renders it instead of the live
|
||||||
// computed so paging through article view doesn't force it to repaint.
|
// computed so paging through article view doesn't force it to repaint.
|
||||||
// List-view scrolling is a different code path (handleIntersection) and
|
// List-view scrolling is a different code path (handleIntersection) and
|
||||||
// should keep it live regardless.
|
// should keep it live regardless.
|
||||||
@@ -658,6 +659,31 @@ describe('useFeeds', () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const threeFeedsResponse = {
|
||||||
|
data: {
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Feed B',
|
||||||
|
items: [
|
||||||
|
{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Feed C',
|
||||||
|
items: [
|
||||||
|
{ id: 3, title: 'C1', content: '', url: 'https://example.test/c1', timestamp: '2026-01-03 10:00:00' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
it('narrows the displayed list to a single feed and restores it on "All feeds"', async () => {
|
it('narrows the displayed list to a single feed and restores it on "All feeds"', async () => {
|
||||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
await fetchData()
|
await fetchData()
|
||||||
@@ -667,7 +693,7 @@ describe('useFeeds', () => {
|
|||||||
// Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1).
|
// Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1).
|
||||||
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
|
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
|
||||||
|
|
||||||
await setFeedFilter('Feed A')
|
await setFeedFilter(['Feed A'])
|
||||||
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
|
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
|
||||||
|
|
||||||
await setFeedFilter(null)
|
await setFeedFilter(null)
|
||||||
@@ -679,7 +705,7 @@ describe('useFeeds', () => {
|
|||||||
axios.put.mockResolvedValue({ status: 200 })
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
await fetchData()
|
await fetchData()
|
||||||
|
|
||||||
await setFeedFilter('Feed A')
|
await setFeedFilter(['Feed A'])
|
||||||
setInitialLoad(true)
|
setInitialLoad(true)
|
||||||
|
|
||||||
// The first Feed A article scrolls above the viewport → marked read and
|
// The first Feed A article scrolls above the viewport → marked read and
|
||||||
@@ -701,7 +727,7 @@ describe('useFeeds', () => {
|
|||||||
axios.put.mockResolvedValue({ status: 200 })
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
await fetchData()
|
await fetchData()
|
||||||
|
|
||||||
await setFeedFilter('Feed B')
|
await setFeedFilter(['Feed B'])
|
||||||
expect(feeds.value.map(f => f.id)).toEqual([2])
|
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||||
|
|
||||||
// Article view marks the displayed article read in place (feed.read =
|
// Article view marks the displayed article read in place (feed.read =
|
||||||
@@ -712,8 +738,8 @@ describe('useFeeds', () => {
|
|||||||
|
|
||||||
// Switching to another feed and back must not resurrect it as if it
|
// Switching to another feed and back must not resurrect it as if it
|
||||||
// were still unread.
|
// were still unread.
|
||||||
await setFeedFilter('Feed A')
|
await setFeedFilter(['Feed A'])
|
||||||
await setFeedFilter('Feed B')
|
await setFeedFilter(['Feed B'])
|
||||||
|
|
||||||
expect(feeds.value).toEqual([])
|
expect(feeds.value).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -727,14 +753,14 @@ describe('useFeeds', () => {
|
|||||||
// Switching into Feed B while in article view immediately displays its
|
// Switching into Feed B while in article view immediately displays its
|
||||||
// one article's full content — that counts as "viewed", so it's marked
|
// one article's full content — that counts as "viewed", so it's marked
|
||||||
// read the same way toggleViewMode()/nextArticle()/prevArticle() do.
|
// read the same way toggleViewMode()/nextArticle()/prevArticle() do.
|
||||||
await setFeedFilter('Feed B')
|
await setFeedFilter(['Feed B'])
|
||||||
|
|
||||||
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
|
||||||
expect(feeds.value[0].read).toBe(true)
|
expect(feeds.value[0].read).toBe(true)
|
||||||
|
|
||||||
// It doesn't resurface either, once dropped on the next switch.
|
// It doesn't resurface either, once dropped on the next switch.
|
||||||
await setFeedFilter('Feed A')
|
await setFeedFilter(['Feed A'])
|
||||||
await setFeedFilter('Feed B')
|
await setFeedFilter(['Feed B'])
|
||||||
expect(feeds.value).toEqual([])
|
expect(feeds.value).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -744,7 +770,7 @@ describe('useFeeds', () => {
|
|||||||
|
|
||||||
// List view only shows titles — switching filters there must not mark
|
// List view only shows titles — switching filters there must not mark
|
||||||
// anything read; that's still driven solely by scroll (handleIntersection).
|
// anything read; that's still driven solely by scroll (handleIntersection).
|
||||||
await setFeedFilter('Feed B')
|
await setFeedFilter(['Feed B'])
|
||||||
|
|
||||||
expect(axios.put).not.toHaveBeenCalled()
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
expect(feeds.value[0].read).toBeUndefined()
|
expect(feeds.value[0].read).toBeUndefined()
|
||||||
@@ -755,7 +781,7 @@ describe('useFeeds', () => {
|
|||||||
axios.put.mockResolvedValue({ status: 200 })
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
await fetchData()
|
await fetchData()
|
||||||
|
|
||||||
await setFeedFilter('Feed B')
|
await setFeedFilter(['Feed B'])
|
||||||
expect(feeds.value.map(f => f.id)).toEqual([2])
|
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||||
|
|
||||||
// Mark all (visible = just Feed B) read — Feed B has no unread items left.
|
// Mark all (visible = just Feed B) read — Feed B has no unread items left.
|
||||||
@@ -763,10 +789,10 @@ describe('useFeeds', () => {
|
|||||||
await markAllRead()
|
await markAllRead()
|
||||||
|
|
||||||
// The list empties, but the filter stays put and the feed remains a
|
// The list empties, but the filter stays put and the feed remains a
|
||||||
// selectable option (so the <select> never dangles) even though it no
|
// selectable option (so a checked feed never dangles) even though it no
|
||||||
// longer has unread items.
|
// longer has unread items.
|
||||||
expect(feeds.value).toEqual([])
|
expect(feeds.value).toEqual([])
|
||||||
expect(feedFilter.value).toBe('Feed B')
|
expect(feedFilter.value).toEqual(['Feed B'])
|
||||||
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
|
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
|
||||||
|
|
||||||
// Switching away drops the now-empty feed from the options.
|
// Switching away drops the now-empty feed from the options.
|
||||||
@@ -783,9 +809,9 @@ describe('useFeeds', () => {
|
|||||||
expect(unreadCount.value).toBe(3)
|
expect(unreadCount.value).toBe(3)
|
||||||
|
|
||||||
// Filtered: only the selected feed's unread items.
|
// Filtered: only the selected feed's unread items.
|
||||||
await setFeedFilter('Feed B')
|
await setFeedFilter(['Feed B'])
|
||||||
expect(unreadCount.value).toBe(1)
|
expect(unreadCount.value).toBe(1)
|
||||||
await setFeedFilter('Feed A')
|
await setFeedFilter(['Feed A'])
|
||||||
expect(unreadCount.value).toBe(2)
|
expect(unreadCount.value).toBe(2)
|
||||||
|
|
||||||
await setFeedFilter(null)
|
await setFeedFilter(null)
|
||||||
@@ -803,6 +829,325 @@ describe('useFeeds', () => {
|
|||||||
allItems.value.find(i => i.id === 3).read = true
|
allItems.value.find(i => i.id === 3).read = true
|
||||||
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 1, 'Feed B': 1 })
|
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 1, 'Feed B': 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filters the displayed list and unread count to the union of several selected feeds', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(threeFeedsResponse)
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
await setFeedFilter(['Feed A', 'Feed C'])
|
||||||
|
|
||||||
|
// Newest first, Feed B (id 2) excluded.
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
|
||||||
|
expect(unreadCount.value).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every selected feed listed (even at 0 unread) once all of them are emptied', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(threeFeedsResponse)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
await setFeedFilter(['Feed A', 'Feed C'])
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
await markAllRead()
|
||||||
|
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A', 'Feed C'])
|
||||||
|
// Feed B is still present because it still has an unread item; A and C
|
||||||
|
// stay present purely because they're selected — see feedTitles.
|
||||||
|
expect(feedTitles.value).toEqual(['Feed A', 'Feed B', 'Feed C'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits mark-all-read to the selected feeds, leaving the rest untouched', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(threeFeedsResponse)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
await setFeedFilter(['Feed A', 'Feed C'])
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
await markAllRead()
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/3', null, expect.anything())
|
||||||
|
expect(axios.put).not.toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
|
||||||
|
// Feed B's unread item survives in the master untouched.
|
||||||
|
expect(allItems.value.map(f => f.id)).toEqual([2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses an empty array selection to "All feeds" rather than an empty filter', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
await setFeedFilter(['Feed A'])
|
||||||
|
await setFeedFilter([])
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('feed filter persistence', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.removeItem('feedFilter')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists a feed selection to localStorage, and removes the key for "All feeds"', async () => {
|
||||||
|
await setFeedFilter(['Feed A', 'Feed B'])
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A', 'Feed B']))
|
||||||
|
|
||||||
|
await setFeedFilter(null)
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearFeedFilter resets the ref and removes the persisted key', async () => {
|
||||||
|
await setFeedFilter(['Feed A'])
|
||||||
|
|
||||||
|
clearFeedFilter()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout clears the persisted feed filter along with the session', async () => {
|
||||||
|
axios.post.mockResolvedValueOnce({ status: 200 })
|
||||||
|
await setFeedFilter(['Feed A'])
|
||||||
|
|
||||||
|
await logout()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pruneFeedFilter (via fetchData)', () => {
|
||||||
|
// pruneFeedFilter's own axios.get call is routed by URL, alongside
|
||||||
|
// fetchData's — vi.clearAllMocks() (see the outer beforeEach) clears call
|
||||||
|
// history but not a mockImplementation, so each test here resets it
|
||||||
|
// explicitly rather than leaking a stale router into later tests.
|
||||||
|
afterEach(() => {
|
||||||
|
axios.get.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockArticleAndFeedsGets({ articleFeeds, filterFeeds, filterFeedsRejects }) {
|
||||||
|
axios.get.mockImplementation((url) => {
|
||||||
|
if (url.startsWith('/api/v1/article/get/')) {
|
||||||
|
return Promise.resolve({ data: { feeds: articleFeeds } })
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v1/article/feeds/')) {
|
||||||
|
if (filterFeedsRejects) return Promise.reject(filterFeedsRejects)
|
||||||
|
return Promise.resolve({ data: { feeds: filterFeeds } })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('unexpected axios.get url: ' + url))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks(times = 10) {
|
||||||
|
for (let i = 0; i < times; i++) await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not request the feeds endpoint when no filter is active', async () => {
|
||||||
|
feedFilter.value = null
|
||||||
|
mockArticleAndFeedsGets({ articleFeeds: [] })
|
||||||
|
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledTimes(1)
|
||||||
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a selected title whose feed no longer exists, keeping the one that does', async () => {
|
||||||
|
feedFilter.value = ['Feed A', 'Gone']
|
||||||
|
mockArticleAndFeedsGets({
|
||||||
|
articleFeeds: [
|
||||||
|
{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] },
|
||||||
|
{ title: 'Feed B', items: [{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' }] },
|
||||||
|
],
|
||||||
|
filterFeeds: [{ title: 'Feed A' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A'])
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses to "All feeds" when every selected title is gone', async () => {
|
||||||
|
feedFilter.value = ['Gone']
|
||||||
|
mockArticleAndFeedsGets({
|
||||||
|
articleFeeds: [
|
||||||
|
{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] },
|
||||||
|
],
|
||||||
|
filterFeeds: [{ title: 'Feed A' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBeNull()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a selected feed with 0 unread items selected, without re-persisting', async () => {
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem')
|
||||||
|
mockArticleAndFeedsGets({
|
||||||
|
articleFeeds: [], // Feed A has no unread items right now
|
||||||
|
filterFeeds: [{ title: 'Feed A' }], // but the feed itself still exists
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A'])
|
||||||
|
expect(setItemSpy).not.toHaveBeenCalledWith('feedFilter', expect.anything())
|
||||||
|
|
||||||
|
setItemSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the filter untouched and surfaces no user-facing error when the feeds lookup fails', async () => {
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(['Feed A']))
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
mockArticleAndFeedsGets({
|
||||||
|
articleFeeds: [
|
||||||
|
{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] },
|
||||||
|
],
|
||||||
|
filterFeedsRejects: new Error('network'),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A'])
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
|
||||||
|
expect(showMessage.value).toBe(false)
|
||||||
|
expect(message.value).toBe('')
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not request the feeds endpoint when the article fetch itself fails', async () => {
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
axios.get.mockImplementation((url) => {
|
||||||
|
if (url.startsWith('/api/v1/article/get/')) return Promise.reject(new Error('network'))
|
||||||
|
return Promise.reject(new Error('unexpected axios.get url: ' + url))
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not resurrect the filter if it was cleared while the feeds lookup was still in flight', async () => {
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
let resolveFeeds
|
||||||
|
axios.get.mockImplementation((url) => {
|
||||||
|
if (url.startsWith('/api/v1/article/get/')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { feeds: [{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] }] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v1/article/feeds/')) {
|
||||||
|
return new Promise(resolve => { resolveFeeds = resolve })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('unexpected axios.get url: ' + url))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
await flushMicrotasks() // let fetchData's own GET resolve and pruneFeedFilter dispatch its GET
|
||||||
|
|
||||||
|
clearFeedFilter()
|
||||||
|
resolveFeeds({ data: { feeds: [{ title: 'Feed A' }] } })
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a filter selected mid-request rather than reverting it to the prune outcome', async () => {
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
let resolveFeeds
|
||||||
|
axios.get.mockImplementation((url) => {
|
||||||
|
if (url.startsWith('/api/v1/article/get/')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
feeds: [
|
||||||
|
{ title: 'Feed A', items: [{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }] },
|
||||||
|
{ title: 'Feed B', items: [{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v1/article/feeds/')) {
|
||||||
|
return new Promise(resolve => { resolveFeeds = resolve })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('unexpected axios.get url: ' + url))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
await setFeedFilter(['Feed B'])
|
||||||
|
// The (stale, already-in-flight) feeds list still includes Feed B, so the
|
||||||
|
// switch survives the prune that resolves afterwards.
|
||||||
|
resolveFeeds({ data: { feeds: [{ title: 'Feed A' }, { title: 'Feed B' }] } })
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
expect(feedFilter.value).toEqual(['Feed B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps isSyncing true until the feed-filter prune settles, not just the initial fetch', async () => {
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
let resolveFeeds
|
||||||
|
axios.get.mockImplementation((url) => {
|
||||||
|
if (url.startsWith('/api/v1/article/get/')) {
|
||||||
|
return Promise.resolve({ data: { feeds: [{ title: 'Feed A', items: [] }] } })
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v1/article/feeds/')) {
|
||||||
|
return new Promise(resolve => { resolveFeeds = resolve })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('unexpected axios.get url: ' + url))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
resolveFeeds({ data: { feeds: [{ title: 'Feed A' }] } })
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setFeedFilter no-op guard', () => {
|
||||||
|
it('no-ops on an empty array while already unfiltered', async () => {
|
||||||
|
feedFilter.value = null
|
||||||
|
const scrollToSpy = vi.spyOn(window, 'scrollTo')
|
||||||
|
|
||||||
|
await setFeedFilter([])
|
||||||
|
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
|
||||||
|
scrollToSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mark anything read on a no-op call while in article view', async () => {
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
viewMode.value = 'article'
|
||||||
|
feeds.value = [{ id: 1, feedTitle: 'Feed A', title: 'a' }]
|
||||||
|
|
||||||
|
await setFeedFilter(['Feed A'])
|
||||||
|
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
viewMode.value = 'list'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('strips leftover embedded-video placeholder headings', async () => {
|
it('strips leftover embedded-video placeholder headings', async () => {
|
||||||
|
|||||||
@@ -12,18 +12,38 @@ const showMessage = ref(false)
|
|||||||
// the index-based observer / currentIndex logic has to know a filter exists.
|
// the index-based observer / currentIndex logic has to know a filter exists.
|
||||||
const allItems = ref([]);
|
const allItems = ref([]);
|
||||||
const feeds = ref([]);
|
const feeds = ref([]);
|
||||||
const feedFilter = ref(null) // selected feedTitle, or null = all feeds (not persisted)
|
// Selected feed titles, or null = all feeds. Persisted in localStorage (see
|
||||||
// Distinct feed titles present in the loaded (unread) items — the filter options.
|
// setFeedFilter) so the selection survives a reload.
|
||||||
// The active filter is always kept in the list even once its feed runs out of
|
function loadPersistedFeedFilter() {
|
||||||
// unread items, so the <select> never binds to a value that isn't an option
|
const raw = localStorage.getItem('feedFilter')
|
||||||
// (it just shows "All caught up") and the feed stays re-selectable.
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) && parsed.length > 0 && parsed.every(title => typeof title === 'string') ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const feedFilter = ref(loadPersistedFeedFilter())
|
||||||
|
// Feed titles are specific to the account that was logged in — call this
|
||||||
|
// wherever a session ends (explicit logout, or a 401 dropping a stale/expired
|
||||||
|
// one in main.js) so they can't leak (in storage or in the still-live module
|
||||||
|
// singleton) into whichever account is active next on this device.
|
||||||
|
export function clearFeedFilter() {
|
||||||
|
localStorage.removeItem('feedFilter')
|
||||||
|
feedFilter.value = null
|
||||||
|
}
|
||||||
|
// Distinct feed titles present in the loaded (unread) items — the filter
|
||||||
|
// checkbox options. Every currently selected title is always kept in the
|
||||||
|
// list even once its feed runs out of unread items, so a checked feed never
|
||||||
|
// disappears out from under the user before they get a chance to uncheck it.
|
||||||
const feedTitles = computed(() => {
|
const feedTitles = computed(() => {
|
||||||
const titles = new Set(allItems.value.map(i => i.feedTitle))
|
const titles = new Set(allItems.value.map(i => i.feedTitle))
|
||||||
if (feedFilter.value) titles.add(feedFilter.value)
|
for (const title of feedFilter.value ?? []) titles.add(title)
|
||||||
return [...titles].sort((a, b) => a.localeCompare(b))
|
return [...titles].sort((a, b) => a.localeCompare(b))
|
||||||
})
|
})
|
||||||
// Unread count per feed title, shown next to each option in the filter
|
// Unread count per feed title, shown next to each checkbox in the filter
|
||||||
// <select>. Derived from the master (allItems) so article view's
|
// modal. Derived from the master (allItems) so article view's
|
||||||
// read-but-still-shown items are excluded. A title kept in feedTitles with no
|
// read-but-still-shown items are excluded. A title kept in feedTitles with no
|
||||||
// unread items left has no key here — render it as 0.
|
// unread items left has no key here — render it as 0.
|
||||||
const feedUnreadCounts = computed(() => {
|
const feedUnreadCounts = computed(() => {
|
||||||
@@ -38,13 +58,12 @@ const feedUnreadCounts = computed(() => {
|
|||||||
// rather than the displayed `feeds` so article view's read-but-still-shown
|
// rather than the displayed `feeds` so article view's read-but-still-shown
|
||||||
// items are excluded and the count matches the filter regardless of view.
|
// items are excluded and the count matches the filter regardless of view.
|
||||||
const unreadCount = computed(() => {
|
const unreadCount = computed(() => {
|
||||||
const items = feedFilter.value
|
if (!feedFilter.value) return allItems.value.filter(i => !i.read).length
|
||||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
const titles = new Set(feedFilter.value)
|
||||||
: allItems.value
|
return allItems.value.filter(i => !i.read && titles.has(i.feedTitle)).length
|
||||||
return items.filter(i => !i.read).length
|
|
||||||
})
|
})
|
||||||
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
|
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
|
||||||
// for the filter <select>'s per-feed option counts, instead of binding
|
// for the filter modal's per-feed checkbox counts, instead of binding
|
||||||
// feedUnreadCounts directly — by design, this stays frozen while paging
|
// feedUnreadCounts directly — by design, this stays frozen while paging
|
||||||
// through article view (markCurrentArticleRead marks one article read per
|
// through article view (markCurrentArticleRead marks one article read per
|
||||||
// page turn, which would otherwise recompute it live on every single swipe).
|
// page turn, which would otherwise recompute it live on every single swipe).
|
||||||
@@ -163,6 +182,7 @@ export async function logout() {
|
|||||||
}
|
}
|
||||||
localStorage.removeItem('user-token')
|
localStorage.removeItem('user-token')
|
||||||
localStorage.removeItem('user-id')
|
localStorage.removeItem('user-id')
|
||||||
|
clearFeedFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
|
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
|
||||||
@@ -364,9 +384,12 @@ async function markRead(id) {
|
|||||||
// feed with no remaining unread items projects to an empty list ("All caught
|
// feed with no remaining unread items projects to an empty list ("All caught
|
||||||
// up") while staying selected — see feedTitles.
|
// up") while staying selected — see feedTitles.
|
||||||
function applyFilter() {
|
function applyFilter() {
|
||||||
feeds.value = feedFilter.value
|
if (!feedFilter.value) {
|
||||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
feeds.value = allItems.value.slice()
|
||||||
: allItems.value.slice()
|
return
|
||||||
|
}
|
||||||
|
const titles = new Set(feedFilter.value)
|
||||||
|
feeds.value = allItems.value.filter(i => titles.has(i.feedTitle))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Article view marks an article read in place (feed.read = true) without
|
// Article view marks an article read in place (feed.read = true) without
|
||||||
@@ -385,16 +408,42 @@ function dropReadArticles() {
|
|||||||
allItems.value = allItems.value.filter(feed => !feed.read)
|
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Order-insensitive: callers building the next selection from checkbox
|
||||||
|
// toggles (see FeedFilter.vue) can produce an array in a different order than
|
||||||
|
// what's already active — that's not a real change.
|
||||||
|
function sameFeedSelection(a, b) {
|
||||||
|
if (a === null || b === null) return a === b
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
const setA = new Set(a)
|
||||||
|
return b.every(title => setA.has(title))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by setFeedFilter() and pruneFeedFilter() — both assign feedFilter.value
|
||||||
|
// then need the same localStorage write/removal to keep it in sync.
|
||||||
|
function persistFeedFilter() {
|
||||||
|
if (feedFilter.value) {
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(feedFilter.value))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('feedFilter')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
||||||
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
|
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
|
||||||
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
||||||
// article view too — there are no .observe nodes there so the setup is a no-op,
|
// article view too — there are no .observe nodes there so the setup is a no-op,
|
||||||
// and resetting currentIndex keeps paging valid against the new list.
|
// and resetting currentIndex keeps paging valid against the new list.
|
||||||
async function setFeedFilter(title) {
|
// A no-op (order-insensitively unchanged) selection returns early, before any
|
||||||
|
// of the scrollTo/dropReadArticles/mark-read side effects below — every
|
||||||
|
// caller gets this protection for free rather than having to check first.
|
||||||
|
async function setFeedFilter(titles) {
|
||||||
|
const next = titles && titles.length > 0 ? titles : null // null for "All feeds"
|
||||||
|
if (sameFeedSelection(feedFilter.value, next)) return
|
||||||
disconnectObserver()
|
disconnectObserver()
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
dropReadArticles()
|
dropReadArticles()
|
||||||
feedFilter.value = title // null for "All feeds"
|
feedFilter.value = next
|
||||||
|
persistFeedFilter()
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
applyFilter()
|
applyFilter()
|
||||||
// In article view the item at currentIndex is fully displayed, not just
|
// In article view the item at currentIndex is fully displayed, not just
|
||||||
@@ -408,6 +457,34 @@ async function setFeedFilter(title) {
|
|||||||
setupIntersectionObserver()
|
setupIntersectionObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A persisted/selected title can outlive its feed (deleted or renamed from
|
||||||
|
// Admin) — the modal then has no way to un-list a title the user never got a
|
||||||
|
// chance to see and uncheck. `allItems` can't tell "the feed still exists"
|
||||||
|
// from "the feed has 0 unread items right now" (feedTitles deliberately keeps
|
||||||
|
// the latter listed), so it's not a usable source of truth here; the feed
|
||||||
|
// list itself is, and AdminFeeds.vue already fetches exactly that from this
|
||||||
|
// same endpoint. Only requested when a filter is actually active, since
|
||||||
|
// that's the only case with anything to prune.
|
||||||
|
async function pruneFeedFilter() {
|
||||||
|
if (!feedFilter.value) return
|
||||||
|
try {
|
||||||
|
const user_id = localStorage.getItem("user-id")
|
||||||
|
const response = await axios.get("/api/v1/article/feeds/" + user_id, authHeaders())
|
||||||
|
// The filter may have been cleared (logout / 401) while the request was in flight.
|
||||||
|
if (!feedFilter.value) return
|
||||||
|
const existingTitles = new Set(response.data.feeds.map(feed => feed.title))
|
||||||
|
const pruned = feedFilter.value.filter(title => existingTitles.has(title))
|
||||||
|
if (pruned.length === feedFilter.value.length) return
|
||||||
|
feedFilter.value = pruned.length > 0 ? pruned : null
|
||||||
|
persistFeedFilter()
|
||||||
|
applyFilter()
|
||||||
|
} catch (error) {
|
||||||
|
// Best-effort: a stale title stuck in the modal until the next successful
|
||||||
|
// prune is harmless, so don't fail the reload over it.
|
||||||
|
console.error('Error pruning feed filter', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
beginSync()
|
beginSync()
|
||||||
@@ -435,6 +512,7 @@ const fetchData = async () => {
|
|||||||
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
allItems.value = freshItems;
|
allItems.value = freshItems;
|
||||||
applyFilter();
|
applyFilter();
|
||||||
|
await pruneFeedFilter();
|
||||||
refreshUnreadDisplay();
|
refreshUnreadDisplay();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import router from './router'
|
||||||
|
import { clearFeedFilter } from './composables/useFeeds'
|
||||||
|
|
||||||
|
// Extracted out of main.js so it's unit-testable in isolation: importing
|
||||||
|
// main.js itself boots the whole app (createApp(...).mount('#app')) and
|
||||||
|
// eagerly touches the router's lazy-loaded route components, neither of
|
||||||
|
// which a test can do safely.
|
||||||
|
//
|
||||||
|
// A 401 means the server has rejected the token (missing, expired, or
|
||||||
|
// revoked via logout/token_version bump elsewhere). Drop the stale session
|
||||||
|
// and send the user back to login rather than leaving them on a page where
|
||||||
|
// every request silently fails.
|
||||||
|
export function handleUnauthorizedResponse(error) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('user-token')
|
||||||
|
localStorage.removeItem('user-id')
|
||||||
|
clearFeedFilter()
|
||||||
|
if (router.currentRoute.value.name !== 'login') {
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
+2
-14
@@ -4,23 +4,11 @@ import axios from 'axios'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { handleUnauthorizedResponse } from './httpAuthInterceptor'
|
||||||
|
|
||||||
// A 401 means the server has rejected the token (missing, expired, or
|
|
||||||
// revoked via logout/token_version bump elsewhere). Drop the stale session
|
|
||||||
// and send the user back to login rather than leaving them on a page where
|
|
||||||
// every request silently fails.
|
|
||||||
axios.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
handleUnauthorizedResponse
|
||||||
if (error.response?.status === 401) {
|
|
||||||
localStorage.removeItem('user-token')
|
|
||||||
localStorage.removeItem('user-id')
|
|
||||||
if (router.currentRoute.value.name !== 'login') {
|
|
||||||
router.push({ name: 'login' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|||||||
Reference in New Issue
Block a user