Skip to content

Initial steps toward migrating the Feeds page to native Europa #201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,6 +64,9 @@ const Routes: FC = () => {
/>
<Route path="/narratives" element={<Authed element={<Navigator />} />} />

{/* Feeds */}
<Route path={feedsPath} element={<Authed element={<Feeds />} />} />

{/* Collections */}
<Route path="/collections">
<Route index element={<Authed element={<CollectionsList />} />} />
Expand Down
57 changes: 56 additions & 1 deletion src/common/api/feedsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +53,9 @@ interface FeedsResults {
user: number;
};
};
getNotificationsResults: {
[key: string]: NotificationFeed;
};
}

// Stubbed Feeds Api for sidebar notifs
Expand All @@ -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;
39 changes: 39 additions & 0 deletions src/features/feeds/FeedTabs.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Provider store={createTestStore()}>
<Router>
<FeedTabs {...testProps} />
</Router>
</Provider>
);
expect(container).toBeTruthy();
expect(container.textContent).toMatch('A feed');
});
53 changes: 53 additions & 0 deletions src/features/feeds/FeedTabs.tsx
Original file line number Diff line number Diff line change
@@ -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<FeedTabsProps> = ({ userId, isAdmin, feeds }) => {
if (!feeds) {
return <></>;
}
const order = getFeedsOrder(feeds);
return (
<>
{order.map((feedId, idx) => {
return <FeedTab feedId={feedId} feed={feeds[feedId]} key={idx} />;
})}
</>
);
};

const FeedTab: FC<{ feedId: string; feed: NotificationFeed }> = ({
feedId,
feed,
}) => {
let name = feed.name;
if (feedId === 'global') {
name = 'KBase Announcements';
}
return <div>{name}</div>;
};

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;
86 changes: 86 additions & 0 deletions src/features/feeds/Feeds.test.tsx
Original file line number Diff line number Diff line change
@@ -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<FallbackProps> = ({ 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 <Feeds /> component', () => {
beforeAll(() => {
enableFetchMocks();
});

afterAll(() => {
disableFetchMocks();
});

beforeEach(() => {
fetchMock.resetMocks();
});

test('renders.', async () => {
const store = createTestStore();
const { container } = await waitFor(() =>
render(
<Provider store={store}>
<Router>
<ErrorBoundary FallbackComponent={TestingError} onError={logError}>
<Feeds />
</ErrorBoundary>
</Router>
</Provider>
)
);
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(
<Provider store={createTestStore()}>
<Router>
<ErrorBoundary FallbackComponent={TestingError} onError={logError}>
<Feeds />
</ErrorBoundary>
</Router>
</Provider>
)
);
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();
});
});
19 changes: 19 additions & 0 deletions src/features/feeds/Feeds.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<FeedTabs userId="foo" isAdmin={false} feeds={feedsData}></FeedTabs>
</>
);
};

export { feedsPath };
export default Feeds;
25 changes: 25 additions & 0 deletions src/features/feeds/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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 }];
};
6 changes: 6 additions & 0 deletions src/features/layout/LeftNavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const LeftNavBar: FC = () => {
icon={faBullhorn}
badge={feeds?.unseen.global}
/>
<NavItem
path="/feeds"
desc="Feeds"
icon={faBullhorn}
badge={'not yet'}
/>
<NavItem
path="/collections"
desc="Collections"
Expand Down
Loading