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:
@@ -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;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user