diff --git a/README.md b/README.md index 9aa1f0c9b..f669b794e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ devServer: { ``` 3. Run insights-chrome with `npm run dev` or `npm run dev:beta`. +## Local search development + +See [local search development documentation](./docs/localSearchDevelopment.md). + ## LocalStorage Debugging There are some localStorage values for you to enable debuging information or enable some values that are in experimental state. If you want to enable them call `const iqe = insights.chrome.enable.iqe()` for instance to enable such service. This function will return callback to disable such feature so calling `iqe()` will remove such item from localStorage. diff --git a/cypress/component/DefaultLayout.cy.js b/cypress/component/DefaultLayout.cy.js index dcd0572a9..9a2800716 100644 --- a/cypress/component/DefaultLayout.cy.js +++ b/cypress/component/DefaultLayout.cy.js @@ -91,6 +91,9 @@ describe('', () => { }); reduxRegistry.register(chromeReducer()); store = reduxRegistry.getStore(); + cy.intercept('PUT', 'http://localhost:8080/api/notifications/v1/notifications/drawer/read', { + statusCode: 200, + }); cy.intercept('GET', '/api/featureflags/*', { toggles: [ { diff --git a/cypress/component/NotificationDrawer/NotificationDrawer.cy.tsx b/cypress/component/NotificationDrawer/NotificationDrawer.cy.tsx index 268c0d9b1..f169a420c 100644 --- a/cypress/component/NotificationDrawer/NotificationDrawer.cy.tsx +++ b/cypress/component/NotificationDrawer/NotificationDrawer.cy.tsx @@ -13,6 +13,7 @@ const notificationDrawerData: NotificationData[] = [ created: new Date().toString(), description: 'This is a test notification', source: 'openshift', + bundle: 'rhel', }, { id: '2', @@ -21,6 +22,7 @@ const notificationDrawerData: NotificationData[] = [ created: new Date().toString(), description: 'This is a test notification', source: 'console', + bundle: 'rhel', }, { id: '3', @@ -29,6 +31,7 @@ const notificationDrawerData: NotificationData[] = [ created: new Date().toString(), description: 'This is a test notification', source: 'console', + bundle: 'rhel', }, ]; @@ -75,41 +78,62 @@ describe('Notification Drawer', () => { }); }); - it('should mark single notification as read', () => { + it('should mark a single notification as read', () => { + cy.intercept('PUT', 'http://localhost:8080/api/notifications/v1/notifications/drawer/read', { + statusCode: 200, + }); cy.mount(); cy.get('#populate-notifications').click(); cy.get('#drawer-toggle').click(); cy.get('.pf-m-read').should('have.length', 0); - cy.contains('Notification 1').get('input[type="checkbox"]').first().click(); + cy.get('[aria-label="Notification actions dropdown"]').first().click(); + cy.get('[role="menuitem"]').contains('Mark as read').first().click(); cy.get('.pf-m-read').should('have.length', 1); }); - it('should mark one notification as unread', () => { + it('should mark a single notification as unread', () => { + cy.intercept('PUT', 'http://localhost:8080/api/notifications/v1/notifications/drawer/read', { + statusCode: 200, + }); cy.mount(); cy.get('#populate-notifications').click(); cy.get('#drawer-toggle').click(); cy.get('.pf-m-read').should('have.length', 3); - cy.contains('Notification 1').get('input[type="checkbox"]').first().click(); - cy.get('.pf-m-read').should('have.length', 2); + cy.get('[aria-label="Notification actions dropdown"]').first().click(); + cy.get('[role="menuitem"]').contains('Mark as unread').first().click(); }); it('should mark all notifications as read', () => { + cy.intercept('PUT', 'http://localhost:8080/api/notifications/v1/notifications/drawer/read', { + statusCode: 200, + }); cy.mount(); cy.get('#populate-notifications').click(); cy.get('#drawer-toggle').click(); cy.get('.pf-m-read').should('have.length', 0); + // select all notifications + cy.get('[aria-label="notifications-bulk-select"]').click(); + cy.get('[data-ouia-component-id="notifications-bulk-select-select-all"]').click(); + // mark selected as read cy.get('#notifications-actions-toggle').click(); - cy.contains('Mark visible as read').click(); + cy.contains('Mark selected as read').click(); cy.get('.pf-m-read').should('have.length', 3); }); - it('should mark all notifications as not read', () => { + it('should mark all notifications as unread', () => { + cy.intercept('PUT', 'http://localhost:8080/api/notifications/v1/notifications/drawer/read', { + statusCode: 200, + }); cy.mount(); cy.get('#populate-notifications').click(); cy.get('#drawer-toggle').click(); cy.get('.pf-m-read').should('have.length', 3); + // select all notifications + cy.get('[aria-label="notifications-bulk-select"]').click(); + cy.get('[data-ouia-component-id="notifications-bulk-select-select-all"]').click(); + // mark selected as unread cy.get('#notifications-actions-toggle').click(); - cy.contains('Mark visible as unread').click(); + cy.contains('Mark selected as unread').click(); cy.get('.pf-m-read').should('have.length', 0); }); diff --git a/docs/localSearchDevelopment.md b/docs/localSearchDevelopment.md new file mode 100644 index 000000000..0cbfa6828 --- /dev/null +++ b/docs/localSearchDevelopment.md @@ -0,0 +1,33 @@ +# Local search development + +You can develop and debug search results (homepage, the "Search for services" field) by running Insights Chrome together with chrome-service-backend. + +## Prerequisites + +1. Have a go language setup. You can follow the [gmv guide](https://github.com/moovweb/gvm#installing). +2. Have a podman installed. [Getting started guide](https://podman.io/get-started) +3. Have the [chrome-service-backend](https://github.com/RedHatInsights/chrome-service-backend) checkout locally. +4. Make sure you terminal supports the [Makefile](https://makefiletutorial.com/) utility. + +## Setting up the development environment + +chrome-service-backend is the bridge between kafka and the browser client. It exposes the search-index.json endpoint required for Chrome search to function. + +### Run chrome-service-backend first + +1. Follow the chrome-service-backend steps for local setup (`make dev-static` or `make dev-static-node` should be enough just to serve the static assets including search index). +2. You can request http://localhost:8000/api/chrome-service/v1/static/stable/stage/search/search-index.json (assuming you have left the default port settings) to test the connection and make sure that the chrome service is serving static assets. + +### Generate the local search index + +1. Follow the chrome-service-backend instructions to generate the search index as a JSON file (running `make generate-search-index` should be enough). + +### Start Insights Chrome frontend + +1. Once your chrome service backend is running, start the chrome dev server with the chrome service config using this command: `NAV_CONFIG=8000 yarn dev`. + +When all the steps are complete, you should be able to see the search results (https://stage.foo.redhat.com:1337, "Search for services") provided from the locally generated search index. Any subsequent update to search index must be followed with `make generate-search-index` to regenerate the search index file. + +### Debug tooling + +You can enable additional logging of the search results when typing any prompt by editing [levenshtein-search.ts](../src/utils/levenshtein-search.ts) and setting `debugFlag` to true. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 914ff72b9..57d313542 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@openshift/dynamic-plugin-sdk": "^5.0.1", "@orama/orama": "^2.0.3", "@patternfly/patternfly": "^5.3.0", - "@patternfly/quickstarts": "^5.3.0", + "@patternfly/quickstarts": "^5.4.0-prerelease.1", "@patternfly/react-charts": "^7.3.0", "@patternfly/react-core": "^5.3.0", "@patternfly/react-icons": "^5.3.0", @@ -4125,9 +4125,9 @@ "integrity": "sha512-93uWA15bOJDgu8NF2iReWbbNtWdtM+v7iaDpK33mJChgej+whiFpGLtQPI2jFk1aVW3rDpbt4qm4OaNinpzSsg==" }, "node_modules/@patternfly/quickstarts": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@patternfly/quickstarts/-/quickstarts-5.3.0.tgz", - "integrity": "sha512-2+nKrLag8z8p9d9caQvlSMqcMGkfd8uRl54SGykpjkdp7UDT6VER/nsb4gAZkJA7udrY+yJ8EockNFY6eCiGbA==", + "version": "5.4.0-prerelease.1", + "resolved": "https://registry.npmjs.org/@patternfly/quickstarts/-/quickstarts-5.4.0-prerelease.1.tgz", + "integrity": "sha512-Sl9LdZh2mbk1q2NaEG6Tmopl9KbUoKKl9jgQU9zwBaI+u5x7ZiVbD2Q0uAyliraKQNZPUs86TA0roAeYh3iFeg==", "dependencies": { "@patternfly/react-catalog-view-extension": "^5.0.0", "dompurify": "^2.2.6", diff --git a/package.json b/package.json index 078f32067..7cd55272a 100644 --- a/package.json +++ b/package.json @@ -132,16 +132,16 @@ "@data-driven-forms/react-form-renderer": "^3.22.1", "@formatjs/cli": "4.8.4", "@openshift/dynamic-plugin-sdk": "^5.0.1", + "@orama/orama": "^2.0.3", "@patternfly/patternfly": "^5.3.0", - "@patternfly/quickstarts": "^5.3.0", + "@patternfly/quickstarts": "^5.4.0-prerelease.1", "@patternfly/react-charts": "^7.3.0", "@patternfly/react-core": "^5.3.0", "@patternfly/react-icons": "^5.3.0", "@patternfly/react-tokens": "^5.3.0", - "@orama/orama": "^2.0.3", - "@redhat-cloud-services/frontend-components": "^4.2.2", "@redhat-cloud-services/chrome": "^1.0.9", "@redhat-cloud-services/entitlements-client": "1.2.0", + "@redhat-cloud-services/frontend-components": "^4.2.2", "@redhat-cloud-services/frontend-components-notifications": "^4.1.0", "@redhat-cloud-services/frontend-components-pdf-generator": "4.0.4", "@redhat-cloud-services/frontend-components-utilities": "^4.0.2", diff --git a/src/chrome/create-chrome.test.ts b/src/chrome/create-chrome.test.ts index 0f07ad858..8d3f48da9 100644 --- a/src/chrome/create-chrome.test.ts +++ b/src/chrome/create-chrome.test.ts @@ -121,6 +121,8 @@ describe('create chrome', () => { setPageMetadata: jest.fn(), useGlobalFilter: jest.fn(), registerModule: jest.fn(), + addNavListener: jest.fn(), + deleteNavListener: jest.fn(), }; beforeAll(() => { const mockAuthMethods = { diff --git a/src/chrome/create-chrome.ts b/src/chrome/create-chrome.ts index 441281adf..3739eda36 100644 --- a/src/chrome/create-chrome.ts +++ b/src/chrome/create-chrome.ts @@ -1,14 +1,12 @@ import { createFetchPermissionsWatcher } from '../auth/fetchPermissions'; -import { AppNavigationCB, ChromeAPI, GenericCB, NavDOMEvent } from '@redhat-cloud-services/types'; +import { AppNavigationCB, ChromeAPI, GenericCB } from '@redhat-cloud-services/types'; import { Store } from 'redux'; import { AnalyticsBrowser } from '@segment/analytics-next'; import get from 'lodash/get'; import Cookies from 'js-cookie'; import { - AppNavClickItem, appAction, - appNavClick, appObjectId, globalFilterScope, removeGlobalFilter, @@ -37,6 +35,7 @@ import requestPdf from '../pdf/requestPdf'; import chromeStore from '../state/chromeStore'; import { isFeedbackModalOpenAtom } from '../state/atoms/feedbackModalAtom'; import { usePendoFeedback } from '../components/Feedback'; +import { NavListener, activeAppAtom } from '../state/atoms/activeAppAtom'; export type CreateChromeContextConfig = { useGlobalFilter: (callback: (selectedTags?: FlagTagsFilter) => any) => ReturnType; @@ -48,6 +47,8 @@ export type CreateChromeContextConfig = { chromeAuth: ChromeAuthContextValue; registerModule: (payload: RegisterModulePayload) => void; isPreview: boolean; + addNavListener: (cb: NavListener) => number; + deleteNavListener: (id: number) => void; }; export const createChromeContext = ({ @@ -60,6 +61,8 @@ export const createChromeContext = ({ registerModule, chromeAuth, isPreview, + addNavListener, + deleteNavListener, }: CreateChromeContextConfig): ChromeAPI => { const fetchPermissions = createFetchPermissionsWatcher(chromeAuth.getUser); const visibilityFunctions = getVisibilityFunctions(); @@ -67,7 +70,7 @@ export const createChromeContext = ({ const actions = { appAction: (action: string) => dispatch(appAction(action)), appObjectId: (objectId: string) => dispatch(appObjectId(objectId)), - appNavClick: (item: AppNavClickItem, event?: NavDOMEvent) => dispatch(appNavClick(item, event)), + appNavClick: (item: string) => chromeStore.set(activeAppAtom, item), globalFilterScope: (scope: string) => dispatch(globalFilterScope(scope)), registerModule: (module: string, manifest?: string) => registerModule({ module, manifest }), removeGlobalFilter: (isHidden: boolean) => { @@ -76,13 +79,17 @@ export const createChromeContext = ({ }, }; - const on = (type: keyof typeof PUBLIC_EVENTS, callback: AppNavigationCB | GenericCB) => { + const on = (type: keyof typeof PUBLIC_EVENTS | 'APP_NAVIGATION', callback: AppNavigationCB | GenericCB) => { + if (type === 'APP_NAVIGATION') { + const listenerId = addNavListener(callback); + return () => deleteNavListener(listenerId); + } if (!Object.prototype.hasOwnProperty.call(PUBLIC_EVENTS, type)) { throw new Error(`Unknown event type: ${type}`); } const [listener, selector] = PUBLIC_EVENTS[type]; - if (type !== 'APP_NAVIGATION' && typeof selector === 'string') { + if (typeof selector === 'string') { (callback as GenericCB)({ data: get(store.getState(), selector) || {}, }); diff --git a/src/components/ChromeLink/ChromeLink.test.js b/src/components/ChromeLink/ChromeLink.test.js index 1909c0668..daae5bba6 100644 --- a/src/components/ChromeLink/ChromeLink.test.js +++ b/src/components/ChromeLink/ChromeLink.test.js @@ -6,7 +6,6 @@ import createMockStore from 'redux-mock-store'; import { MemoryRouter } from 'react-router-dom'; import ChromeLink from './ChromeLink'; import NavContext from '../Navigation/navContext'; -import { APP_NAV_CLICK } from '../../redux/action-types'; const LinkContext = ({ store, @@ -48,86 +47,6 @@ describe('ChromeLink', () => { expect(getAllByTestId('router-link')).toHaveLength(1); }); - test('should dispatch appNavClick with correct actionId for top level route', () => { - const store = mockStore({ - chrome: { - moduleRoutes: [], - activeModule: 'testModule', - modules: { - testModule: {}, - }, - }, - }); - const { - container: { firstElementChild: buttton }, - } = render( - - Test module link - - ); - - act(() => { - fireEvent.click(buttton); - }); - - expect(store.getActions()).toEqual([ - { - type: APP_NAV_CLICK, - payload: { - id: '/', - event: { - id: '/', - navId: '/', - href: '/insights/foo', - type: 'click', - target: expect.any(Element), - }, - }, - }, - ]); - }); - - test('should dispatch appNavClick with correct actionId for nested route', () => { - const store = mockStore({ - chrome: { - moduleRoutes: [], - activeModule: 'testModule', - modules: { - testModule: {}, - }, - }, - }); - const { - container: { firstElementChild: buttton }, - } = render( - - - Test module link - - - ); - - act(() => { - fireEvent.click(buttton); - }); - - expect(store.getActions()).toEqual([ - { - type: APP_NAV_CLICK, - payload: { - id: 'bar', - event: { - id: 'bar', - navId: 'bar', - href: '/insights/foo/bar', - type: 'click', - target: expect.any(Element), - }, - }, - }, - ]); - }); - test('should not trigger onLinkClick callback', () => { const onLinkClickSpy = jest.fn(); const store = mockStore({ diff --git a/src/components/ChromeLink/ChromeLink.tsx b/src/components/ChromeLink/ChromeLink.tsx index 1ef4845b5..d955cae50 100644 --- a/src/components/ChromeLink/ChromeLink.tsx +++ b/src/components/ChromeLink/ChromeLink.tsx @@ -1,14 +1,13 @@ import React, { memo, useContext, useMemo, useRef } from 'react'; import { NavLink } from 'react-router-dom'; -import { useDispatch } from 'react-redux'; import { preloadModule } from '@scalprum/core'; -import { appNavClick } from '../../redux/actions'; import NavContext, { OnLinkClick } from '../Navigation/navContext'; import { NavDOMEvent } from '../../@types/types'; -import { useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { activeModuleAtom } from '../../state/atoms/activeModuleAtom'; import { moduleRoutesAtom } from '../../state/atoms/chromeModuleAtom'; +import { triggerNavListenersAtom } from '../../state/atoms/activeAppAtom'; interface RefreshLinkProps extends React.HTMLAttributes { isExternal?: boolean; @@ -32,6 +31,7 @@ const LinkWrapper: React.FC = memo( ({ href = '', isBeta, onLinkClick, className, currAppId, appId, children, tabIndex, ...props }) => { const linkRef = useRef(null); const moduleRoutes = useAtomValue(moduleRoutesAtom); + const triggerNavListener = useSetAtom(triggerNavListenersAtom); const moduleEntry = useMemo(() => moduleRoutes?.find((route) => href?.includes(route.path)), [href, appId]); const preloadTimeout = useRef(); let actionId = href.split('/').slice(2).join('/'); @@ -57,7 +57,6 @@ const LinkWrapper: React.FC = memo( */ type: 'click', }; - const dispatch = useDispatch(); const onClick = (event: React.MouseEvent) => { if (event.ctrlKey || event.shiftKey) { return false; @@ -72,7 +71,7 @@ const LinkWrapper: React.FC = memo( * Add reference to the DOM link element */ domEvent.target = linkRef.current; - dispatch(appNavClick({ id: actionId }, domEvent)); + triggerNavListener({ navId: actionId, domEvent }); }; // turns /settings/rbac/roles -> settings_rbac_roles diff --git a/src/components/ErrorComponents/DefaultErrorComponent.tsx b/src/components/ErrorComponents/DefaultErrorComponent.tsx index 8ede6a381..d60105ce8 100644 --- a/src/components/ErrorComponents/DefaultErrorComponent.tsx +++ b/src/components/ErrorComponents/DefaultErrorComponent.tsx @@ -24,6 +24,7 @@ export type DefaultErrorComponentProps = { errorInfo?: { componentStack?: string; }; + signIn?: () => Promise; }; const DefaultErrorComponent = (props: DefaultErrorComponentProps) => { @@ -66,7 +67,7 @@ const DefaultErrorComponent = (props: DefaultErrorComponentProps) => { }, [props.error, activeModule]); // second level of error capture if xhr/fetch interceptor fails - const gatewayError = get3scaleError(props.error as any); + const gatewayError = get3scaleError(props.error as any, props.signIn); if (gatewayError) { return ; } diff --git a/src/components/ErrorComponents/ErrorBoundary.tsx b/src/components/ErrorComponents/ErrorBoundary.tsx index 4c87766f8..86dad8bf3 100644 --- a/src/components/ErrorComponents/ErrorBoundary.tsx +++ b/src/components/ErrorComponents/ErrorBoundary.tsx @@ -11,6 +11,7 @@ type ErrorBoundaryState = { class ErrorBoundary extends React.Component< { children: React.ReactNode; + singIn?: () => Promise; }, ErrorBoundaryState > { @@ -33,7 +34,7 @@ class ErrorBoundary extends React.Component< render() { if (this.state.hasError) { - return ; + return ; } return this.props.children; diff --git a/src/components/NotificationsDrawer/DrawerPanelContent.tsx b/src/components/NotificationsDrawer/DrawerPanelContent.tsx index 21043a5d2..2d2bcf42f 100644 --- a/src/components/NotificationsDrawer/DrawerPanelContent.tsx +++ b/src/components/NotificationsDrawer/DrawerPanelContent.tsx @@ -29,8 +29,13 @@ import { notificationDrawerDataAtom, notificationDrawerExpandedAtom, notificationDrawerFilterAtom, - updateNotificationsStatusAtom, + notificationDrawerSelectedAtom, + updateNotificationReadAtom, + updateNotificationSelectedAtom, + updateNotificationsSelectedAtom, } from '../../state/atoms/notificationDrawerAtom'; +import BulkSelect from '@redhat-cloud-services/frontend-components/BulkSelect'; +import axios from 'axios'; export type DrawerPanelProps = { innerRef: React.Ref; @@ -74,11 +79,14 @@ const DrawerPanelBase = ({ innerRef }: DrawerPanelProps) => { const toggleDrawer = useSetAtom(notificationDrawerExpandedAtom); const navigate = useNavigate(); const notifications = useAtomValue(notificationDrawerDataAtom); - const updateNotificationsStatus = useSetAtom(updateNotificationsStatusAtom); + const selectedNotifications = useAtomValue(notificationDrawerSelectedAtom); + const updateSelectedNotification = useSetAtom(updateNotificationSelectedAtom); const auth = useContext(ChromeAuthContext); const isOrgAdmin = auth?.user?.identity?.user?.is_org_admin; const { getUserPermissions } = useContext(InternalChromeContext); const [hasNotificationsPermissions, setHasNotificationsPermissions] = useState(false); + const updateNotificationRead = useSetAtom(updateNotificationReadAtom); + const updateAllNotificationsSelected = useSetAtom(updateNotificationsSelectedAtom); useEffect(() => { let mounted = true; @@ -114,14 +122,29 @@ const DrawerPanelBase = ({ innerRef }: DrawerPanelProps) => { toggleDrawer(false); }; - const onMarkAllAsRead = () => { - updateNotificationsStatus(true); - setIsDropdownOpen(false); + const onUpdateSelectedStatus = (read: boolean) => { + axios + .put('/api/notifications/v1/notifications/drawer/read', { + notification_ids: selectedNotifications.map((notification) => notification.id), + read_status: read, + }) + .then(() => { + selectedNotifications.forEach((notification) => updateNotificationRead(notification.id, read)); + setIsDropdownOpen(false); + updateAllNotificationsSelected(false); + }) + .catch((e) => { + console.error('failed to update notification read status', e); + }); }; - const onMarkAllAsUnread = () => { - updateNotificationsStatus(false); - setIsDropdownOpen(false); + const selectAllNotifications = (selected: boolean) => { + updateAllNotificationsSelected(selected); + }; + + const selectVisibleNotifications = () => { + const visibleNotifications = activeFilters.length > 0 ? filteredNotifications : notifications; + visibleNotifications.forEach((notification) => updateSelectedNotification(notification.id, true)); }; const onFilterSelect = (chosenFilter: string) => { @@ -137,11 +160,23 @@ const DrawerPanelBase = ({ innerRef }: DrawerPanelProps) => { const dropdownItems = [ , - - Mark visible as read + { + onUpdateSelectedStatus(true); + }} + isDisabled={notifications.length === 0} + > + Mark selected as read , - - Mark visible as unread + { + onUpdateSelectedStatus(false); + }} + isDisabled={notifications.length === 0} + > + Mark selected as unread , , , @@ -225,6 +260,21 @@ const DrawerPanelBase = ({ innerRef }: DrawerPanelProps) => { > {filterDropdownItems()} + selectAllNotifications(false) }, + { + title: `Select visible (${activeFilters.length > 0 ? filteredNotifications.length : notifications.length})`, + key: 'select-visible', + onClick: selectVisibleNotifications, + }, + { title: `Select all (${notifications.length})`, key: 'select-all', onClick: () => selectAllNotifications(true) }, + ]} + count={notifications.filter(({ selected }) => selected).length} + checked={notifications.length > 0 && notifications.every(({ selected }) => selected)} + ouiaId="notifications-bulk-select" + /> ) => ( = ({ notification, onNavigateTo }) => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const updateNotificationSelected = useSetAtom(updateNotificationSelectedAtom); const updateNotificationRead = useSetAtom(updateNotificationReadAtom); const onCheckboxToggle = () => { - updateNotificationRead(notification.id, !notification.read); - setIsDropdownOpen(false); + updateNotificationSelected(notification.id, !notification.selected); + }; + + const onMarkAsRead = () => { + axios + .put('/api/notifications/v1/notifications/drawer/read', { + notification_ids: [notification.id], + read_status: !notification.read, + }) + .then(() => { + updateNotificationRead(notification.id, !notification.read); + setIsDropdownOpen(false); + }) + .catch((e) => { + console.error('failed to update notification read status', e); + }); }; const notificationDropdownItems = [ - {`Mark as ${!notification.read ? 'read' : 'unread'}`}, + {`Mark as ${!notification.read ? 'read' : 'unread'}`}, onNavigateTo('settings/notifications/configure-events')}> Manage this event , @@ -39,7 +55,7 @@ const NotificationItem: React.FC = ({ notification, onNav - + ) => ( import('../Stratosphere/ProductSelection')); @@ -59,6 +60,8 @@ const ScalprumRoot = memo( const registerModule = useSetAtom(onRegisterModuleWriteAtom); const populateNotifications = useSetAtom(notificationDrawerDataAtom); const isPreview = useAtomValue(isPreviewAtom); + const addNavListener = useSetAtom(addNavListenerAtom); + const deleteNavListener = useSetAtom(deleteNavListenerAtom); const store = useStore(); const mutableChromeApi = useRef(); @@ -155,6 +158,8 @@ const ScalprumRoot = memo( chromeAuth, registerModule, isPreview, + addNavListener, + deleteNavListener, }); // reset chrome object after token (user) updates/changes }, [chromeAuth.token, isPreview]); diff --git a/src/layouts/DefaultLayout.test.js b/src/layouts/DefaultLayout.test.js index c9c1fadbf..08b10bfb4 100644 --- a/src/layouts/DefaultLayout.test.js +++ b/src/layouts/DefaultLayout.test.js @@ -4,6 +4,20 @@ import DefaultLayout from './DefaultLayout'; import { render } from '@testing-library/react'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-redux'; +import { Provider as ProviderJotai } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import { activeAppAtom } from '../state/atoms/activeAppAtom'; + +const HydrateAtoms = ({ initialValues, children }) => { + useHydrateAtoms(initialValues); + return children; +}; + +const TestProvider = ({ initialValues, children }) => ( + + {children} + +); jest.mock('../state/atoms/releaseAtom', () => { const util = jest.requireActual('../state/atoms/utils'); @@ -28,7 +42,6 @@ describe('DefaultLayout', () => { mockStore = configureStore(); initialState = { chrome: { - activeApp: 'some-app', activeLocation: 'some-location', appId: 'app-id', navigation: { @@ -51,11 +64,13 @@ describe('DefaultLayout', () => { it('should render correctly - no data', async () => { const store = mockStore({ chrome: {} }); const { container } = render( - - - - - + + + + + + + ); expect(container.querySelector('#chrome-app-render-root')).toMatchSnapshot(); }); @@ -63,11 +78,13 @@ describe('DefaultLayout', () => { it('should render correctly', () => { const store = mockStore(initialState); const { container } = render( - - - - - + + + + + + + ); expect(container.querySelector('#chrome-app-render-root')).toMatchSnapshot(); }); @@ -81,11 +98,13 @@ describe('DefaultLayout', () => { globalFilter: {}, }); const { container } = render( - - - - - + + + + + + + ); expect(container.querySelector('#chrome-app-render-root')).toMatchSnapshot(); }); @@ -98,11 +117,13 @@ describe('DefaultLayout', () => { }, }); const { container } = render( - - - - - + + + + + + + ); expect(container.querySelector('#chrome-app-render-root')).toMatchSnapshot(); }); @@ -116,11 +137,13 @@ describe('DefaultLayout', () => { }, }); const { container } = render( - - - - - + + + + + + + ); expect(container.querySelector('#chrome-app-render-root')).toMatchSnapshot(); }); diff --git a/src/layouts/__snapshots__/DefaultLayout.test.js.snap b/src/layouts/__snapshots__/DefaultLayout.test.js.snap index 621b7a96d..653dada8b 100644 --- a/src/layouts/__snapshots__/DefaultLayout.test.js.snap +++ b/src/layouts/__snapshots__/DefaultLayout.test.js.snap @@ -4,6 +4,7 @@ exports[`DefaultLayout should render correctly - no data 1`] = `
diff --git a/src/redux/action-types.ts b/src/redux/action-types.ts index 0690ff205..64f76ecc7 100644 --- a/src/redux/action-types.ts +++ b/src/redux/action-types.ts @@ -1,7 +1,5 @@ export const USER_LOGIN = '@@chrome/user-login'; -export const APP_NAV_CLICK = '@@chrome/app-nav-click'; - export const CHROME_PAGE_ACTION = '@@chrome/app-page-action'; export const CHROME_PAGE_OBJECT = '@@chrome/app-object-id'; diff --git a/src/redux/actions.ts b/src/redux/actions.ts index bd33f2b31..88cf682fc 100644 --- a/src/redux/actions.ts +++ b/src/redux/actions.ts @@ -2,7 +2,7 @@ import * as actionTypes from './action-types'; import { getAllSIDs, getAllTags, getAllWorkloads } from '../components/GlobalFilter/tagsApi'; import type { TagFilterOptions, TagPagination } from '../components/GlobalFilter/tagsApi'; import type { ChromeUser } from '@redhat-cloud-services/types'; -import type { FlagTagsFilter, NavDOMEvent, NavItem, Navigation } from '../@types/types'; +import type { FlagTagsFilter, NavItem, Navigation } from '../@types/types'; import type { AccessRequest } from './store'; import type { QuickStart } from '@patternfly/quickstarts'; @@ -18,9 +18,6 @@ export type AppNavClickItem = { id?: string; custom?: boolean }; /* *TODO: The event type is deliberately nonse. It will start failing once we mirate rest of the app and we will figure out the correct type */ -export function appNavClick(item: AppNavClickItem, event?: NavDOMEvent) { - return { type: actionTypes.APP_NAV_CLICK, payload: { ...(item || {}), id: item?.id, event } }; -} export function appAction(action: string) { return { type: actionTypes.CHROME_PAGE_ACTION, payload: action }; diff --git a/src/redux/chromeReducers.ts b/src/redux/chromeReducers.ts index d7424b796..0bfff2347 100644 --- a/src/redux/chromeReducers.ts +++ b/src/redux/chromeReducers.ts @@ -5,13 +5,6 @@ import { NavItem, Navigation } from '../@types/types'; import { ITLess, highlightItems, levelArray } from '../utils/common'; import { AccessRequest, ChromeState } from './store'; -export function appNavClick(state: ChromeState, { payload }: { payload: { id: string } }): ChromeState { - return { - ...state, - activeApp: payload.id, - }; -} - export function loginReducer(state: ChromeState, { payload }: { payload: ChromeUser }): ChromeState { const missingIDP = ITLess() && !Object.prototype.hasOwnProperty.call(payload?.identity, 'idp'); return { diff --git a/src/redux/index.ts b/src/redux/index.ts index 5d27dfd2d..7654e981b 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -3,7 +3,6 @@ import { applyReducerHash } from '@redhat-cloud-services/frontend-components-uti import { accessRequestsNotificationsReducer, addQuickstartstoApp, - appNavClick, clearQuickstartsReducer, disableQuickstartsReducer, documentTitleReducer, @@ -33,7 +32,6 @@ import { } from './globalFilterReducers'; import { ADD_QUICKSTARTS_TO_APP, - APP_NAV_CLICK, CHROME_GET_ALL_SIDS, CHROME_GET_ALL_TAGS, CHROME_GET_ALL_WORKLOADS, @@ -60,7 +58,6 @@ import { ChromeState, GlobalFilterState, ReduxState } from './store'; import { AnyAction } from 'redux'; const reducers = { - [APP_NAV_CLICK]: appNavClick, [USER_LOGIN]: loginReducer, [CHROME_PAGE_ACTION]: onPageAction, [CHROME_PAGE_OBJECT]: onPageObjectId, diff --git a/src/redux/store.d.ts b/src/redux/store.d.ts index 0b1fa639c..871b9cc59 100644 --- a/src/redux/store.d.ts +++ b/src/redux/store.d.ts @@ -10,7 +10,6 @@ export type InternalNavigation = { export type AccessRequest = { request_id: string; created: string; seen: boolean }; export type ChromeState = { - activeApp?: string; activeProduct?: string; missingIDP?: boolean; pageAction?: string; diff --git a/src/state/atoms/activeAppAtom.test.tsx b/src/state/atoms/activeAppAtom.test.tsx new file mode 100644 index 000000000..874102387 --- /dev/null +++ b/src/state/atoms/activeAppAtom.test.tsx @@ -0,0 +1,139 @@ +import React, { useEffect } from 'react'; +import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; +import ChromeLink from '../../components/ChromeLink'; +import { activeNavListenersAtom, addNavListenerAtom, deleteNavListenerAtom, triggerNavListenersAtom } from './activeAppAtom'; +import { Provider as ProviderJotai, useAtomValue, useSetAtom } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import { MemoryRouter } from 'react-router-dom'; +import { NavDOMEvent } from '@redhat-cloud-services/types'; + +const HydrateAtoms = ({ initialValues, children }: { initialValues: any; children: React.ReactNode }) => { + useHydrateAtoms(initialValues); + return children; +}; + +const TestProvider = ({ initialValues, children }: { initialValues: any; children: React.ReactNode }) => ( + + {children} + +); + +test('addNavListenerAtom should add a listener', async () => { + const mockNavListener = jest.fn(); + + const MockComponent = () => { + const addNavListener = useSetAtom(addNavListenerAtom); + + useEffect(() => { + addNavListener(mockNavListener); + }, []); + + return ( + + + Add Event Listener + + + ); + }; + + const { getByText } = render( + + + + ); + + fireEvent.click(getByText('Add Event Listener')); + + await waitFor(() => { + expect(mockNavListener).toHaveBeenCalled(); + }); +}); + +test('deleteNavListenerAtom should remove a listener by id', async () => { + let listenerId: number; + const mockNavListener = jest.fn(); + + const MockComponent = () => { + const addNavListener = useSetAtom(addNavListenerAtom); + const deleteNavListener = useSetAtom(deleteNavListenerAtom); + + useEffect(() => { + listenerId = addNavListener(mockNavListener); + }, [addNavListener]); + + useEffect(() => { + if (listenerId) { + deleteNavListener(listenerId); + } + }, [deleteNavListener, listenerId]); + + return null; + }; + + render( + + + + ); + + await waitFor(() => { + const activeNavListeners = renderHook(() => useAtomValue(activeNavListenersAtom)).result.current; + expect(activeNavListeners[listenerId]).toBeUndefined(); + }); +}); + +test('triggerNavListenersAtom should call all activeListeners', async () => { + const mockNavListener1 = jest.fn(); + const mockNavListener2 = jest.fn(); + + const sampleNavEvent: { + nav: string; + domEvent: NavDOMEvent; + } = { + nav: 'sample-id', + domEvent: { + href: 'foo', + id: 'bar', + navId: 'baz', + type: 'quazz', + target: {} as any, + }, + }; + + const MockComponent = () => { + const triggerNavListeners = useSetAtom(triggerNavListenersAtom); + return ( + + ); + }; + + await render( + + + + ); + + await fireEvent.click(screen.getByText('Foo')); + + await waitFor(() => { + expect(mockNavListener1).toHaveBeenCalledWith(sampleNavEvent); + expect(mockNavListener2).toHaveBeenCalledWith(sampleNavEvent); + }); +}); diff --git a/src/state/atoms/activeAppAtom.ts b/src/state/atoms/activeAppAtom.ts new file mode 100644 index 000000000..4e3879003 --- /dev/null +++ b/src/state/atoms/activeAppAtom.ts @@ -0,0 +1,27 @@ +import { NavDOMEvent } from '@redhat-cloud-services/types'; +import { atom } from 'jotai'; + +export type NavEvent = { navId?: string; domEvent: NavDOMEvent }; +export type NavListener = (navEvent: NavEvent) => void; + +export const activeAppAtom = atom(undefined); +export const activeNavListenersAtom = atom<{ [listenerId: number]: NavListener | undefined }>({}); +export const addNavListenerAtom = atom(null, (_get, set, navListener: NavListener) => { + const listenerId = Date.now(); + set(activeNavListenersAtom, (prev) => { + return { ...prev, [listenerId]: navListener }; + }); + return listenerId; +}); +export const deleteNavListenerAtom = atom(null, (get, set, id: number) => { + set(activeNavListenersAtom, (prev) => { + return { ...prev, [id]: undefined }; + }); +}); + +export const triggerNavListenersAtom = atom(null, (get, _set, event: NavEvent) => { + const activeNavListeners = get(activeNavListenersAtom); + Object.values(activeNavListeners).forEach((el) => { + el?.(event); + }); +}); diff --git a/src/state/atoms/notificationDrawerAtom.ts b/src/state/atoms/notificationDrawerAtom.ts index f83b5f6f4..3f184ecee 100644 --- a/src/state/atoms/notificationDrawerAtom.ts +++ b/src/state/atoms/notificationDrawerAtom.ts @@ -5,6 +5,7 @@ export type NotificationData = { title: string; description: string; read: boolean; + selected?: boolean; source: string; created: string; }; @@ -35,3 +36,10 @@ export const addNotificationAtom = atom(null, (_get, set, ...notifications: Noti set(notificationDrawerDataAtom, (prev) => [...notifications, ...prev]); set(notificationDrawerCountAtom, (prev) => prev + notifications.length); }); +export const notificationDrawerSelectedAtom = atom((get) => get(notificationDrawerDataAtom).filter((notification) => notification.selected)); +export const updateNotificationSelectedAtom = atom(null, (_get, set, id: string, selected: boolean) => { + set(notificationDrawerDataAtom, (prev) => prev.map((notification) => (notification.id === id ? { ...notification, selected } : notification))); +}); +export const updateNotificationsSelectedAtom = atom(null, (_get, set, selected: boolean) => { + set(notificationDrawerDataAtom, (prev) => prev.map((notification) => ({ ...notification, selected }))); +}); diff --git a/src/state/atoms/releaseAtom.ts b/src/state/atoms/releaseAtom.ts index dc8fae6ac..1b6587a36 100644 --- a/src/state/atoms/releaseAtom.ts +++ b/src/state/atoms/releaseAtom.ts @@ -12,9 +12,13 @@ export const isPreviewAtom = atomWithToggle(undefined, async (isPreview) => { } if (unleashClientExists()) { // Required to change the `platform.chrome.ui.preview` context in the feature flags, TS is bugged - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - getUnleashClient().updateContext({ 'platform.chrome.ui.preview': isPreview }); + getUnleashClient().updateContext({ + // make sure to re-use the prev context + ...getUnleashClient().getContext(), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + 'platform.chrome.ui.preview': isPreview, + }); } } catch (error) { console.error('Failed to update the visibility functions or feature flags context', error); diff --git a/src/state/chromeStore.ts b/src/state/chromeStore.ts index e727b8f8c..b176137be 100644 --- a/src/state/chromeStore.ts +++ b/src/state/chromeStore.ts @@ -5,6 +5,7 @@ import { isPreviewAtom } from './atoms/releaseAtom'; import { isBeta } from '../utils/common'; import { gatewayErrorAtom } from './atoms/gatewayErrorAtom'; import { isFeedbackModalOpenAtom } from './atoms/feedbackModalAtom'; +import { activeAppAtom } from './atoms/activeAppAtom'; const chromeStore = createStore(); @@ -16,6 +17,7 @@ chromeStore.set(gatewayErrorAtom, undefined); chromeStore.set(isFeedbackModalOpenAtom, false); // is set in bootstrap chromeStore.set(isPreviewAtom, false); +chromeStore.set(activeAppAtom, undefined); // globally handle subscription to activeModuleAtom chromeStore.sub(activeModuleAtom, () => { diff --git a/src/utils/consts.ts b/src/utils/consts.ts index 4b13bd206..d9ef4871e 100644 --- a/src/utils/consts.ts +++ b/src/utils/consts.ts @@ -1,7 +1,7 @@ import { ITLess } from './common'; -import { AppNavigationCB, ChromeAuthOptions, GenericCB, NavDOMEvent } from '../@types/types'; +import { ChromeAuthOptions, GenericCB } from '../@types/types'; import { Listener } from '@redhat-cloud-services/frontend-components-utilities/MiddlewareListener'; -import { APP_NAV_CLICK, GLOBAL_FILTER_UPDATE } from '../redux/action-types'; +import { GLOBAL_FILTER_UPDATE } from '../redux/action-types'; export const noAuthParam = 'noauth'; export const offlineToken = '2402500adeacc30eb5c5a8a5e2e0ec1f'; @@ -72,23 +72,9 @@ export const defaultAuthOptions: ChromeAuthOptions = { export const OFFLINE_REDIRECT_STORAGE_KEY = 'chrome.offline.redirectUri'; export const PUBLIC_EVENTS: { - APP_NAVIGATION: [(callback: AppNavigationCB) => Listener]; NAVIGATION_TOGGLE: [(callback: GenericCB) => Listener]; GLOBAL_FILTER_UPDATE: [(callback: GenericCB) => Listener, string]; } = { - APP_NAVIGATION: [ - (callback: (navEvent: { navId?: string; domEvent: NavDOMEvent }) => void) => { - const appNavListener: Listener<{ event: NavDOMEvent; id?: string }> = { - on: APP_NAV_CLICK, - callback: ({ data }) => { - if (data.id !== undefined || data.event) { - callback({ navId: data.id, domEvent: data.event }); - } - }, - }; - return appNavListener; - }, - ], NAVIGATION_TOGGLE: [ (callback: (...args: unknown[]) => void) => { console.error('NAVIGATION_TOGGLE event is deprecated and will be removed in future versions of chrome.'); diff --git a/src/utils/iqeEnablement.ts b/src/utils/iqeEnablement.ts index 0368b4dae..10a8133a3 100644 --- a/src/utils/iqeEnablement.ts +++ b/src/utils/iqeEnablement.ts @@ -130,7 +130,7 @@ export function init(chromeStore: ReturnType, authRef: React } this.onload = function () { if (this.status >= 400) { - const gatewayError = get3scaleError(this.response); + const gatewayError = get3scaleError(this.response, authRef.current.signinRedirect); if (this.status === 403 && this.responseText.includes(DENIED_CROSS_CHECK)) { crossAccountBouncer(); // check for 3scale error @@ -178,7 +178,7 @@ export function init(chromeStore: ReturnType, authRef: React try { const isJson = resCloned?.headers?.get('content-type')?.includes('application/json'); const data = isJson ? await resCloned.json() : await resCloned.text(); - const gatewayError = get3scaleError(data); + const gatewayError = get3scaleError(data, authRef.current.signinRedirect); if (gatewayError) { chromeStore.set(gatewayErrorAtom, gatewayError); } diff --git a/src/utils/responseInterceptors.ts b/src/utils/responseInterceptors.ts index 534cf1dcf..22c7f8c87 100644 --- a/src/utils/responseInterceptors.ts +++ b/src/utils/responseInterceptors.ts @@ -1,9 +1,24 @@ -export type ThreeScaleError = { complianceError?: boolean; status: number; source?: string; detail: string; meta?: { response_by: string } }; +// eslint-disable-next-line no-restricted-imports +import { AuthContextProps } from 'react-oidc-context'; + +export type ThreeScaleError = { + data?: string; + complianceError?: boolean; + status: number; + source?: string; + detail: string; + meta?: { response_by: string }; +}; export const COMPLIACE_ERROR_CODES = ['ERROR_OFAC', 'ERROR_T5', 'ERROR_EXPORT_CONTROL']; const errorCodeRegexp = new RegExp(`(${COMPLIACE_ERROR_CODES.join('|')})`); -export function get3scaleError(response: string | { errors: ThreeScaleError[] }) { +export function get3scaleError(response: string | { errors: ThreeScaleError[] }, signIn?: AuthContextProps['signinRedirect']) { + if (signIn && typeof response !== 'string' && isTokenExpiredError(response)) { + signIn(); + return; + } + // attempt to parse XHR response let parsedResponse: ThreeScaleError[]; try { @@ -40,3 +55,9 @@ export function get3scaleError(response: string | { errors: ThreeScaleError[] }) function isComplianceError(response = '') { return !!response.match(errorCodeRegexp); } + +const TOKEN_EXPIRED_MATCHER = `Invalid JWT token - 'exp' claim expired at`; + +function isTokenExpiredError(error?: { errors?: ThreeScaleError[] }) { + return error?.errors?.find(({ status, detail }) => status === 401 && detail?.includes(TOKEN_EXPIRED_MATCHER)); +} diff --git a/src/utils/useOuiaTags.ts b/src/utils/useOuiaTags.ts index ddf77018a..a12835737 100644 --- a/src/utils/useOuiaTags.ts +++ b/src/utils/useOuiaTags.ts @@ -3,6 +3,8 @@ import { shallowEqual, useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { ReduxState } from '../redux/store'; import { isAnsible } from '../hooks/useBundle'; +import { activeAppAtom } from '../state/atoms/activeAppAtom'; +import { useAtomValue } from 'jotai'; export type OuiaTags = { landing?: 'true' | 'false'; @@ -19,10 +21,12 @@ const useOuiaTags = () => { 'data-ouia-safe': 'true', }); const { pathname } = useLocation(); - const { pageAction, pageObjectId, activeApp } = useSelector( - ({ chrome: { pageAction, pageObjectId, activeApp } }: ReduxState) => ({ pageAction, pageObjectId, activeApp }), + const { pageAction, pageObjectId } = useSelector( + ({ chrome: { pageAction, pageObjectId } }: ReduxState) => ({ pageAction, pageObjectId }), shallowEqual ); + const activeApp = useAtomValue(activeAppAtom); + useEffect(() => { setState(() => { const result: OuiaTags = {