From 7c582e9b86799638a1ceb205057646e1359ff0de Mon Sep 17 00:00:00 2001 From: Bill Riehl Date: Tue, 23 Apr 2024 15:44:01 -0400 Subject: [PATCH 1/4] Baby steps toward migrating the Feeds page to native Europa --- src/app/Routes.tsx | 4 ++ src/common/api/feedsService.ts | 57 +++++++++++++++++++++++++++- src/features/feeds/FeedTabs.test.tsx | 29 ++++++++++++++ src/features/feeds/FeedTabs.tsx | 32 ++++++++++++++++ src/features/feeds/Feeds.test.tsx | 21 ++++++++++ src/features/feeds/Feeds.tsx | 20 ++++++++++ src/features/layout/LeftNavBar.tsx | 6 +++ 7 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/features/feeds/FeedTabs.test.tsx create mode 100644 src/features/feeds/FeedTabs.tsx create mode 100644 src/features/feeds/Feeds.test.tsx create mode 100644 src/features/feeds/Feeds.tsx 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..b0adad0d --- /dev/null +++ b/src/features/feeds/FeedTabs.test.tsx @@ -0,0 +1,29 @@ +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, + }, + }, +}; + +test('FeedTabs renders', async () => { + const { container } = render( + + + + + + ); + expect(container).toBeTruthy(); + expect(container.textContent).toMatch(testProps.userId); +}); diff --git a/src/features/feeds/FeedTabs.tsx b/src/features/feeds/FeedTabs.tsx new file mode 100644 index 00000000..2405b239 --- /dev/null +++ b/src/features/feeds/FeedTabs.tsx @@ -0,0 +1,32 @@ +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 <>; + } + return ( + <> + {Object.keys(feeds).map((feedId, idx) => { + return ; + })} + + ); +}; + +const FeedTab: FC<{ feedId: string; feed: NotificationFeed }> = ({ + feedId, + feed, +}) => { + return
{feed.name}
; +}; + +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..bcb693b8 --- /dev/null +++ b/src/features/feeds/Feeds.test.tsx @@ -0,0 +1,21 @@ +import { render, screen, waitFor } 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'; + +test('Feeds renders', async () => { + const store = createTestStore(); + const { container } = render( + + + + + + ); + expect(container).toBeTruthy(); + expect(screen.getByText(/Feeds/, { exact: false })).toBeInTheDocument(); + await waitFor(() => { + expect(store.getState().layout.pageTitle).toBe('Notification Feeds'); + }); +}); diff --git a/src/features/feeds/Feeds.tsx b/src/features/feeds/Feeds.tsx new file mode 100644 index 00000000..f7c8c368 --- /dev/null +++ b/src/features/feeds/Feeds.tsx @@ -0,0 +1,20 @@ +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 ( + <> +

Hello I am Feeds.

