Move Add Feed button from hamburger menu to admin area

The Add RSS modal is now opened from a button directly above the feed
list in AdminFeeds.vue instead of the hamburger menu, so feed creation
lives alongside the rest of feed management. AppNav.vue no longer
renders it.

AddUrl.vue emits an `added` event on a successful save; AdminFeeds.vue
listens for it and reloads its feed list, so a newly added feed shows
up immediately without leaving the admin page.

Also fixes a bug this move surfaced: the modal's "Close" button had no
`type`, so inside the form it defaulted to type="submit" — clicking
Close on a filled-in form silently created the feed.
This commit is contained in:
2026-09-12 19:42:08 +02:00
parent 169c294e62
commit 41a17eba6d
7 changed files with 161 additions and 32 deletions
+29
View File
@@ -1,7 +1,9 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import axios from 'axios' import axios from 'axios'
import Modal from './modal/AddUrl.vue'
const showModal = ref(false)
const feeds = ref([]) const feeds = ref([])
const error = ref('') const error = ref('')
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
<template> <template>
<div class="admin"> <div class="admin">
<h1 class="admin__heading">Admin</h1> <h1 class="admin__heading">Admin</h1>
<button class="admin__add" type="button" @click="showModal = true">Add RSS</button>
<p v-if="error" class="admin__error">{{ error }}</p> <p v-if="error" class="admin__error">{{ error }}</p>
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p> <p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
<ul v-else class="admin__list"> <ul v-else class="admin__list">
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button> <button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
</li> </li>
</ul> </ul>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</div> </div>
</template> </template>
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.admin__add {
display: inline-flex;
align-items: center;
min-height: 44px;
margin-bottom: 1.25rem;
padding: 0.5rem 1.1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.admin__add:hover {
border-color: var(--color-border-hover);
}
.admin__error, .admin__error,
.admin__empty { .admin__empty {
opacity: 0.6; opacity: 0.6;
+1 -16
View File
@@ -2,11 +2,10 @@
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 Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds() const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
const headerRef = ref(null) const headerRef = ref(null)
@@ -147,11 +146,6 @@ function handleMarkAllRead() {
closeMenu() closeMenu()
} }
function openAddModal() {
showModal.value = true
closeMenu()
}
function handleToggleViewMode() { function handleToggleViewMode() {
toggleViewMode() toggleViewMode()
closeMenu() closeMenu()
@@ -212,20 +206,11 @@ function handleToggleLayout() {
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button> <button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template> </template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button> <button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink> <RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button> <button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div> </div>
</nav> </nav>
</Transition> </Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header> </header>
</template> </template>
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import AdminFeeds from '../AdminFeeds.vue'
import AddUrl from '../modal/AddUrl.vue'
vi.mock('axios')
describe('AdminFeeds', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
})
// Each mount renders the AddUrl modal via <Teleport to="body">, which
// survives outside the wrapper's own element — unmount every mounted
// instance so a leftover doesn't bleed into a later test's document.body.
let mountedWrappers = []
function mountAdmin() {
const wrapper = mount(AdminFeeds)
mountedWrappers.push(wrapper)
return wrapper
}
afterEach(() => {
for (const wrapper of mountedWrappers) {
try {
wrapper.unmount()
} catch {
// already unmounted by the test itself — fine
}
}
mountedWrappers = []
})
it('fetches and renders the feed list on mount', async () => {
axios.get.mockResolvedValueOnce({
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
})
const wrapper = mountAdmin()
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
expect(wrapper.text()).toContain('Feed A')
})
it('shows the empty state when there are no feeds', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('No feeds added yet.')
})
it('shows an error message when the feed list fails to load', async () => {
axios.get.mockRejectedValueOnce(new Error('Network Error'))
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('Failed to load feeds.')
})
it('deletes a feed after confirmation', async () => {
axios.get.mockResolvedValueOnce({
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
})
axios.delete.mockResolvedValueOnce({ status: 204 })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mountAdmin()
await flushPromises()
await wrapper.find('.admin__delete').trigger('click')
await flushPromises()
expect(axios.delete).toHaveBeenCalledWith('/api/v1/article/feed/1', expect.anything())
expect(wrapper.text()).toContain('No feeds added yet.')
confirmSpy.mockRestore()
})
it('opens the add-feed modal from the Add RSS button', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mountAdmin()
await flushPromises()
await wrapper.find('.admin__add').trigger('click')
expect(wrapper.findComponent(AddUrl).props('show')).toBe(true)
})
it('reloads the feed list when the modal reports a feed was added', async () => {
axios.get
.mockResolvedValueOnce({ data: { feeds: [] } })
.mockResolvedValueOnce({ data: { feeds: [{ id: 2, title: 'Feed B', url: 'https://example.test/b.xml' }] } })
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('No feeds added yet.')
await wrapper.findComponent(AddUrl).vm.$emit('added')
await flushPromises()
expect(axios.get).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('Feed B')
})
})
+1 -12
View File
@@ -25,7 +25,7 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7') localStorage.setItem('user-id', '7')
vi.clearAllMocks() vi.clearAllMocks()
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds() const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
feeds.value = [] feeds.value = []
allItems.value = [] allItems.value = []
feedFilter.value = null feedFilter.value = null
@@ -34,7 +34,6 @@ describe('AppNav', () => {
lastProgrammaticScroll.value = 0 lastProgrammaticScroll.value = 0
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false
viewMode.value = 'list' viewMode.value = 'list'
currentIndex.value = 0 currentIndex.value = 0
layout.value = 'list' layout.value = 'list'
@@ -130,16 +129,6 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__menu').exists()).toBe(false) expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
}) })
it('opens the add-feed modal from the menu', async () => {
const wrapper = await mountWithMenuOpen()
const { showModal } = useFeeds()
const addButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Add RSS')
await addButton.trigger('click')
expect(showModal.value).toBe(true)
})
it('switches the view mode from the menu and closes it', async () => { it('switches the view mode from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen() const wrapper = await mountWithMenuOpen()
const { viewMode } = useFeeds() const { viewMode } = useFeeds()
@@ -37,11 +37,10 @@ describe('RssFeeds', () => {
// useFeeds() returns module-level singleton refs shared across the whole // useFeeds() returns module-level singleton refs shared across the whole
// app (and this spec file) — reset them so state doesn't leak between tests. // app (and this spec file) — reset them so state doesn't leak between tests.
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds() const { feeds, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
feeds.value = [] feeds.value = []
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false
viewMode.value = 'list' viewMode.value = 'list'
currentIndex.value = 0 currentIndex.value = 0
layout.value = 'list' layout.value = 'list'
+4 -2
View File
@@ -2,9 +2,10 @@
import '@/assets/modal.css'; import '@/assets/modal.css';
import { ref } from 'vue'; import { ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
const props = defineProps({ defineProps({
show: Boolean show: Boolean
}) })
const emit = defineEmits(['close', 'added'])
const submitted = ref(false) const submitted = ref(false)
const url = ref('') const url = ref('')
const title = ref('') const title = ref('')
@@ -30,6 +31,7 @@ async function save() {
console.log(response) console.log(response)
output.value = 'saved successfully' output.value = 'saved successfully'
emit('added')
} catch (error) { } catch (error) {
console.error(error.message) console.error(error.message)
output.value = error.message output.value = error.message
@@ -57,7 +59,7 @@ async function save() {
<div class="modal-footer"> <div class="modal-footer">
<slot name="footer"> <slot name="footer">
<button type="submit">Save</button> <button type="submit">Save</button>
<button class="modal-default-button" @click="$emit('close')">Close</button> <button class="modal-default-button" type="button" @click="$emit('close')">Close</button>
</slot> </slot>
</div> </div>
</form> </form>
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
expect.anything(), expect.anything(),
) )
expect(wrapper.text()).toContain('saved successfully') expect(wrapper.text()).toContain('saved successfully')
expect(wrapper.emitted('added')).toHaveLength(1)
}) })
it('surfaces the error message when the request fails', async () => { it('surfaces the error message when the request fails', async () => {
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Network Error') expect(wrapper.text()).toContain('Network Error')
expect(wrapper.emitted('added')).toBeUndefined()
})
it('closes without submitting when Close is clicked, even with fields filled in', async () => {
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('.modal-default-button').trigger('click')
expect(axios.post).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toHaveLength(1)
}) })
}) })