From 5b982e0a00a9164203262be37f00ac8380281a5d Mon Sep 17 00:00:00 2001 From: Devlin Junker Date: Sun, 20 Aug 2023 20:58:51 -0700 Subject: [PATCH 1/7] add unread route and component - started on unread component and using load-more callback from VirtualScroll component - realized we need to change to a 3-panel display because VirtualScroll removes the rendered component even when open if you scroll too much - created FeedItemDisplay component to display a selected feed item details Signed-off-by: Devlin Junker --- src/App.vue | 1 + src/components/FeedItem.vue | 8 +- src/components/FeedItemDisplay.vue | 178 +++++++++++++++++++++++++++++ src/components/Sidebar.vue | 4 +- src/components/Starred.vue | 18 ++- src/components/Unread.vue | 111 ++++++++++++++++++ src/components/VirtualScroll.vue | 6 +- src/routes/index.ts | 8 ++ src/store/feed.ts | 5 +- src/store/item.ts | 61 +++++++++- src/types/MutationTypes.ts | 2 + 11 files changed, 389 insertions(+), 13 deletions(-) create mode 100644 src/components/FeedItemDisplay.vue create mode 100644 src/components/Unread.vue diff --git a/src/App.vue b/src/App.vue index 98c3e393c..dafe1a33c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -25,6 +25,7 @@ export default Vue.extend({ await this.$store.dispatch(ACTIONS.FETCH_FOLDERS) await this.$store.dispatch(ACTIONS.FETCH_FEEDS) await this.$store.dispatch(ACTIONS.FETCH_STARRED) + await this.$store.dispatch(ACTIONS.FETCH_UNREAD) }, }) diff --git a/src/components/FeedItem.vue b/src/components/FeedItem.vue index 3d59b4f92..8650b58dd 100644 --- a/src/components/FeedItem.vue +++ b/src/components/FeedItem.vue @@ -11,7 +11,7 @@ - +
@@ -162,7 +162,8 @@ export default Vue.extend({ }, methods: { expand() { - this.expanded = !this.expanded + this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: this.item.id }) + // this.expanded = !this.expanded this.markRead(this.item) }, formatDate(epoch: number) { @@ -218,6 +219,9 @@ export default Vue.extend({ toggleStarred(item: FeedItem): void { this.$store.dispatch(item.starred ? ACTIONS.UNSTAR_ITEM : ACTIONS.STAR_ITEM, { item }) }, + isCompactView(): boolean { + return true + }, }, }) diff --git a/src/components/FeedItemDisplay.vue b/src/components/FeedItemDisplay.vue new file mode 100644 index 000000000..8190c8176 --- /dev/null +++ b/src/components/FeedItemDisplay.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index a9c128b15..c5499c585 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -10,14 +10,14 @@ icon="icon-add-folder" @new-item="newFolder" /> - + diff --git a/src/components/Starred.vue b/src/components/Starred.vue index 4dbfd9f5f..c07ef66af 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -1,18 +1,25 @@ @@ -24,14 +31,17 @@ import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' import VirtualScroll from './VirtualScroll.vue' import FeedItemComponent from './FeedItem.vue' +import FeedItemDisplay from './FeedItemDisplay.vue' import { FeedItem } from '../types/FeedItem' +import { ACTIONS } from '../store' export default Vue.extend({ components: { NcCounterBubble, VirtualScroll, FeedItemComponent, + FeedItemDisplay, }, data() { return { @@ -46,9 +56,13 @@ export default Vue.extend({ reachedEnd(): boolean { return this.mounted && this.$store.state.items.starredLoaded }, + selected(): FeedItem | undefined { + return this.$store.getters.selected + }, }, mounted() { this.mounted = true + this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: undefined }) }, methods: { async fetchMore() { diff --git a/src/components/Unread.vue b/src/components/Unread.vue new file mode 100644 index 000000000..d7a2e9d89 --- /dev/null +++ b/src/components/Unread.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/components/VirtualScroll.vue b/src/components/VirtualScroll.vue index 775855969..c6bb471bd 100644 --- a/src/components/VirtualScroll.vue +++ b/src/components/VirtualScroll.vue @@ -19,6 +19,10 @@ export default Vue.extend({ type: Boolean, required: true, }, + fetchKey: { + type: String, + required: true, + }, }, data() { return { @@ -31,7 +35,7 @@ export default Vue.extend({ }, computed: { fetching() { - return this.$store.state.items.fetchingItems + return this.$store.state.items.fetchingItems[this.key] }, }, watch: { diff --git a/src/routes/index.ts b/src/routes/index.ts index f54823d60..b572742c8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,10 +2,12 @@ import VueRouter from 'vue-router' import ExplorePanel from '../components/Explore.vue' import StarredPanel from '../components/Starred.vue' +import UnreadPanel from '../components/Unread.vue' export const ROUTES = { EXPLORE: 'explore', STARRED: 'starred', + UNREAD: 'unread', } const getInitialRoute = function() { @@ -33,6 +35,12 @@ const routes = [ component: StarredPanel, props: true, }, + { + name: ROUTES.UNREAD, + path: '/unread', + component: UnreadPanel, + props: true, + }, ] export default new VueRouter({ diff --git a/src/store/feed.ts b/src/store/feed.ts index 9a0da520b..d86604266 100644 --- a/src/store/feed.ts +++ b/src/store/feed.ts @@ -3,7 +3,7 @@ import axios from '@nextcloud/axios' import { ActionParams, AppState } from '../store' import { Feed } from '../types/Feed' import { API_ROUTES } from '../types/ApiRoutes' -import { FOLDER_MUTATION_TYPES, FEED_MUTATION_TYPES } from '../types/MutationTypes' +import { FOLDER_MUTATION_TYPES, FEED_MUTATION_TYPES, FEED_ITEM_MUTATION_TYPES } from '../types/MutationTypes' export const FEED_ACTION_TYPES = { ADD_FEED: 'ADD_FEED', @@ -25,6 +25,9 @@ export const actions = { const feeds = await axios.get(API_ROUTES.FEED) commit(FEED_MUTATION_TYPES.SET_FEEDS, feeds.data.feeds) + commit(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, (feeds.data.feeds.reduce((total: number, feed: Feed) => { + return total + feed.unreadCount + }, 0))) }, async [FEED_ACTION_TYPES.ADD_FEED]( { commit }: ActionParams, diff --git a/src/store/item.ts b/src/store/item.ts index dbe343cb5..0fef155f0 100644 --- a/src/store/item.ts +++ b/src/store/item.ts @@ -7,39 +7,79 @@ import { FeedItem } from '../types/FeedItem' export const FEED_ITEM_ACTION_TYPES = { FETCH_STARRED: 'FETCH_STARRED', + FETCH_UNREAD: 'FETCH_UNREAD', MARK_READ: 'MARK_READ', MARK_UNREAD: 'MARK_UNREAD', STAR_ITEM: 'STAR_ITEM', UNSTAR_ITEM: 'UNSTAR_ITEM', + SET_SELECTED_ITEM: 'SET_SELECTED_ITEM', } export type ItemState = { - fetchingItems: boolean; + fetchingItems: { [key: string]: boolean }; + allItemsLoaded: { [key: string]: boolean }; starredLoaded: boolean; starredCount: number; + unreadCount: number; allItems: FeedItem[]; + + selectedId?: string; } const state: ItemState = { - fetchingItems: false, + fetchingItems: {}, + allItemsLoaded: {}, starredLoaded: false, starredCount: 0, + unreadCount: 0, allItems: [], + selectedId: undefined, } const getters = { starred(state: ItemState) { return state.allItems.filter((item) => item.starred) }, + unread(state: ItemState) { + return state.allItems.filter((item) => item.unread) + }, + selected(state: ItemState) { + return state.allItems.find((item: FeedItem) => item.id === state.selectedId) + }, } export const actions = { + async [FEED_ITEM_ACTION_TYPES.SET_SELECTED_ITEM]({ commit }: ActionParams, { id }: { id: string }) { + state.selectedId = id + }, + async [FEED_ITEM_ACTION_TYPES.FETCH_UNREAD]({ commit }: ActionParams, { start }: { start: number } = { start: 0 }) { + if (state.allItems.filter((item) => item.unread).length === 0) { + state.fetchingItems.unread = true + } + const response = await axios.get(API_ROUTES.ITEMS, { + params: { + limit: 40, + oldestFirst: false, + search: '', + showAll: false, + type: 6, + offset: start, + }, + }) + + commit(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, response.data.items) + + if (response.data.items.length < 40) { + state.allItemsLoaded.unread = true + } + state.fetchingItems.unread = false + }, async [FEED_ITEM_ACTION_TYPES.FETCH_STARRED]({ commit }: ActionParams) { - state.fetchingItems = true + state.fetchingItems.starred = true const response = await axios.get(API_ROUTES.ITEMS, { params: { limit: 40, @@ -57,12 +97,15 @@ export const actions = { if (response.data.items.length < 40) { state.starredLoaded = true } - state.fetchingItems = false + state.fetchingItems.starred = false }, [FEED_ITEM_ACTION_TYPES.MARK_READ]({ commit }: ActionParams, { item }: { item: FeedItem}) { axios.post(API_ROUTES.ITEMS + `/${item.id}/read`, { isRead: true, }) + if (item.unread) { + commit(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, state.unreadCount - 1) + } item.unread = false commit(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) }, @@ -70,6 +113,9 @@ export const actions = { axios.post(API_ROUTES.ITEMS + `/${item.id}/read`, { isRead: false, }) + if (!item.unread) { + commit(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, state.unreadCount + 1) + } item.unread = true commit(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) }, @@ -94,12 +140,17 @@ export const actions = { export const mutations = { [FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state: ItemState, items: FeedItem[]) { items.forEach(it => { - state.allItems.push(it) + if (state.allItems.find((existing: FeedItem) => existing.id === it.id) === undefined) { + state.allItems.push(it) + } }) }, [FEED_ITEM_MUTATION_TYPES.SET_STARRED_COUNT](state: ItemState, count: number) { state.starredCount = count }, + [FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT](state: ItemState, count: number) { + state.unreadCount = count + }, [FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM](state: ItemState, { item }: { item: FeedItem }) { const idx = state.allItems.findIndex((it) => it.id === item.id) state.allItems.splice(idx, 1, item) diff --git a/src/types/MutationTypes.ts b/src/types/MutationTypes.ts index 8c00e714b..7176366e5 100644 --- a/src/types/MutationTypes.ts +++ b/src/types/MutationTypes.ts @@ -11,5 +11,7 @@ export const FOLDER_MUTATION_TYPES = { export const FEED_ITEM_MUTATION_TYPES = { SET_ITEMS: 'SET_ITEMS', SET_STARRED_COUNT: 'SET_STARRED_COUNT', + SET_UNREAD_COUNT: 'SET_UNREAD_COUNT', UPDATE_ITEM: 'UPDATE_ITEM', + SET_FETCHING: 'SET_FETCHING' } From 7f59a3a03f6a56e3b709da2dbd78b7aab9b01e05 Mon Sep 17 00:00:00 2001 From: Devlin Junker Date: Sun, 20 Aug 2023 21:45:08 -0700 Subject: [PATCH 2/7] cleanup and rename FeedItemRow component Signed-off-by: Devlin Junker --- .../{FeedItem.vue => FeedItemRow.vue} | 163 +++++------------- src/components/Starred.vue | 6 +- src/components/Unread.vue | 6 +- .../{FeedItem.spec.ts => FeedItemRow.spec.ts} | 17 +- .../unit/components/Starred.spec.ts | 4 +- 5 files changed, 51 insertions(+), 145 deletions(-) rename src/components/{FeedItem.vue => FeedItemRow.vue} (53%) rename tests/javascript/unit/components/{FeedItem.spec.ts => FeedItemRow.spec.ts} (87%) diff --git a/src/components/FeedItem.vue b/src/components/FeedItemRow.vue similarity index 53% rename from src/components/FeedItem.vue rename to src/components/FeedItemRow.vue index 8650b58dd..3bac1cfd1 100644 --- a/src/components/FeedItem.vue +++ b/src/components/FeedItemRow.vue @@ -1,115 +1,44 @@ @@ -132,7 +61,7 @@ import { FeedItem } from '../types/FeedItem' import { ACTIONS } from '../store' export default Vue.extend({ - name: 'FeedItem', + name: 'FeedItemRow', components: { EarthIcon, StarIcon, @@ -150,18 +79,14 @@ export default Vue.extend({ }, data: () => { return { - expanded: false, keepUnread: false, } }, computed: { - isExpanded() { - return this.expanded - }, ...mapState(['feeds']), }, methods: { - expand() { + select() { this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: this.item.id }) // this.expanded = !this.expanded this.markRead(this.item) @@ -200,13 +125,6 @@ export default Vue.extend({ getFeed(id: number): Feed { return this.$store.getters.feeds.find((feed: Feed) => feed.id === id) || {} }, - getMediaType(mime: any): 'audio' | 'video' | false { - // TODO: figure out how to check media type - return false - }, - play(item: any) { - // TODO: implement play audio/video - }, markRead(item: FeedItem): void { if (!this.keepUnread) { this.$store.dispatch(ACTIONS.MARK_READ, { item }) @@ -219,9 +137,6 @@ export default Vue.extend({ toggleStarred(item: FeedItem): void { this.$store.dispatch(item.starred ? ACTIONS.UNSTAR_ITEM : ACTIONS.STAR_ITEM, { item }) }, - isCompactView(): boolean { - return true - }, }, }) diff --git a/src/components/Starred.vue b/src/components/Starred.vue index c07ef66af..27e25206f 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -12,7 +12,7 @@ @load-more="fetchMore()"> @@ -30,7 +30,7 @@ import { mapState } from 'vuex' import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' import VirtualScroll from './VirtualScroll.vue' -import FeedItemComponent from './FeedItem.vue' +import FeedItemRow from './FeedItemRow.vue' import FeedItemDisplay from './FeedItemDisplay.vue' import { FeedItem } from '../types/FeedItem' @@ -40,7 +40,7 @@ export default Vue.extend({ components: { NcCounterBubble, VirtualScroll, - FeedItemComponent, + FeedItemRow, FeedItemDisplay, }, data() { diff --git a/src/components/Unread.vue b/src/components/Unread.vue index d7a2e9d89..d3714659e 100644 --- a/src/components/Unread.vue +++ b/src/components/Unread.vue @@ -13,7 +13,7 @@ @load-more="fetchMore()"> @@ -32,7 +32,7 @@ import { mapState } from 'vuex' import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' import VirtualScroll from './VirtualScroll.vue' -import FeedItemComponent from './FeedItem.vue' +import FeedItemRow from './FeedItemRow.vue' import FeedItemDisplay from './FeedItemDisplay.vue' import { FeedItem } from '../types/FeedItem' @@ -42,7 +42,7 @@ export default Vue.extend({ components: { NcCounterBubble, VirtualScroll, - FeedItemComponent, + FeedItemRow, FeedItemDisplay, }, data() { diff --git a/tests/javascript/unit/components/FeedItem.spec.ts b/tests/javascript/unit/components/FeedItemRow.spec.ts similarity index 87% rename from tests/javascript/unit/components/FeedItem.spec.ts rename to tests/javascript/unit/components/FeedItemRow.spec.ts index 88991201f..9abe384d6 100644 --- a/tests/javascript/unit/components/FeedItem.spec.ts +++ b/tests/javascript/unit/components/FeedItemRow.spec.ts @@ -1,12 +1,12 @@ import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils' -import FeedItem from '../../../../src/components/FeedItem.vue' +import FeedItemRow from '../../../../src/components/FeedItemRow.vue' import { ACTIONS } from '../../../../src/store' -describe('FeedItem.vue', () => { +describe('FeedItemRow.vue', () => { 'use strict' const localVue = createLocalVue() - let wrapper: Wrapper + let wrapper: Wrapper const mockItem = { feedId: 1, @@ -19,7 +19,7 @@ describe('FeedItem.vue', () => { const dispatchStub = jest.fn() beforeAll(() => { - wrapper = shallowMount(FeedItem, { + wrapper = shallowMount(FeedItemRow, { propsData: { item: mockItem, }, @@ -44,7 +44,6 @@ describe('FeedItem.vue', () => { }) it('should initialize without expanded and without keepUnread', () => { - expect(wrapper.vm.$data.expanded).toBeFalsy() expect(wrapper.vm.$data.keepUnread).toBeFalsy() }) @@ -130,12 +129,4 @@ describe('FeedItem.vue', () => { item: wrapper.vm.$props.item, }) }) - - xit('TODO test: getMediaType(mime: any): audio | video | false', () => { - // TODO: finish tests after audio/video playback is supported - }) - - xit('TODO test: play(item: any): void', () => { - // TODO: finish tests after audio/video playback is supported - }) }) diff --git a/tests/javascript/unit/components/Starred.spec.ts b/tests/javascript/unit/components/Starred.spec.ts index 212c69f6e..36d4e53a1 100644 --- a/tests/javascript/unit/components/Starred.spec.ts +++ b/tests/javascript/unit/components/Starred.spec.ts @@ -3,7 +3,7 @@ import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils' import Starred from '../../../../src/components/Starred.vue' import VirtualScroll from '../../../../src/components/VirtualScroll.vue' -import FeedItem from '../../../../src/components/FeedItem.vue' +import FeedItemRow from '../../../../src/components/FeedItemRow.vue' jest.mock('@nextcloud/axios') @@ -47,7 +47,7 @@ describe('Explore.vue', () => { }) it('should get starred items from state', () => { - expect((wrapper.findAllComponents(FeedItem).length)).toEqual(1) + expect((wrapper.findAllComponents(FeedItemRow).length)).toEqual(1) }) it('should check starredLoaded and mounted to determine if the virtual scroll has reached end ', () => { From 139fc226f9aa67ed97bef00e97f6556d7d9d5dd4 Mon Sep 17 00:00:00 2001 From: Devlin Junker Date: Sun, 20 Aug 2023 21:59:27 -0700 Subject: [PATCH 3/7] add logic to fetch more starred Signed-off-by: Devlin Junker --- src/components/Starred.vue | 6 ++++-- src/components/Unread.vue | 1 - src/store/item.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/Starred.vue b/src/components/Starred.vue index 27e25206f..8bd3cb6b3 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -54,7 +54,7 @@ export default Vue.extend({ return this.$store.getters.starred }, reachedEnd(): boolean { - return this.mounted && this.$store.state.items.starredLoaded + return this.mounted && this.$store.state.items.allItemsLoaded.starred !== undefined && this.$store.state.items.allItemsLoaded.starred }, selected(): FeedItem | undefined { return this.$store.getters.selected @@ -66,7 +66,9 @@ export default Vue.extend({ }, methods: { async fetchMore() { - // TODO: fetch more starred + if (!this.$store.state.items.fetchingItems.starred) { + this.$store.dispatch(ACTIONS.FETCH_STARRED, { start: this.$store.getters.starred[this.$store.getters.starred?.length - 1]?.id }) + } }, }, }) diff --git a/src/components/Unread.vue b/src/components/Unread.vue index d3714659e..3b1bfe2d2 100644 --- a/src/components/Unread.vue +++ b/src/components/Unread.vue @@ -82,7 +82,6 @@ export default Vue.extend({ }, async fetchMore() { if (this._unread && !this.$store.state.items.fetchingItems.unread) { - console.log({ start: this._unread[this._unread?.length - 1]?.id }) this.$store.dispatch(ACTIONS.FETCH_UNREAD, { start: this._unread[this._unread?.length - 1]?.id }) } }, diff --git a/src/store/item.ts b/src/store/item.ts index 0fef155f0..de30f6778 100644 --- a/src/store/item.ts +++ b/src/store/item.ts @@ -78,7 +78,7 @@ export const actions = { } state.fetchingItems.unread = false }, - async [FEED_ITEM_ACTION_TYPES.FETCH_STARRED]({ commit }: ActionParams) { + async [FEED_ITEM_ACTION_TYPES.FETCH_STARRED]({ commit }: ActionParams, { start }: { start: number } = { start: 0 }) { state.fetchingItems.starred = true const response = await axios.get(API_ROUTES.ITEMS, { params: { @@ -87,7 +87,7 @@ export const actions = { search: '', showAll: false, type: 2, - offset: 0, + offset: start, }, }) From 9ad8943060cccb7e75189fa7f2d448f9014a1428 Mon Sep 17 00:00:00 2001 From: Devlin Junker Date: Mon, 21 Aug 2023 23:07:03 -0700 Subject: [PATCH 4/7] Add FeedItemDisplayList component - Reused by Starred and Unread route components Signed-off-by: Devlin Junker --- src/components/FeedItemDisplayList.vue | 86 ++++++++++++++++++++++++++ src/components/Starred.vue | 37 ++--------- src/components/Unread.vue | 40 +++--------- 3 files changed, 100 insertions(+), 63 deletions(-) create mode 100644 src/components/FeedItemDisplayList.vue diff --git a/src/components/FeedItemDisplayList.vue b/src/components/FeedItemDisplayList.vue new file mode 100644 index 000000000..41cdd72a8 --- /dev/null +++ b/src/components/FeedItemDisplayList.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/src/components/Starred.vue b/src/components/Starred.vue index 8bd3cb6b3..5a00ea31f 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -6,20 +6,8 @@ {{ items.starredCount }}
- - - -
- -
+ @@ -29,9 +17,7 @@ import { mapState } from 'vuex' import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' -import VirtualScroll from './VirtualScroll.vue' -import FeedItemRow from './FeedItemRow.vue' -import FeedItemDisplay from './FeedItemDisplay.vue' +import FeedItemDisplayList from './FeedItemDisplayList.vue' import { FeedItem } from '../types/FeedItem' import { ACTIONS } from '../store' @@ -39,29 +25,16 @@ import { ACTIONS } from '../store' export default Vue.extend({ components: { NcCounterBubble, - VirtualScroll, - FeedItemRow, - FeedItemDisplay, - }, - data() { - return { - mounted: false, - } + FeedItemDisplayList, }, computed: { ...mapState(['items']), + starred(): FeedItem[] { return this.$store.getters.starred }, - reachedEnd(): boolean { - return this.mounted && this.$store.state.items.allItemsLoaded.starred !== undefined && this.$store.state.items.allItemsLoaded.starred - }, - selected(): FeedItem | undefined { - return this.$store.getters.selected - }, }, - mounted() { - this.mounted = true + created() { this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: undefined }) }, methods: { diff --git a/src/components/Unread.vue b/src/components/Unread.vue index 3b1bfe2d2..6cb332eca 100644 --- a/src/components/Unread.vue +++ b/src/components/Unread.vue @@ -6,22 +6,8 @@ {{ items.unreadCount }} -
- - - -
- -
-
+ @@ -31,37 +17,29 @@ import { mapState } from 'vuex' import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' -import VirtualScroll from './VirtualScroll.vue' -import FeedItemRow from './FeedItemRow.vue' -import FeedItemDisplay from './FeedItemDisplay.vue' +import FeedItemDisplayList from './FeedItemDisplayList.vue' import { FeedItem } from '../types/FeedItem' import { ACTIONS } from '../store' +type UnreadItemState = { + _unread?: FeedItem[] +} + export default Vue.extend({ components: { NcCounterBubble, - VirtualScroll, - FeedItemRow, - FeedItemDisplay, + FeedItemDisplayList, }, data() { return { - mounted: false, _unread: undefined, - } as any + } as UnreadItemState }, computed: { ...mapState(['items']), - reachedEnd(): boolean { - return this.mounted && this.$store.state.items.allItemsLoaded.unread !== undefined && this.$store.state.items.allItemsLoaded.unread - }, - selected(): FeedItem | undefined { - return this.$store.getters.selected - }, }, - mounted() { - this.mounted = true + created() { this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: undefined }) }, methods: { From 3de31d6e86687b3b0d8c9e77012db3aa6d405eab Mon Sep 17 00:00:00 2001 From: Devlin Junker Date: Tue, 22 Aug 2023 21:52:51 -0700 Subject: [PATCH 5/7] css tweaks Signed-off-by: Devlin Junker --- src/components/FeedItemDisplay.vue | 54 +++++++++++++++++++++++++- src/components/FeedItemDisplayList.vue | 10 ++--- src/components/FeedItemRow.vue | 42 ++++---------------- src/components/Starred.vue | 6 ++- src/components/Unread.vue | 22 +++++------ 5 files changed, 78 insertions(+), 56 deletions(-) diff --git a/src/components/FeedItemDisplay.vue b/src/components/FeedItemDisplay.vue index 8190c8176..df6fa9199 100644 --- a/src/components/FeedItemDisplay.vue +++ b/src/components/FeedItemDisplay.vue @@ -1,7 +1,22 @@ -
+
@@ -25,10 +25,6 @@ import FeedItemDisplay from './FeedItemDisplay.vue' import { FeedItem } from '../types/FeedItem' -type FeedItemDisplayListState = { - mounted: boolean -} - export default Vue.extend({ components: { VirtualScroll, @@ -48,7 +44,7 @@ export default Vue.extend({ data() { return { mounted: false, - } as FeedItemDisplayListState + } }, computed: { selected(): FeedItem | undefined { @@ -81,6 +77,6 @@ export default Vue.extend({ .feed-item-container { max-width: 50%; - overflow-y: scroll; + overflow-y: hidden; } diff --git a/src/components/FeedItemRow.vue b/src/components/FeedItemRow.vue index 3bac1cfd1..04d4a9343 100644 --- a/src/components/FeedItemRow.vue +++ b/src/components/FeedItemRow.vue @@ -86,18 +86,18 @@ export default Vue.extend({ ...mapState(['feeds']), }, methods: { - select() { + select(): void { this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: this.item.id }) // this.expanded = !this.expanded this.markRead(this.item) }, - formatDate(epoch: number) { + formatDate(epoch: number): string { return new Date(epoch).toLocaleString() }, - formatDatetime(epoch: number) { + formatDatetime(epoch: number): string { return new Date(epoch).toISOString() }, - getRelativeTimestamp(previous: number) { + getRelativeTimestamp(previous: number): string { const current = Date.now() const msPerMinute = 60 * 1000 @@ -152,7 +152,7 @@ export default Vue.extend({ } .feed-item-row:hover { - background-color: #222; + background-color: var(--color-background-hover); } .feed-item-row, .feed-item-row * { @@ -206,11 +206,11 @@ export default Vue.extend({ } .material-design-icon { - color: #555555; + color: var(--color-text-lighter) } .material-design-icon:hover { - color: var(--color-main-text); + color: var(--color-text-light); } .material-design-icon.rss-icon:hover { @@ -228,32 +228,4 @@ export default Vue.extend({ .material-design-icon.starred:hover { color: #555555; } - - .article { - padding: 0 50px 50px 50px; - } - - .article .body { - color: var(--color-main-text); - font-size: 15px; - } - - .article a { - text-decoration: underline; - } - - .article .body a { - color: #3a84e4 - } - - .article .subtitle { - color: var(--color-text-lighter); - font-size: 15px; - padding: 25px 0; - } - - .article .author { - color: var(--color-text-lighter); - font-size: 15px; - } diff --git a/src/components/Starred.vue b/src/components/Starred.vue index 5a00ea31f..d1aba4921 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -1,5 +1,5 @@ @@ -20,7 +23,7 @@ import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' import FeedItemDisplayList from './FeedItemDisplayList.vue' import { FeedItem } from '../types/FeedItem' -import { ACTIONS } from '../store' +import { ACTIONS, MUTATIONS } from '../store' type UnreadItemState = { unreadCache?: FeedItem[] @@ -40,7 +43,10 @@ export default Vue.extend({ ...mapState(['items']), }, created() { - this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: undefined }) + this.$store.commit(MUTATIONS.SET_SELECTED_ITEM, { id: undefined }) + if (this.unread() === undefined) { + this.$store.dispatch(ACTIONS.FETCH_UNREAD) + } }, methods: { unread() { diff --git a/src/dataservices/item.service.ts b/src/dataservices/item.service.ts new file mode 100644 index 000000000..cddd1e978 --- /dev/null +++ b/src/dataservices/item.service.ts @@ -0,0 +1,80 @@ +import _ from 'lodash' +import { AxiosResponse } from 'axios' +import axios from '@nextcloud/axios' + +import { API_ROUTES } from '../types/ApiRoutes' +import { FeedItem } from '../types/FeedItem' + +export const ITEM_TYPES = { + STARRED: 2, + UNREAD: 6, +} + +export class ItemService { + + static debounceFetchStarred = _.debounce(ItemService.fetchStarred, 400, { leading: true }) + static debounceFetchUnread = _.debounce(ItemService.fetchUnread, 400, { leading: true }) + + /** + * Makes backend call to retrieve starred items + * + * @param start (id of last starred item loaded) + * @return {AxiosResponse} response object containing backend request response + */ + static async fetchStarred(start: number): Promise { + return await axios.get(API_ROUTES.ITEMS, { + params: { + limit: 40, + oldestFirst: false, + search: '', + showAll: false, + type: ITEM_TYPES.STARRED, + offset: start, + }, + }) + } + + /** + * Makes backend call to retrieve unread items + * + * @param start (id of last unread item loaded) + * @return {AxiosResponse} response object containing backend request response + */ + static async fetchUnread(start: number): Promise { + return await axios.get(API_ROUTES.ITEMS, { + params: { + limit: 40, + oldestFirst: false, + search: '', + showAll: false, + type: ITEM_TYPES.UNREAD, + offset: start, + }, + }) + } + + /** + * Makes backend call to mark item as read/unread in DB + * + * @param {FeedItem} item FeedItem (containing id) that wil be marked as read/unread + * @param {boolean} read if read or not + */ + static async markRead(item: FeedItem, read: boolean): Promise { + axios.post(API_ROUTES.ITEMS + `/${item.id}/read`, { + isRead: read, + }) + } + + /** + * Makes backend call to mark item as starred/unstarred in DB + * + * @param {FeedItem} item FeedItem (containing id) that wil be marked as starred/unstarred + * @param {boolean} read if starred or not + */ + static async markStarred(item: FeedItem, read: boolean): Promise { + axios.post(API_ROUTES.ITEMS + `/${item.feedId}/${item.guidHash}/star`, { + isStarred: read, + }) + } + +} diff --git a/src/store/item.ts b/src/store/item.ts index de30f6778..982e75979 100644 --- a/src/store/item.ts +++ b/src/store/item.ts @@ -1,9 +1,8 @@ -import axios from '@nextcloud/axios' - import { ActionParams } from '../store' import { FEED_ITEM_MUTATION_TYPES } from '../types/MutationTypes' -import { API_ROUTES } from '../types/ApiRoutes' + import { FeedItem } from '../types/FeedItem' +import { ItemService } from '../dataservices/item.service' export const FEED_ITEM_ACTION_TYPES = { FETCH_STARRED: 'FETCH_STARRED', @@ -12,7 +11,6 @@ export const FEED_ITEM_ACTION_TYPES = { MARK_UNREAD: 'MARK_UNREAD', STAR_ITEM: 'STAR_ITEM', UNSTAR_ITEM: 'UNSTAR_ITEM', - SET_SELECTED_ITEM: 'SET_SELECTED_ITEM', } export type ItemState = { @@ -53,56 +51,35 @@ const getters = { } export const actions = { - async [FEED_ITEM_ACTION_TYPES.SET_SELECTED_ITEM]({ commit }: ActionParams, { id }: { id: string }) { - state.selectedId = id - }, async [FEED_ITEM_ACTION_TYPES.FETCH_UNREAD]({ commit }: ActionParams, { start }: { start: number } = { start: 0 }) { - if (state.allItems.filter((item) => item.unread).length === 0) { - state.fetchingItems.unread = true - } - const response = await axios.get(API_ROUTES.ITEMS, { - params: { - limit: 40, - oldestFirst: false, - search: '', - showAll: false, - type: 6, - offset: start, - }, - }) - - commit(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, response.data.items) - - if (response.data.items.length < 40) { - state.allItemsLoaded.unread = true + commit(FEED_ITEM_MUTATION_TYPES.SET_FETCHING, { key: 'unread', fetching: true }) + + const response = await ItemService.debounceFetchUnread(start) + + commit(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, response?.data.items) + + if (response?.data.items.length < 40) { + commit(FEED_ITEM_MUTATION_TYPES.SET_ALL_LOADED, { key: 'unread', loaded: true }) } - state.fetchingItems.unread = false + commit(FEED_ITEM_MUTATION_TYPES.SET_FETCHING, { key: 'unread', fetching: false }) }, async [FEED_ITEM_ACTION_TYPES.FETCH_STARRED]({ commit }: ActionParams, { start }: { start: number } = { start: 0 }) { - state.fetchingItems.starred = true - const response = await axios.get(API_ROUTES.ITEMS, { - params: { - limit: 40, - oldestFirst: false, - search: '', - showAll: false, - type: 2, - offset: start, - }, - }) - - commit(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, response.data.items) - commit(FEED_ITEM_MUTATION_TYPES.SET_STARRED_COUNT, response.data.starred) - - if (response.data.items.length < 40) { - state.starredLoaded = true + commit(FEED_ITEM_MUTATION_TYPES.SET_FETCHING, { key: 'starred', fetching: true }) + const response = await ItemService.debounceFetchStarred(start) + + commit(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, response?.data.items) + if (response?.data.starred) { + commit(FEED_ITEM_MUTATION_TYPES.SET_STARRED_COUNT, response?.data.starred) + } + + if (response?.data.items.length < 40) { + commit(FEED_ITEM_MUTATION_TYPES.SET_ALL_LOADED, { key: 'starred', loaded: true }) } - state.fetchingItems.starred = false + commit(FEED_ITEM_MUTATION_TYPES.SET_FETCHING, { key: 'starred', fetching: false }) }, [FEED_ITEM_ACTION_TYPES.MARK_READ]({ commit }: ActionParams, { item }: { item: FeedItem}) { - axios.post(API_ROUTES.ITEMS + `/${item.id}/read`, { - isRead: true, - }) + ItemService.markRead(item, true) + if (item.unread) { commit(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, state.unreadCount - 1) } @@ -110,9 +87,8 @@ export const actions = { commit(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) }, [FEED_ITEM_ACTION_TYPES.MARK_UNREAD]({ commit }: ActionParams, { item }: { item: FeedItem}) { - axios.post(API_ROUTES.ITEMS + `/${item.id}/read`, { - isRead: false, - }) + ItemService.markRead(item, false) + if (!item.unread) { commit(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, state.unreadCount + 1) } @@ -120,17 +96,15 @@ export const actions = { commit(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) }, [FEED_ITEM_ACTION_TYPES.STAR_ITEM]({ commit }: ActionParams, { item }: { item: FeedItem}) { - axios.post(API_ROUTES.ITEMS + `/${item.feedId}/${item.guidHash}/star`, { - isStarred: true, - }) + ItemService.markStarred(item, true) + item.starred = true commit(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) commit(FEED_ITEM_MUTATION_TYPES.SET_STARRED_COUNT, state.starredCount + 1) }, [FEED_ITEM_ACTION_TYPES.UNSTAR_ITEM]({ commit }: ActionParams, { item }: { item: FeedItem}) { - axios.post(API_ROUTES.ITEMS + `/${item.feedId}/${item.guidHash}/star`, { - isStarred: false, - }) + ItemService.markStarred(item, false) + item.starred = false commit(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) commit(FEED_ITEM_MUTATION_TYPES.SET_STARRED_COUNT, state.starredCount - 1) @@ -138,12 +112,17 @@ export const actions = { } export const mutations = { + [FEED_ITEM_MUTATION_TYPES.SET_SELECTED_ITEM](state: ItemState, { id }: { id: string }) { + state.selectedId = id + }, [FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state: ItemState, items: FeedItem[]) { - items.forEach(it => { - if (state.allItems.find((existing: FeedItem) => existing.id === it.id) === undefined) { - state.allItems.push(it) - } - }) + if (items) { + items.forEach(it => { + if (state.allItems.find((existing: FeedItem) => existing.id === it.id) === undefined) { + state.allItems.push(it) + } + }) + } }, [FEED_ITEM_MUTATION_TYPES.SET_STARRED_COUNT](state: ItemState, count: number) { state.starredCount = count @@ -155,6 +134,12 @@ export const mutations = { const idx = state.allItems.findIndex((it) => it.id === item.id) state.allItems.splice(idx, 1, item) }, + [FEED_ITEM_MUTATION_TYPES.SET_FETCHING](state: ItemState, { fetching, key }: { fetching: boolean; key: string; }) { + state.fetchingItems[key] = fetching + }, + [FEED_ITEM_MUTATION_TYPES.SET_ALL_LOADED](state: ItemState, { loaded, key }: { loaded: boolean; key: string; }) { + state.allItemsLoaded[key] = loaded + }, } export default { diff --git a/src/types/MutationTypes.ts b/src/types/MutationTypes.ts index 7176366e5..9d725c0c3 100644 --- a/src/types/MutationTypes.ts +++ b/src/types/MutationTypes.ts @@ -10,8 +10,12 @@ export const FOLDER_MUTATION_TYPES = { export const FEED_ITEM_MUTATION_TYPES = { SET_ITEMS: 'SET_ITEMS', + UPDATE_ITEM: 'UPDATE_ITEM', + SET_SELECTED_ITEM: 'SET_SELECTED_ITEM', + SET_STARRED_COUNT: 'SET_STARRED_COUNT', SET_UNREAD_COUNT: 'SET_UNREAD_COUNT', - UPDATE_ITEM: 'UPDATE_ITEM', - SET_FETCHING: 'SET_FETCHING' + + SET_FETCHING: 'SET_FETCHING', + SET_ALL_LOADED: 'SET_ALL_LOADED', } diff --git a/tests/javascript/unit/components/AddFeed.spec.ts b/tests/javascript/unit/components/AddFeed.spec.ts index daf50329e..169d9b5a2 100644 --- a/tests/javascript/unit/components/AddFeed.spec.ts +++ b/tests/javascript/unit/components/AddFeed.spec.ts @@ -47,13 +47,13 @@ describe('AddFeed.vue', () => { expect(response).toBeFalsy() - wrapper.vm.$data.feedUrl = 'http://test.com' + wrapper.vm.$data.feedUrl = 'http://example.com' response = wrapper.vm.feedUrlExists() expect(response).toBeFalsy() - wrapper.vm.$data.feedUrl = 'http://test.com' - wrapper.vm.$store.state.feeds.feeds = [{ url: 'http://test.com' }] + wrapper.vm.$data.feedUrl = 'http://example.com' + wrapper.vm.$store.state.feeds.feeds = [{ url: 'http://example.com' }] response = wrapper.vm.feedUrlExists() expect(response).toBeTruthy() diff --git a/tests/javascript/unit/components/FeedItemRow.spec.ts b/tests/javascript/unit/components/FeedItemRow.spec.ts index 9abe384d6..14c2d6cca 100644 --- a/tests/javascript/unit/components/FeedItemRow.spec.ts +++ b/tests/javascript/unit/components/FeedItemRow.spec.ts @@ -34,6 +34,7 @@ describe('FeedItemRow.vue', () => { folders: [], }, dispatch: dispatchStub, + commit: jest.fn(), }, }, }) @@ -50,7 +51,7 @@ describe('FeedItemRow.vue', () => { it('should expand when clicked', async () => { await wrapper.find('.feed-item-row').trigger('click') - expect(wrapper.vm.$data.expanded).toBe(true) + // expect(wrapper.vm.$data.expanded).toBe(true) }) it('should format date correctly', () => { diff --git a/tests/javascript/unit/components/Starred.spec.ts b/tests/javascript/unit/components/Starred.spec.ts index 36d4e53a1..4f09f4e3c 100644 --- a/tests/javascript/unit/components/Starred.spec.ts +++ b/tests/javascript/unit/components/Starred.spec.ts @@ -2,12 +2,11 @@ import Vuex, { Store } from 'vuex' import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils' import Starred from '../../../../src/components/Starred.vue' -import VirtualScroll from '../../../../src/components/VirtualScroll.vue' -import FeedItemRow from '../../../../src/components/FeedItemRow.vue' +import FeedItemDisplayList from '../../../../src/components/FeedItemDisplayList.vue' jest.mock('@nextcloud/axios') -describe('Explore.vue', () => { +describe('Starred.vue', () => { 'use strict' const localVue = createLocalVue() localVue.use(Vuex) @@ -24,7 +23,9 @@ describe('Explore.vue', () => { store = new Vuex.Store({ state: { items: { - starredLoaded: false, + fetchingItems: { + starred: false, + }, }, }, actions: { @@ -33,6 +34,10 @@ describe('Explore.vue', () => { starred: () => [mockItem], }, }) + + store.dispatch = jest.fn() + store.commit = jest.fn() + wrapper = shallowMount(Starred, { propsData: { item: mockItem, @@ -42,34 +47,12 @@ describe('Explore.vue', () => { }) }) - it('should initialize with mounted flag set', () => { - expect(wrapper.vm.$data.mounted).toBeTruthy() - }) - it('should get starred items from state', () => { - expect((wrapper.findAllComponents(FeedItemRow).length)).toEqual(1) + expect((wrapper.findComponent(FeedItemDisplayList)).props().items.length).toEqual(1) }) - it('should check starredLoaded and mounted to determine if the virtual scroll has reached end ', () => { - wrapper.vm.$store.state.items.starredLoaded = false - expect((wrapper.findComponent(VirtualScroll)).props().reachedEnd).toEqual(false) - - wrapper.vm.$store.state.items.starredLoaded = true - store.state.items.starredLoaded = true - - wrapper = shallowMount(Starred, { - propsData: { - item: mockItem, - }, - data: () => { - return { - mounted: true, - } - }, - localVue, - store, - }) - - expect((wrapper.findComponent(VirtualScroll)).props().reachedEnd).toEqual(true) + it('should dispatch FETCH_STARRED action if not fetchingItems.starred', () => { + (wrapper.vm as any).fetchMore() + expect(store.dispatch).toBeCalled() }) }) diff --git a/tests/javascript/unit/components/Unread.spec.ts b/tests/javascript/unit/components/Unread.spec.ts new file mode 100644 index 000000000..6de4d8565 --- /dev/null +++ b/tests/javascript/unit/components/Unread.spec.ts @@ -0,0 +1,65 @@ +import Vuex, { Store } from 'vuex' +import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils' + +import Unread from '../../../../src/components/Unread.vue' +import FeedItemDisplayList from '../../../../src/components/FeedItemDisplayList.vue' + +jest.mock('@nextcloud/axios') + +describe('Unread.vue', () => { + 'use strict' + const localVue = createLocalVue() + localVue.use(Vuex) + let wrapper: Wrapper + + const mockItem = { + feedId: 1, + title: 'feed item', + pubDate: Date.now() / 1000, + } + + let store: Store + beforeAll(() => { + store = new Vuex.Store({ + state: { + items: { + fetchingItems: { + unread: false, + }, + }, + }, + actions: { + }, + getters: { + unread: () => [mockItem, mockItem], + }, + }) + + store.dispatch = jest.fn() + store.commit = jest.fn() + + wrapper = shallowMount(Unread, { + propsData: { + item: mockItem, + }, + localVue, + store, + }) + }) + + it('should get unread items from state', () => { + expect((wrapper.findComponent(FeedItemDisplayList)).props().items.length).toEqual(2) + }) + + it('should dispatch FETCH_UNREAD action if not fetchingItems.unread', () => { + (wrapper.vm as any).$store.state.items.fetchingItems.unread = true; + + (wrapper.vm as any).fetchMore() + expect(store.dispatch).not.toBeCalled(); + + (wrapper.vm as any).$store.state.items.fetchingItems.unread = false; + + (wrapper.vm as any).fetchMore() + expect(store.dispatch).toBeCalled() + }) +}) diff --git a/tests/javascript/unit/services/item.service.spec.ts b/tests/javascript/unit/services/item.service.spec.ts new file mode 100644 index 000000000..29de8f1a3 --- /dev/null +++ b/tests/javascript/unit/services/item.service.spec.ts @@ -0,0 +1,66 @@ +import { ITEM_TYPES, ItemService } from '../../../../src/dataservices/item.service' +import axios from '@nextcloud/axios' + +jest.mock('@nextcloud/axios') + +describe('item.service.ts', () => { + 'use strict' + + beforeEach(() => { + (axios.get as any).mockReset(); + (axios.post as any).mockReset() + }) + + describe('fetchStarred', () => { + it('should call GET with offset set to start param', async () => { + (axios as any).get.mockResolvedValue({ data: { feeds: [] } }) + + await ItemService.fetchStarred(0) + + expect(axios.get).toBeCalled() + const queryParams = (axios.get as any).mock.calls[0][1].params + + expect(queryParams.offset).toEqual(0) + expect(queryParams.type).toEqual(ITEM_TYPES.STARRED) + }) + }) + + describe('fetchUnread', () => { + it('should call GET with offset set to start param', async () => { + (axios as any).get.mockResolvedValue({ data: { feeds: [] } }) + + await ItemService.fetchUnread(2) + + expect(axios.get).toBeCalled() + const queryParams = (axios.get as any).mock.calls[0][1].params + + expect(queryParams.offset).toEqual(2) + expect(queryParams.type).toEqual(ITEM_TYPES.UNREAD) + }) + }) + + describe('markRead', () => { + it('should call POST with item id in URL and read param', async () => { + await ItemService.markRead({ id: 123 } as any, true) + + expect(axios.post).toBeCalled() + const args = (axios.post as any).mock.calls[0] + + expect(args[0]).toContain('123') + expect(args[1].isRead).toEqual(true) + }) + }) + + describe('markStarred', () => { + it('should call POST with item feedId and guidHash in URL and read param', async () => { + await ItemService.markStarred({ feedId: 1, guidHash: 'abc' } as any, false) + + expect(axios.post).toBeCalled() + const args = (axios.post as any).mock.calls[0] + + expect(args[0]).toContain('1') + expect(args[0]).toContain('abc') + expect(args[1].isStarred).toEqual(false) + }) + }) +}) diff --git a/tests/javascript/unit/store/feed.spec.ts b/tests/javascript/unit/store/feed.spec.ts index 922c79f3f..3a7521f89 100644 --- a/tests/javascript/unit/store/feed.spec.ts +++ b/tests/javascript/unit/store/feed.spec.ts @@ -3,7 +3,7 @@ import { Feed } from '../../../../src/types/Feed' import { AppState } from '../../../../src/store' import { FEED_ACTION_TYPES, mutations, actions } from '../../../../src/store/feed' -import { FEED_MUTATION_TYPES } from '../../../../src/types/MutationTypes' +import { FEED_ITEM_MUTATION_TYPES, FEED_MUTATION_TYPES } from '../../../../src/types/MutationTypes' jest.mock('@nextcloud/axios') @@ -11,6 +11,17 @@ describe('feed.ts', () => { 'use strict' describe('actions', () => { + describe('FETCH_FEEDS', () => { + it('should call GET and commit returned feeds to state', async () => { + (axios as any).get.mockResolvedValue({ data: { feeds: [] } }) + const commit = jest.fn() + await (actions[FEED_ACTION_TYPES.FETCH_FEEDS] as any)({ commit }) + expect(axios.get).toBeCalled() + expect(commit).toBeCalledWith(FEED_MUTATION_TYPES.SET_FEEDS, []) + expect(commit).toBeCalledWith(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, 0) + }) + }) + describe('ADD_FEED', () => { it('should call POST and commit feed to state', async () => { (axios as any).post.mockResolvedValue({ data: { feeds: [] } }) @@ -30,13 +41,6 @@ describe('feed.ts', () => { }) }) - it('FETCH_FEEDS should call GET and commit returned feeds to state', async () => { - (axios as any).get.mockResolvedValue({ data: { feeds: [] } }) - const commit = jest.fn() - await (actions[FEED_ACTION_TYPES.FETCH_FEEDS] as any)({ commit }) - expect(axios.get).toBeCalled() - expect(commit).toBeCalled() - }) }) describe('mutations', () => { diff --git a/tests/javascript/unit/store/item.spec.ts b/tests/javascript/unit/store/item.spec.ts index 614e47c1d..c393a03f4 100644 --- a/tests/javascript/unit/store/item.spec.ts +++ b/tests/javascript/unit/store/item.spec.ts @@ -1,22 +1,37 @@ -import axios from '@nextcloud/axios' import { AppState } from '../../../../src/store' import { FEED_ITEM_ACTION_TYPES, mutations, actions } from '../../../../src/store/item' import { FEED_ITEM_MUTATION_TYPES } from '../../../../src/types/MutationTypes' -import { FeedItem } from '../../../../src/types/FeedItem' +import { ItemService } from '../../../../src/dataservices/item.service' -jest.mock('@nextcloud/axios') - -describe('feed.ts', () => { +describe('item.ts', () => { 'use strict' describe('actions', () => { + describe('FETCH_UNREAD', () => { + it('should call ItemService and commit items to state', async () => { + const fetchMock = jest.fn() + fetchMock.mockResolvedValue({ data: { items: [] } }) + ItemService.debounceFetchUnread = fetchMock as any + const commit = jest.fn() + + await (actions[FEED_ITEM_ACTION_TYPES.FETCH_UNREAD] as any)({ commit }) + + expect(fetchMock).toBeCalled() + expect(commit).toBeCalledWith(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, []) + }) + }) + describe('FETCH_STARRED', () => { - it('should call GET and commit items and starred count to state', async () => { - (axios as any).get.mockResolvedValue({ data: { items: [], starred: 3 } }) + it('should call ItemService and commit items and starred count to state', async () => { + const fetchMock = jest.fn() + fetchMock.mockResolvedValue({ data: { items: [], starred: 3 } }) + ItemService.debounceFetchStarred = fetchMock as any const commit = jest.fn() + await (actions[FEED_ITEM_ACTION_TYPES.FETCH_STARRED] as any)({ commit }) - expect(axios.get).toBeCalled() + + expect(fetchMock).toBeCalled() expect(commit).toBeCalledWith(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, []) expect(commit).toBeCalledWith(FEED_ITEM_MUTATION_TYPES.SET_STARRED_COUNT, 3) }) @@ -25,52 +40,95 @@ describe('feed.ts', () => { it('MARK_READ should call GET and commit returned feeds to state', async () => { const item = { id: 1 } const commit = jest.fn() + const serviceMock = jest.fn() + ItemService.markRead = serviceMock + await (actions[FEED_ITEM_ACTION_TYPES.MARK_READ] as any)({ commit }, { item }) - expect(axios.post).toBeCalled() + + expect(serviceMock).toBeCalledWith(item, true) expect(commit).toBeCalled() }) it('MARK_UNREAD should call GET and commit returned feeds to state', async () => { const item = { id: 1 } const commit = jest.fn() + const serviceMock = jest.fn() + ItemService.markRead = serviceMock + await (actions[FEED_ITEM_ACTION_TYPES.MARK_UNREAD] as any)({ commit }, { item }) - expect(axios.post).toBeCalled() + + expect(serviceMock).toBeCalledWith(item, false) expect(commit).toBeCalledWith(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) }) it('STAR_ITEM should call GET and commit returned feeds to state', async () => { - const item = { id: 1 }; - (axios as any).get.mockResolvedValue({ data: { feeds: [] } }) + const item = { id: 1 } const commit = jest.fn() + const serviceMock = jest.fn() + ItemService.markStarred = serviceMock + await (actions[FEED_ITEM_ACTION_TYPES.STAR_ITEM] as any)({ commit }, { item }) - expect(axios.post).toBeCalled() + + expect(serviceMock).toBeCalledWith(item, true) expect(commit).toBeCalled() }) it('UNSTAR_ITEM should call GET and commit returned feeds to state', async () => { - const item = { id: 1 }; - (axios as any).get.mockResolvedValue({ data: { feeds: [] } }) + const item = { id: 1 } const commit = jest.fn() + const serviceMock = jest.fn() + ItemService.markStarred = serviceMock + await (actions[FEED_ITEM_ACTION_TYPES.UNSTAR_ITEM] as any)({ commit }, { item }) - expect(axios.post).toBeCalled() + + expect(serviceMock).toBeCalledWith(item, false) expect(commit).toBeCalled() }) }) describe('mutations', () => { + describe('SET_SELECTED_ITEM', () => { + it('should update selectedId on state', async () => { + const state = { selectedId: undefined } as any + const item = { id: 123 } as any + mutations[FEED_ITEM_MUTATION_TYPES.SET_SELECTED_ITEM](state, item as any) + expect(state.selectedId).toEqual(123) + }) + }) describe('SET_ITEMS', () => { it('should add feeds to state', () => { - const state = { allItems: [] as any } as AppState + const state = { allItems: [] as any } as any let items = [] as any mutations[FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state, items) expect(state.allItems.length).toEqual(0) - items = [{ title: 'test' }] as FeedItem[] + items = [{ title: 'test', id: 123 }] + + mutations[FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state, items) + expect(state.allItems.length).toEqual(1) + expect(state.allItems[0]).toEqual(items[0]) + + items = [{ title: 'test2', id: 234 }] + mutations[FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state, items) + expect(state.allItems.length).toEqual(2) + }) + + it('should not add duplicates', () => { + const state = { allItems: [] as any } as any + let items = [{ title: 'test', id: 123 }] as any mutations[FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state, items) expect(state.allItems.length).toEqual(1) expect(state.allItems[0]).toEqual(items[0]) + + mutations[FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state, items) + expect(state.allItems.length).toEqual(1) + expect(state.allItems[0]).toEqual(items[0]) + + items = [{ title: 'test2', id: 234 }] + mutations[FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state, items) + expect(state.allItems.length).toEqual(2) }) }) @@ -83,6 +141,15 @@ describe('feed.ts', () => { }) }) + describe('SET_UNREAD_COUNT', () => { + it('should set unreadCount with value passed in', () => { + const state = { unreadCount: 0 } as AppState + + (mutations[FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT] as any)(state, 123) + expect(state.unreadCount).toEqual(123) + }) + }) + describe('UPDATE_ITEM', () => { it('should add a single feed to state', () => { const state = { allItems: [{ id: 1, title: 'abc' }] as any } as AppState @@ -92,5 +159,29 @@ describe('feed.ts', () => { expect(state.allItems[0]).toEqual(item) }) }) + + describe('SET_FETCHING', () => { + it('should set fetchingItems value with key passed in', () => { + const state = { fetchingItems: {} } as AppState + + (mutations[FEED_ITEM_MUTATION_TYPES.SET_FETCHING] as any)(state, { fetching: true, key: 'starred' }) + expect(state.fetchingItems.starred).toEqual(true); + + (mutations[FEED_ITEM_MUTATION_TYPES.SET_FETCHING] as any)(state, { fetching: false, key: 'starred' }) + expect(state.fetchingItems.starred).toEqual(false) + }) + }) + + describe('SET_ALL_LOADED', () => { + it('should set allItemsLoaded value with key passed in', () => { + const state = { allItemsLoaded: {} } as AppState + + (mutations[FEED_ITEM_MUTATION_TYPES.SET_ALL_LOADED] as any)(state, { loaded: true, key: 'starred' }) + expect(state.allItemsLoaded.starred).toEqual(true); + + (mutations[FEED_ITEM_MUTATION_TYPES.SET_ALL_LOADED] as any)(state, { loaded: false, key: 'starred' }) + expect(state.allItemsLoaded.starred).toEqual(false) + }) + }) }) }) From 32867a8cfad51008ae318f70cd122df1c7a4ba19 Mon Sep 17 00:00:00 2001 From: Devlin Junker Date: Thu, 24 Aug 2023 17:12:27 -0700 Subject: [PATCH 7/7] Add more unit tests and all passing Signed-off-by: Devlin Junker --- src/components/FeedItemDisplay.vue | 43 +++++++-- src/components/Unread.vue | 1 + .../unit/components/FeedItemDisplay.spec.ts | 92 +++++++++++++++++++ .../components/FeedItemDisplayList.spec.ts | 66 +++++++++++++ .../unit/components/FeedItemRow.spec.ts | 10 +- 5 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 tests/javascript/unit/components/FeedItemDisplay.spec.ts create mode 100644 tests/javascript/unit/components/FeedItemDisplayList.spec.ts diff --git a/src/components/FeedItemDisplay.vue b/src/components/FeedItemDisplay.vue index edb576b5f..5818fad51 100644 --- a/src/components/FeedItemDisplay.vue +++ b/src/components/FeedItemDisplay.vue @@ -129,28 +129,55 @@ export default Vue.extend({ ...mapState(['feeds']), }, methods: { - clearSelected() { + /** + * Sends message to state to clear the selectedId number + */ + clearSelected(): void { this.$store.commit(MUTATIONS.SET_SELECTED_ITEM, { id: undefined }) }, - formatDate(epoch: number) { + /** + * Returns locale formatted date string + * + * @param {number} epoch date value in epoch format + * @return {string} locale formatted date string (based on users browser) + */ + formatDate(epoch: number): string { return new Date(epoch).toLocaleString() }, - formatDatetime(epoch: number) { + /** + * Returns UTC formatted datetime in format recognized by `datetime` property + * + * @param {number} epoch date value in epoch format + * @return {string} UTC formatted datetime string (in format yyyy-MM-ddTHH:mm:ssZ) + */ + formatDatetime(epoch: number): string { return new Date(epoch).toISOString() }, + /** + * Retrieve the feed by id number + * + * @param {number} id id of feed to fetch + * @return {Feed} associated Feed + */ getFeed(id: number): Feed { return this.$store.getters.feeds.find((feed: Feed) => feed.id === id) || {} }, - getMediaType(mime: any): 'audio' | 'video' | false { + /** + * Sends message to change the items starred property to the opposite value + * + * @param {FeedItem} item item to toggle starred status on + */ + toggleStarred(item: FeedItem): void { + this.$store.dispatch(item.starred ? ACTIONS.UNSTAR_ITEM : ACTIONS.STAR_ITEM, { item }) + }, + + getMediaType(mime: string): 'audio' | 'video' | false { // TODO: figure out how to check media type return false }, - play(item: any) { + play(item: FeedItem) { // TODO: implement play audio/video }, - toggleStarred(item: FeedItem): void { - this.$store.dispatch(item.starred ? ACTIONS.UNSTAR_ITEM : ACTIONS.STAR_ITEM, { item }) - }, }, }) diff --git a/src/components/Unread.vue b/src/components/Unread.vue index 195623245..9a59f529b 100644 --- a/src/components/Unread.vue +++ b/src/components/Unread.vue @@ -26,6 +26,7 @@ import { FeedItem } from '../types/FeedItem' import { ACTIONS, MUTATIONS } from '../store' type UnreadItemState = { + // need cache so we aren't always removing items when they get read unreadCache?: FeedItem[] } diff --git a/tests/javascript/unit/components/FeedItemDisplay.spec.ts b/tests/javascript/unit/components/FeedItemDisplay.spec.ts new file mode 100644 index 000000000..594ccf9c1 --- /dev/null +++ b/tests/javascript/unit/components/FeedItemDisplay.spec.ts @@ -0,0 +1,92 @@ +import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils' + +import FeedItemDisplay from '../../../../src/components/FeedItemDisplay.vue' +import { ACTIONS, MUTATIONS } from '../../../../src/store' + +describe('FeedItemDisplay.vue', () => { + 'use strict' + const localVue = createLocalVue() + let wrapper: Wrapper + + const mockItem = { + feedId: 1, + title: 'feed item', + pubDate: Date.now() / 1000, + } + const mockFeed = { + id: 1, + } + + const dispatchStub = jest.fn() + const commitStub = jest.fn() + beforeAll(() => { + wrapper = shallowMount(FeedItemDisplay, { + propsData: { + item: mockItem, + }, + localVue, + mocks: { + $store: { + getters: { + feeds: [mockFeed], + }, + state: { + feeds: [], + folders: [], + }, + dispatch: dispatchStub, + commit: commitStub, + }, + }, + }) + }) + + beforeEach(() => { + dispatchStub.mockReset() + commitStub.mockReset() + }) + + it('should send SET_SELECTED_ITEM with undefined id', () => { + (wrapper.vm as any).clearSelected() + + expect(commitStub).toBeCalledWith(MUTATIONS.SET_SELECTED_ITEM, { id: undefined }) + }) + + it('should format date to match locale', () => { + const epoch = Date.now() // Provide an epoch timestamp + const formattedDate = (wrapper.vm as any).formatDate(epoch) + + expect(formattedDate).toEqual(new Date(epoch).toLocaleString()) + }) + + it('should format datetime to match international standard', () => { + const epoch = Date.now() // Provide an epoch timestamp + const formattedDate = (wrapper.vm as any).formatDatetime(epoch) + + expect(formattedDate).toEqual(new Date(epoch).toISOString()) + }) + + it('should retrieve feed by ID', () => { + const feed = (wrapper.vm as any).getFeed(mockFeed.id) + + expect(feed).toEqual(mockFeed) + }) + + it('toggles starred state', () => { + wrapper.vm.$props.item.starred = true; + + (wrapper.vm as any).toggleStarred(wrapper.vm.$props.item) + expect(dispatchStub).toHaveBeenCalledWith(ACTIONS.UNSTAR_ITEM, { + item: wrapper.vm.$props.item, + }) + + wrapper.vm.$props.item.starred = false; + + (wrapper.vm as any).toggleStarred(wrapper.vm.$props.item) + expect(dispatchStub).toHaveBeenCalledWith(ACTIONS.STAR_ITEM, { + item: wrapper.vm.$props.item, + }) + }) + + // TODO: Audio/Video tests +}) diff --git a/tests/javascript/unit/components/FeedItemDisplayList.spec.ts b/tests/javascript/unit/components/FeedItemDisplayList.spec.ts new file mode 100644 index 000000000..2b1d7e455 --- /dev/null +++ b/tests/javascript/unit/components/FeedItemDisplayList.spec.ts @@ -0,0 +1,66 @@ +import Vuex, { Store } from 'vuex' +import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils' + +import FeedItemDisplayList from '../../../../src/components/FeedItemDisplayList.vue' +import VirtualScroll from '../../../../src/components/VirtualScroll.vue' +import FeedItemRow from '../../../../src/components/FeedItemRow.vue' + +jest.mock('@nextcloud/axios') + +describe('FeedItemDisplayList.vue', () => { + 'use strict' + const localVue = createLocalVue() + localVue.use(Vuex) + let wrapper: Wrapper + + const mockItem = { + feedId: 1, + title: 'feed item', + pubDate: Date.now() / 1000, + } + + let store: Store + beforeAll(() => { + store = new Vuex.Store({ + state: { + items: { + allItemsLoaded: { + unread: false, + }, + }, + }, + actions: { + }, + getters: { + unread: () => [mockItem, mockItem], + }, + }) + + store.dispatch = jest.fn() + store.commit = jest.fn() + + wrapper = shallowMount(FeedItemDisplayList, { + propsData: { + items: [mockItem], + fetchKey: 'unread', + }, + localVue, + store, + }) + }) + + it('should create FeedItemRow items from input', () => { + expect((wrapper.findComponent(VirtualScroll)).findAllComponents(FeedItemRow).length).toEqual(1) + + wrapper = shallowMount(FeedItemDisplayList, { + propsData: { + items: [mockItem, mockItem], + fetchKey: 'unread', + }, + localVue, + store, + }) + expect((wrapper.findComponent(VirtualScroll)).findAllComponents(FeedItemRow).length).toEqual(2) + }) + +}) diff --git a/tests/javascript/unit/components/FeedItemRow.spec.ts b/tests/javascript/unit/components/FeedItemRow.spec.ts index 14c2d6cca..dde0106a9 100644 --- a/tests/javascript/unit/components/FeedItemRow.spec.ts +++ b/tests/javascript/unit/components/FeedItemRow.spec.ts @@ -48,20 +48,14 @@ describe('FeedItemRow.vue', () => { expect(wrapper.vm.$data.keepUnread).toBeFalsy() }) - it('should expand when clicked', async () => { - await wrapper.find('.feed-item-row').trigger('click') - - // expect(wrapper.vm.$data.expanded).toBe(true) - }) - - it('should format date correctly', () => { + it('should format date to match locale', () => { const epoch = Date.now() // Provide an epoch timestamp const formattedDate = (wrapper.vm as any).formatDate(epoch) expect(formattedDate).toEqual(new Date(epoch).toLocaleString()) }) - it('should format datetime correctly', () => { + it('should format datetime to match international standard', () => { const epoch = Date.now() // Provide an epoch timestamp const formattedDate = (wrapper.vm as any).formatDatetime(epoch)