+ + + ); +}; + +export { feedsPath }; +export default Feeds; 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} /> + Date: Tue, 23 Apr 2024 15:59:47 -0400 Subject: [PATCH 2/4] appease prettier --- src/features/feeds/FeedTabs.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/feeds/FeedTabs.test.tsx b/src/features/feeds/FeedTabs.test.tsx index b0adad0d..d4276a5d 100644 --- a/src/features/feeds/FeedTabs.test.tsx +++ b/src/features/feeds/FeedTabs.test.tsx @@ -20,7 +20,7 @@ test('FeedTabs renders', async () => { const { container } = render( - + ); From 3dbbdd6d32f59ba4ff091ed5d547005e0331dfd0 Mon Sep 17 00:00:00 2001 From: Bill Riehl Date: Tue, 14 May 2024 15:08:08 -0400 Subject: [PATCH 3/4] add more feeds basics and tests / test harness --- src/features/feeds/FeedTabs.test.tsx | 12 +++- src/features/feeds/FeedTabs.tsx | 25 +++++++- src/features/feeds/Feeds.test.tsx | 93 +++++++++++++++++++++++----- src/features/feeds/Feeds.tsx | 1 - src/features/feeds/fixtures.ts | 46 ++++++++++++++ 5 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 src/features/feeds/fixtures.ts diff --git a/src/features/feeds/FeedTabs.test.tsx b/src/features/feeds/FeedTabs.test.tsx index d4276a5d..e3de73e4 100644 --- a/src/features/feeds/FeedTabs.test.tsx +++ b/src/features/feeds/FeedTabs.test.tsx @@ -13,6 +13,16 @@ const testProps: FeedTabsProps = { feed: [], unseen: 0, }, + user: { + name: 'Some User', + feed: [], + unseen: 0, + }, + global: { + name: 'KBase', + feed: [], + unseen: 0, + }, }, }; @@ -25,5 +35,5 @@ test('FeedTabs renders', async () => { ); expect(container).toBeTruthy(); - expect(container.textContent).toMatch(testProps.userId); + expect(container.textContent).toMatch('A feed'); }); diff --git a/src/features/feeds/FeedTabs.tsx b/src/features/feeds/FeedTabs.tsx index 2405b239..d609d519 100644 --- a/src/features/feeds/FeedTabs.tsx +++ b/src/features/feeds/FeedTabs.tsx @@ -13,9 +13,10 @@ const FeedTabs: FC = ({ userId, isAdmin, feeds }) => { if (!feeds) { return <>; } + const order = getFeedsOrder(feeds); return ( <> - {Object.keys(feeds).map((feedId, idx) => { + {order.map((feedId, idx) => { return ; })} @@ -26,7 +27,27 @@ const FeedTab: FC<{ feedId: string; feed: NotificationFeed }> = ({ feedId, feed, }) => { - return
{feed.name}
; + 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 index bcb693b8..fd71dbe5 100644 --- a/src/features/feeds/Feeds.test.tsx +++ b/src/features/feeds/Feeds.test.tsx @@ -1,21 +1,86 @@ -import { render, screen, waitFor } from '@testing-library/react'; +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'; -test('Feeds renders', async () => { - const store = createTestStore(); - const { container } = render( - - - - - - ); - expect(container).toBeTruthy(); - expect(screen.getByText(/Feeds/, { exact: false })).toBeInTheDocument(); - await waitFor(() => { - expect(store.getState().layout.pageTitle).toBe('Notification Feeds'); +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 index f7c8c368..c7a71992 100644 --- a/src/features/feeds/Feeds.tsx +++ b/src/features/feeds/Feeds.tsx @@ -10,7 +10,6 @@ const Feeds: FC = () => { const { data: feedsData } = getFeeds.useQuery({}); return ( <> -

Hello I am Feeds.

); diff --git a/src/features/feeds/fixtures.ts b/src/features/feeds/fixtures.ts new file mode 100644 index 00000000..51474697 --- /dev/null +++ b/src/features/feeds/fixtures.ts @@ -0,0 +1,46 @@ +import { NotificationFeed } from '../../common/api/feedsService'; +import { MockParams } from 'jest-fetch-mock'; + +// 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, +// }, +// }, +// }; + +function emptyFeed(name: string): NotificationFeed { + return { name, feed: [], unseen: 0 }; +} + +function simpleFeedsResponseFactory(feeds: { [key: string]: string }): { + [key: string]: NotificationFeed; +} { + const simpleFeeds: { [key: string]: NotificationFeed } = {}; + Object.keys(feeds).forEach((feedId) => { + simpleFeeds[feedId] = emptyFeed(feeds[feedId]); + }); + simpleFeeds['global'] = emptyFeed('KBase'); + return simpleFeeds; +} + +export const basicFeedsResponseOk = (feeds: { + [key: string]: string; +}): [string, MockParams] => { + const feedResponse = simpleFeedsResponseFactory(feeds); + return [JSON.stringify(feedResponse), { status: 200 }]; +}; From 90fa8e5281f4332344ba97d17d249eb919ba3b6e Mon Sep 17 00:00:00 2001 From: Bill Riehl Date: Tue, 14 May 2024 15:17:08 -0400 Subject: [PATCH 4/4] a little tidying and cleanup --- src/features/feeds/fixtures.ts | 37 ++++++++-------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/features/feeds/fixtures.ts b/src/features/feeds/fixtures.ts index 51474697..b8414f1b 100644 --- a/src/features/feeds/fixtures.ts +++ b/src/features/feeds/fixtures.ts @@ -1,28 +1,6 @@ import { NotificationFeed } from '../../common/api/feedsService'; import { MockParams } from 'jest-fetch-mock'; -// 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, -// }, -// }, -// }; - function emptyFeed(name: string): NotificationFeed { return { name, feed: [], unseen: 0 }; } @@ -30,17 +8,18 @@ function emptyFeed(name: string): NotificationFeed { function simpleFeedsResponseFactory(feeds: { [key: string]: string }): { [key: string]: NotificationFeed; } { - const simpleFeeds: { [key: string]: NotificationFeed } = {}; - Object.keys(feeds).forEach((feedId) => { - simpleFeeds[feedId] = emptyFeed(feeds[feedId]); - }); - simpleFeeds['global'] = emptyFeed('KBase'); + 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] => { - const feedResponse = simpleFeedsResponseFactory(feeds); - return [JSON.stringify(feedResponse), { status: 200 }]; + return [JSON.stringify(simpleFeedsResponseFactory(feeds)), { status: 200 }]; };