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}
/>
+