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 = {