Replace the feed select with a multi-feed filter modal
The header's feed <select> is now a funnel button that opens a modal with an "All feeds" checkbox and one checkbox per feed with unread items. The selection is either "All feeds" or at least one feed, is applied once when the modal closes, and is persisted in localStorage. - feedFilter holds string[] | null; setFeedFilter is a no-op when the selection is unchanged (order-insensitive), sparing callers the scroll/mark-read side effects. - Selected feeds stay listed at 0 unread so they can be unchecked; titles whose feed no longer exists are pruned after each fetch. - The persisted filter is cleared on logout and on 401 (handler extracted to httpAuthInterceptor.js so it is unit-testable). - The modal locks body scroll while open so scrolling behind it can't mark articles read.
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 { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||
import FeedFilter from './modal/FeedFilter.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
|
||||
|
||||
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
|
||||
// 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
|
||||
function openFilterModal() {
|
||||
refreshUnreadDisplay()
|
||||
filterModalOpen.value = true
|
||||
}
|
||||
|
||||
function onFilterChange(event) {
|
||||
setFeedFilter(event.target.value || null)
|
||||
filterOpen.value = false
|
||||
// setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
|
||||
// so there's nothing to check here before applying it.
|
||||
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
|
||||
@@ -166,18 +164,31 @@ function handleToggleLayout() {
|
||||
class="app-nav__sync-spinner"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<select
|
||||
<button
|
||||
v-if="onFeedsPage && feedTitles.length"
|
||||
class="app-nav__filter"
|
||||
:value="feedFilter ?? ''"
|
||||
aria-label="Filter by feed"
|
||||
@focus="onFilterFocus"
|
||||
@change="onFilterChange"
|
||||
@blur="filterOpen = false"
|
||||
ref="filterBtnRef"
|
||||
class="app-nav__filter-btn"
|
||||
:class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
: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>
|
||||
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
|
||||
</select>
|
||||
<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">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<FeedFilter
|
||||
:show="filterModalOpen"
|
||||
:titles="feedTitles"
|
||||
:counts="displayedFeedUnreadCounts"
|
||||
:selected="feedFilter"
|
||||
@close="handleFilterClose"
|
||||
/>
|
||||
</Teleport>
|
||||
<button
|
||||
class="app-nav__hamburger"
|
||||
type="button"
|
||||
@@ -283,25 +294,37 @@ function handleToggleLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
.app-nav__filter {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
.app-nav__filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
min-height: 44px;
|
||||
max-width: clamp(90px, 40vw, 220px);
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-width: 44px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-nav__filter:hover {
|
||||
.app-nav__filter-btn: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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import AppNav from '../AppNav.vue'
|
||||
import FeedFilter from '../modal/FeedFilter.vue'
|
||||
import { useFeeds } from '../../composables/useFeeds'
|
||||
|
||||
vi.mock('axios')
|
||||
@@ -218,7 +219,7 @@ describe('AppNav', () => {
|
||||
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()
|
||||
allItems.value = [
|
||||
{ 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()
|
||||
await flushPromises()
|
||||
const select = wrapper.find('.app-nav__filter')
|
||||
|
||||
// Counts only show while the dropdown is open (focused) — see AppNav.vue.
|
||||
await select.trigger('focus')
|
||||
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||
|
||||
const options = select.findAll('option').map(o => o.text())
|
||||
// "All feeds" first, then distinct titles sorted alphabetically, each with
|
||||
// its unread count.
|
||||
expect(options).toEqual(['All feeds', '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')
|
||||
const modal = wrapper.findComponent(FeedFilter)
|
||||
expect(modal.props('show')).toBe(true)
|
||||
// Distinct titles, sorted alphabetically, with per-feed unread counts.
|
||||
expect(modal.props('titles')).toEqual(['Feed A', 'Feed B'])
|
||||
expect(modal.props('counts')).toEqual({ 'Feed A': 2, 'Feed B': 1 })
|
||||
})
|
||||
|
||||
it('does not render the feed filter when there are no feeds', async () => {
|
||||
const wrapper = mountNav()
|
||||
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 () => {
|
||||
@@ -275,14 +256,16 @@ describe('AppNav', () => {
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
const select = wrapper.find('.app-nav__filter')
|
||||
await select.setValue('Feed A')
|
||||
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||
const modal = wrapper.findComponent(FeedFilter)
|
||||
modal.vm.$emit('close', ['Feed A'])
|
||||
await flushPromises()
|
||||
|
||||
expect(feedFilter.value).toBe('Feed A')
|
||||
expect(feedFilter.value).toEqual(['Feed A'])
|
||||
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()
|
||||
|
||||
expect(feedFilter.value).toBeNull()
|
||||
@@ -303,13 +286,128 @@ describe('AppNav', () => {
|
||||
// No filter → global total (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()
|
||||
|
||||
// Filtered → unread in Feed A only (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', () => {
|
||||
it('does not render the spinner, and reserves no space for it, when idle', async () => {
|
||||
const wrapper = mountNav()
|
||||
@@ -351,7 +449,7 @@ describe('AppNav', () => {
|
||||
const syncPromise = sync(true)
|
||||
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)
|
||||
|
||||
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 axios from 'axios'
|
||||
import { useFeeds } from '../useFeeds'
|
||||
import { useFeeds, logout, clearFeedFilter } from '../useFeeds'
|
||||
|
||||
vi.mock('axios')
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('useFeeds', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
localStorage.setItem('user-id', '7')
|
||||
localStorage.removeItem('feedFilter')
|
||||
vi.clearAllMocks()
|
||||
|
||||
feeds.value = []
|
||||
@@ -118,9 +119,9 @@ describe('useFeeds', () => {
|
||||
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
|
||||
// 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.
|
||||
// List-view scrolling is a different code path (handleIntersection) and
|
||||
// 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 () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
await fetchData()
|
||||
@@ -667,7 +693,7 @@ describe('useFeeds', () => {
|
||||
// Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 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])
|
||||
|
||||
await setFeedFilter(null)
|
||||
@@ -679,7 +705,7 @@ describe('useFeeds', () => {
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
await fetchData()
|
||||
|
||||
await setFeedFilter('Feed A')
|
||||
await setFeedFilter(['Feed A'])
|
||||
setInitialLoad(true)
|
||||
|
||||
// The first Feed A article scrolls above the viewport → marked read and
|
||||
@@ -701,7 +727,7 @@ describe('useFeeds', () => {
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
await fetchData()
|
||||
|
||||
await setFeedFilter('Feed B')
|
||||
await setFeedFilter(['Feed B'])
|
||||
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||
|
||||
// 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
|
||||
// were still unread.
|
||||
await setFeedFilter('Feed A')
|
||||
await setFeedFilter('Feed B')
|
||||
await setFeedFilter(['Feed A'])
|
||||
await setFeedFilter(['Feed B'])
|
||||
|
||||
expect(feeds.value).toEqual([])
|
||||
})
|
||||
@@ -727,14 +753,14 @@ describe('useFeeds', () => {
|
||||
// Switching into Feed B while in article view immediately displays its
|
||||
// one article's full content — that counts as "viewed", so it's marked
|
||||
// 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(feeds.value[0].read).toBe(true)
|
||||
|
||||
// It doesn't resurface either, once dropped on the next switch.
|
||||
await setFeedFilter('Feed A')
|
||||
await setFeedFilter('Feed B')
|
||||
await setFeedFilter(['Feed A'])
|
||||
await setFeedFilter(['Feed B'])
|
||||
expect(feeds.value).toEqual([])
|
||||
})
|
||||
|
||||
@@ -744,7 +770,7 @@ describe('useFeeds', () => {
|
||||
|
||||
// List view only shows titles — switching filters there must not mark
|
||||
// anything read; that's still driven solely by scroll (handleIntersection).
|
||||
await setFeedFilter('Feed B')
|
||||
await setFeedFilter(['Feed B'])
|
||||
|
||||
expect(axios.put).not.toHaveBeenCalled()
|
||||
expect(feeds.value[0].read).toBeUndefined()
|
||||
@@ -755,7 +781,7 @@ describe('useFeeds', () => {
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
await fetchData()
|
||||
|
||||
await setFeedFilter('Feed B')
|
||||
await setFeedFilter(['Feed B'])
|
||||
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||
|
||||
// Mark all (visible = just Feed B) read — Feed B has no unread items left.
|
||||
@@ -763,10 +789,10 @@ describe('useFeeds', () => {
|
||||
await markAllRead()
|
||||
|
||||
// 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.
|
||||
expect(feeds.value).toEqual([])
|
||||
expect(feedFilter.value).toBe('Feed B')
|
||||
expect(feedFilter.value).toEqual(['Feed B'])
|
||||
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
|
||||
|
||||
// Switching away drops the now-empty feed from the options.
|
||||
@@ -783,9 +809,9 @@ describe('useFeeds', () => {
|
||||
expect(unreadCount.value).toBe(3)
|
||||
|
||||
// Filtered: only the selected feed's unread items.
|
||||
await setFeedFilter('Feed B')
|
||||
await setFeedFilter(['Feed B'])
|
||||
expect(unreadCount.value).toBe(1)
|
||||
await setFeedFilter('Feed A')
|
||||
await setFeedFilter(['Feed A'])
|
||||
expect(unreadCount.value).toBe(2)
|
||||
|
||||
await setFeedFilter(null)
|
||||
@@ -803,6 +829,325 @@ describe('useFeeds', () => {
|
||||
allItems.value.find(i => i.id === 3).read = true
|
||||
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 () => {
|
||||
|
||||
@@ -12,18 +12,38 @@ const showMessage = ref(false)
|
||||
// the index-based observer / currentIndex logic has to know a filter exists.
|
||||
const allItems = ref([]);
|
||||
const feeds = ref([]);
|
||||
const feedFilter = ref(null) // selected feedTitle, or null = all feeds (not persisted)
|
||||
// Distinct feed titles present in the loaded (unread) items — the filter options.
|
||||
// The active filter is always kept in the list even once its feed runs out of
|
||||
// unread items, so the <select> never binds to a value that isn't an option
|
||||
// (it just shows "All caught up") and the feed stays re-selectable.
|
||||
// Selected feed titles, or null = all feeds. Persisted in localStorage (see
|
||||
// setFeedFilter) so the selection survives a reload.
|
||||
function loadPersistedFeedFilter() {
|
||||
const raw = localStorage.getItem('feedFilter')
|
||||
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 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))
|
||||
})
|
||||
// Unread count per feed title, shown next to each option in the filter
|
||||
// <select>. Derived from the master (allItems) so article view's
|
||||
// Unread count per feed title, shown next to each checkbox in the filter
|
||||
// modal. Derived from the master (allItems) so article view's
|
||||
// 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.
|
||||
const feedUnreadCounts = computed(() => {
|
||||
@@ -38,13 +58,12 @@ const feedUnreadCounts = computed(() => {
|
||||
// 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.
|
||||
const unreadCount = computed(() => {
|
||||
const items = feedFilter.value
|
||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
||||
: allItems.value
|
||||
return items.filter(i => !i.read).length
|
||||
if (!feedFilter.value) return allItems.value.filter(i => !i.read).length
|
||||
const titles = new Set(feedFilter.value)
|
||||
return allItems.value.filter(i => !i.read && titles.has(i.feedTitle)).length
|
||||
})
|
||||
// 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
|
||||
// through article view (markCurrentArticleRead marks one article read per
|
||||
// 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-id')
|
||||
clearFeedFilter()
|
||||
}
|
||||
|
||||
// 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
|
||||
// up") while staying selected — see feedTitles.
|
||||
function applyFilter() {
|
||||
feeds.value = feedFilter.value
|
||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
||||
: allItems.value.slice()
|
||||
if (!feedFilter.value) {
|
||||
feeds.value = 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
|
||||
@@ -385,16 +408,42 @@ function dropReadArticles() {
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
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()
|
||||
window.scrollTo(0, 0)
|
||||
dropReadArticles()
|
||||
feedFilter.value = title // null for "All feeds"
|
||||
feedFilter.value = next
|
||||
persistFeedFilter()
|
||||
currentIndex.value = 0
|
||||
applyFilter()
|
||||
// In article view the item at currentIndex is fully displayed, not just
|
||||
@@ -408,6 +457,34 @@ async function setFeedFilter(title) {
|
||||
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 () => {
|
||||
try {
|
||||
beginSync()
|
||||
@@ -435,6 +512,7 @@ const fetchData = async () => {
|
||||
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
allItems.value = freshItems;
|
||||
applyFilter();
|
||||
await pruneFeedFilter();
|
||||
refreshUnreadDisplay();
|
||||
await nextTick();
|
||||
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 App from './App.vue'
|
||||
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(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
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)
|
||||
}
|
||||
handleUnauthorizedResponse
|
||||
)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
Reference in New Issue
Block a user