diff --git a/package-lock.json b/package-lock.json index f8a0bf6965..50742389ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nextcloud/password-confirmation": "^1.0.1", "@nextcloud/router": "^2.0.0", "@nextcloud/vue": "^7.0.1", + "lodash": "^4.17.21", "vue": "^2.6.14", "vue-router": "^3.5.3", "vuex": "^3.6.2" @@ -33,6 +34,7 @@ "@nextcloud/stylelint-config": "^2.1.2", "@nextcloud/webpack-vue-config": "^5.1.0", "@types/jest": "^29.1.1", + "@types/lodash": "^4.14.197", "@types/webpack-env": "^1.17.0", "@typescript-eslint/eslint-plugin": "^5.27.1", "@typescript-eslint/parser": "^5.27.1", @@ -3861,6 +3863,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.197", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.197.tgz", + "integrity": "sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g==", + "dev": true + }, "node_modules/@types/mdast": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz", @@ -23464,6 +23472,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/lodash": { + "version": "4.14.197", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.197.tgz", + "integrity": "sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g==", + "dev": true + }, "@types/mdast": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz", diff --git a/package.json b/package.json index 3d58fb272e..ad25bc7d3b 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@nextcloud/password-confirmation": "^1.0.1", "@nextcloud/router": "^2.0.0", "@nextcloud/vue": "^7.0.1", + "lodash": "^4.17.21", "vue": "^2.6.14", "vue-router": "^3.5.3", "vuex": "^3.6.2" @@ -69,6 +70,7 @@ "@nextcloud/stylelint-config": "^2.1.2", "@nextcloud/webpack-vue-config": "^5.1.0", "@types/jest": "^29.1.1", + "@types/lodash": "^4.14.197", "@types/webpack-env": "^1.17.0", "@typescript-eslint/eslint-plugin": "^5.27.1", "@typescript-eslint/parser": "^5.27.1", diff --git a/src/components/FeedItem.vue b/src/components/FeedItem.vue deleted file mode 100644 index 3d59b4f921..0000000000 --- a/src/components/FeedItem.vue +++ /dev/null @@ -1,340 +0,0 @@ - - - - - diff --git a/src/components/FeedItemDisplay.vue b/src/components/FeedItemDisplay.vue new file mode 100644 index 0000000000..5818fad51a --- /dev/null +++ b/src/components/FeedItemDisplay.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/src/components/FeedItemDisplayList.vue b/src/components/FeedItemDisplayList.vue new file mode 100644 index 0000000000..e86aa93c12 --- /dev/null +++ b/src/components/FeedItemDisplayList.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/components/FeedItemRow.vue b/src/components/FeedItemRow.vue new file mode 100644 index 0000000000..c7fcfd6ecd --- /dev/null +++ b/src/components/FeedItemRow.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index a9c128b150..c5499c585c 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 4dbfd9f5f5..29e7d3bf3e 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -1,18 +1,13 @@ @@ -22,43 +17,41 @@ import { mapState } from 'vuex' import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' -import VirtualScroll from './VirtualScroll.vue' -import FeedItemComponent from './FeedItem.vue' +import FeedItemDisplayList from './FeedItemDisplayList.vue' import { FeedItem } from '../types/FeedItem' +import { ACTIONS, MUTATIONS } from '../store' export default Vue.extend({ components: { NcCounterBubble, - VirtualScroll, - FeedItemComponent, - }, - data() { - return { - mounted: false, - } + FeedItemDisplayList, }, computed: { ...mapState(['items']), + starred(): FeedItem[] { return this.$store.getters.starred }, - reachedEnd(): boolean { - return this.mounted && this.$store.state.items.starredLoaded - }, }, - mounted() { - this.mounted = true + created() { + this.$store.commit(MUTATIONS.SET_SELECTED_ITEM, { id: undefined }) }, 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/VirtualScroll.vue b/src/components/VirtualScroll.vue index 7758559695..c6bb471bd9 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/dataservices/item.service.ts b/src/dataservices/item.service.ts new file mode 100644 index 0000000000..cddd1e9781 --- /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/routes/index.ts b/src/routes/index.ts index f54823d608..b572742c8f 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 9a0da520b3..d866042661 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 dbe343cb55..982e759790 100644 --- a/src/store/item.ts +++ b/src/store/item.ts @@ -1,12 +1,12 @@ -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', + FETCH_UNREAD: 'FETCH_UNREAD', MARK_READ: 'MARK_READ', MARK_UNREAD: 'MARK_UNREAD', STAR_ITEM: 'STAR_ITEM', @@ -14,77 +14,97 @@ export const FEED_ITEM_ACTION_TYPES = { } 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.FETCH_STARRED]({ commit }: ActionParams) { - state.fetchingItems = true - const response = await axios.get(API_ROUTES.ITEMS, { - params: { - limit: 40, - oldestFirst: false, - search: '', - showAll: false, - type: 2, - offset: 0, - }, - }) - - 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 + async [FEED_ITEM_ACTION_TYPES.FETCH_UNREAD]({ commit }: ActionParams, { start }: { start: number } = { start: 0 }) { + 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 }) + } + 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 }) { + 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 = 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) + } item.unread = false 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) + } item.unread = true 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) @@ -92,18 +112,34 @@ 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 => { - 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 }, + [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) }, + [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 8c00e714b4..9d725c0c39 100644 --- a/src/types/MutationTypes.ts +++ b/src/types/MutationTypes.ts @@ -10,6 +10,12 @@ export const FOLDER_MUTATION_TYPES = { export const FEED_ITEM_MUTATION_TYPES = { SET_ITEMS: 'SET_ITEMS', - SET_STARRED_COUNT: 'SET_STARRED_COUNT', UPDATE_ITEM: 'UPDATE_ITEM', + SET_SELECTED_ITEM: 'SET_SELECTED_ITEM', + + SET_STARRED_COUNT: 'SET_STARRED_COUNT', + SET_UNREAD_COUNT: 'SET_UNREAD_COUNT', + + 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 daf50329e6..169d9b5a21 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/FeedItemDisplay.spec.ts b/tests/javascript/unit/components/FeedItemDisplay.spec.ts new file mode 100644 index 0000000000..594ccf9c15 --- /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 0000000000..2b1d7e4551 --- /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/FeedItem.spec.ts b/tests/javascript/unit/components/FeedItemRow.spec.ts similarity index 80% rename from tests/javascript/unit/components/FeedItem.spec.ts rename to tests/javascript/unit/components/FeedItemRow.spec.ts index 88991201fb..dde0106a95 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, }, @@ -34,6 +34,7 @@ describe('FeedItem.vue', () => { folders: [], }, dispatch: dispatchStub, + commit: jest.fn(), }, }, }) @@ -44,24 +45,17 @@ describe('FeedItem.vue', () => { }) it('should initialize without expanded and without keepUnread', () => { - expect(wrapper.vm.$data.expanded).toBeFalsy() 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) @@ -130,12 +124,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 212c69f6e1..4f09f4e3ce 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 FeedItem from '../../../../src/components/FeedItem.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(FeedItem).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 0000000000..6de4d85651 --- /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 0000000000..29de8f1a30 --- /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 922c79f3fa..3a7521f892 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 614e47c1da..c393a03f4a 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) + }) + }) }) })