diff --git a/src/app/Routes.tsx b/src/app/Routes.tsx index 7016f74e..b50f3be0 100644 --- a/src/app/Routes.tsx +++ b/src/app/Routes.tsx @@ -8,6 +8,7 @@ import { import Legacy, { LEGACY_BASE_ROUTE } from '../features/legacy/Legacy'; import { Fallback } from '../features/legacy/IFrameFallback'; +import Feeds, { feedsPath } from '../features/feeds/Feeds'; import Navigator, { navigatorPath, navigatorPathWithCategory, @@ -63,6 +64,9 @@ const Routes: FC = () => { /> } />} /> + {/* Feeds */} + } />} /> + {/* Collections */} } />} /> diff --git a/src/common/api/feedsService.ts b/src/common/api/feedsService.ts index a3679cbd..6f1e65b0 100644 --- a/src/common/api/feedsService.ts +++ b/src/common/api/feedsService.ts @@ -7,8 +7,43 @@ const feedsService = httpService({ url: '/services/feeds/api/V1', }); +export interface NotificationEntity { + id: string; + type: string; + name?: string; +} + +export interface Notification { + id: string; + actor: NotificationEntity; + verb: string; + object: NotificationEntity; + target: NotificationEntity[]; + source: string; + level: string; + seen: boolean; + created: number; + expires: number; + externalKey: string; + context: object; + users: NotificationEntity[]; +} + +export interface NotificationFeed { + name: string; + unseen: number; + feed: Notification[]; +} + interface FeedsParams { getFeedsUnseenCount: void; + getNotificationsParams: { + n?: number; // the maximum number of notifications to return. Should be a number > 0. + rev?: number; // reverse the chronological sort order if 1, if 0, returns with most recent first + l?: string; // filter by the level. Allowed values are alert, warning, error, and request + v?: string; // filter by verb used + seen?: number; // return all notifications that have also been seen by the user if this is set to 1. + }; } interface FeedsResults { @@ -18,6 +53,9 @@ interface FeedsResults { user: number; }; }; + getNotificationsResults: { + [key: string]: NotificationFeed; + }; } // Stubbed Feeds Api for sidebar notifs @@ -39,7 +77,24 @@ export const feedsApi = baseApi.injectEndpoints({ }); }, }), + + getFeeds: builder.query< + FeedsResults['getNotificationsResults'], + FeedsParams['getNotificationsParams'] + >({ + query: () => { + return feedsService({ + headers: { + Authorization: store.getState().auth.token, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + url: '/notifications', + }); + }, + }), }), }); -export const { getFeedsUnseenCount } = feedsApi.endpoints; +export const { getFeedsUnseenCount, getFeeds } = feedsApi.endpoints; diff --git a/src/features/feeds/FeedTabs.test.tsx b/src/features/feeds/FeedTabs.test.tsx new file mode 100644 index 00000000..e3de73e4 --- /dev/null +++ b/src/features/feeds/FeedTabs.test.tsx @@ -0,0 +1,39 @@ +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter as Router } from 'react-router-dom'; +import { createTestStore } from '../../app/store'; +import FeedTabs, { FeedTabsProps } from './FeedTabs'; + +const testProps: FeedTabsProps = { + userId: 'some_user', + isAdmin: false, + feeds: { + feed1: { + name: 'A feed', + feed: [], + unseen: 0, + }, + user: { + name: 'Some User', + feed: [], + unseen: 0, + }, + global: { + name: 'KBase', + feed: [], + unseen: 0, + }, + }, +}; + +test('FeedTabs renders', async () => { + const { container } = render( + + + + + + ); + expect(container).toBeTruthy(); + expect(container.textContent).toMatch('A feed'); +}); diff --git a/src/features/feeds/FeedTabs.tsx b/src/features/feeds/FeedTabs.tsx new file mode 100644 index 00000000..d609d519 --- /dev/null +++ b/src/features/feeds/FeedTabs.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react'; +import { NotificationFeed } from '../../common/api/feedsService'; + +export interface FeedTabsProps { + userId: string; + isAdmin: boolean; + feeds?: { + [key: string]: NotificationFeed; + }; +} + +const FeedTabs: FC = ({ userId, isAdmin, feeds }) => { + if (!feeds) { + return <>; + } + const order = getFeedsOrder(feeds); + return ( + <> + {order.map((feedId, idx) => { + return ; + })} + + ); +}; + +const FeedTab: FC<{ feedId: string; feed: NotificationFeed }> = ({ + feedId, + feed, +}) => { + let name = feed.name; + if (feedId === 'global') { + name = 'KBase Announcements'; + } + return
{name}
; +}; + +function getFeedsOrder(feedsData: { + [key: string]: NotificationFeed; +}): string[] { + const feedOrder = Object.keys(feedsData); + feedOrder.splice(feedOrder.indexOf('global'), 1); + feedOrder.splice(feedOrder.indexOf('user'), 1); + feedOrder.sort((a, b) => feedsData[a].name.localeCompare(feedsData[b].name)); + if ('user' in feedsData) { + feedOrder.unshift('user'); + } + if ('global' in feedsData) { + feedOrder.unshift('global'); + } + return feedOrder; +} + +export default FeedTabs; diff --git a/src/features/feeds/Feeds.test.tsx b/src/features/feeds/Feeds.test.tsx new file mode 100644 index 00000000..fd71dbe5 --- /dev/null +++ b/src/features/feeds/Feeds.test.tsx @@ -0,0 +1,86 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter as Router } from 'react-router-dom'; +import { createTestStore } from '../../app/store'; +import Feeds from './Feeds'; +import fetchMock, { + disableFetchMocks, + enableFetchMocks, +} from 'jest-fetch-mock'; +import { basicFeedsResponseOk } from './fixtures'; +import { FC } from 'react'; +import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; + +const TestingError: FC = ({ error }) => { + return <>Error: {JSON.stringify(error)}; +}; + +const logError = (error: Error, info: { componentStack: string }) => { + console.log({ error }); // eslint-disable-line no-console + console.log(info.componentStack); // eslint-disable-line no-console + screen.debug(); +}; + +describe('The component', () => { + beforeAll(() => { + enableFetchMocks(); + }); + + afterAll(() => { + disableFetchMocks(); + }); + + beforeEach(() => { + fetchMock.resetMocks(); + }); + + test('renders.', async () => { + const store = createTestStore(); + const { container } = await waitFor(() => + render( + + + + + + + + ) + ); + expect(container).toBeTruthy(); + await waitFor(() => { + expect(store.getState().layout.pageTitle).toBe('Notification Feeds'); + }); + }); + + test('renders element for each feed', async () => { + enableFetchMocks(); + const feedsList = { + user: 'SomeUser', + global: 'KBase', + test1: 'Test Feed', + }; + const resp = basicFeedsResponseOk(feedsList); + fetchMock.mockResponses(resp); + const { container } = await waitFor(() => + render( + + + + + + + + ) + ); + expect(container).toBeTruthy(); + // check we have announcements, user, and Test Feed, in that order + const feedLabels = within(container).getAllByText( + /KBase Announcements|SomeUser|Test Feed/ + ); + expect(feedLabels[0]).toHaveTextContent('KBase Announcements'); + expect(feedLabels[1]).toHaveTextContent('SomeUser'); + expect(feedLabels[2]).toHaveTextContent('Test Feed'); + disableFetchMocks(); + }); +}); diff --git a/src/features/feeds/Feeds.tsx b/src/features/feeds/Feeds.tsx new file mode 100644 index 00000000..c7a71992 --- /dev/null +++ b/src/features/feeds/Feeds.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react'; +import { usePageTitle } from '../layout/layoutSlice'; +import FeedTabs from './FeedTabs'; +import { getFeeds } from '../../common/api/feedsService'; + +const feedsPath = '/feeds'; + +const Feeds: FC = () => { + usePageTitle('Notification Feeds'); + const { data: feedsData } = getFeeds.useQuery({}); + return ( + <> + + + ); +}; + +export { feedsPath }; +export default Feeds; diff --git a/src/features/feeds/fixtures.ts b/src/features/feeds/fixtures.ts new file mode 100644 index 00000000..b8414f1b --- /dev/null +++ b/src/features/feeds/fixtures.ts @@ -0,0 +1,25 @@ +import { NotificationFeed } from '../../common/api/feedsService'; +import { MockParams } from 'jest-fetch-mock'; + +function emptyFeed(name: string): NotificationFeed { + return { name, feed: [], unseen: 0 }; +} + +function simpleFeedsResponseFactory(feeds: { [key: string]: string }): { + [key: string]: NotificationFeed; +} { + const simpleFeeds = Object.keys(feeds).reduce( + (acc: { [key: string]: NotificationFeed }, feedId) => { + acc[feedId] = emptyFeed(feeds[feedId]); + return acc; + }, + {} + ); + return simpleFeeds; +} + +export const basicFeedsResponseOk = (feeds: { + [key: string]: string; +}): [string, MockParams] => { + return [JSON.stringify(simpleFeedsResponseFactory(feeds)), { status: 200 }]; +}; diff --git a/src/features/layout/LeftNavBar.tsx b/src/features/layout/LeftNavBar.tsx index 25354b66..cb96e1ed 100644 --- a/src/features/layout/LeftNavBar.tsx +++ b/src/features/layout/LeftNavBar.tsx @@ -43,6 +43,12 @@ const LeftNavBar: FC = () => { icon={faBullhorn} badge={feeds?.unseen.global} /> +