From 2cec626e679d593d5b208a0807b391ab978e0e9d Mon Sep 17 00:00:00 2001 From: =?utf8?q?Daniel=20Kuty=C5=82a?= <daniel.kutyla@contractors.roche.com> Date: Tue, 20 Dec 2022 14:47:34 +0100 Subject: [PATCH] 18368: Notification banner first implementation MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Arvados-DCO-1.1-Signed-off-by: Daniel KutyÅa <daniel.kutyla@contractors.roche.com> --- src/common/config.ts | 2 + src/store/banner/banner-action.ts | 29 +++++ src/store/banner/banner-reducer.ts | 26 ++++ src/store/store.ts | 2 + src/views-components/baner/banner.test.tsx | 63 ++++++++++ src/views-components/baner/banner.tsx | 111 ++++++++++++++++++ .../main-app-bar/notifications-menu.tsx | 70 ++++++++--- src/views/workbench/workbench.test.tsx | 2 + src/views/workbench/workbench.tsx | 15 +-- 9 files changed, 291 insertions(+), 29 deletions(-) create mode 100644 src/store/banner/banner-action.ts create mode 100644 src/store/banner/banner-reducer.ts create mode 100644 src/views-components/baner/banner.test.tsx create mode 100644 src/views-components/baner/banner.tsx diff --git a/src/common/config.ts b/src/common/config.ts index 574445df..ff44e2ef 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -62,6 +62,7 @@ export interface ClusterConfigJSON { SSHHelpHostSuffix: string; SiteName: string; IdleTimeout: string; + BannerUUID: string; }; Login: { LoginCluster: string; @@ -249,6 +250,7 @@ export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): Clust SSHHelpHostSuffix: "", SiteName: "", IdleTimeout: "0s", + BannerUUID: "", }, Login: { LoginCluster: "", diff --git a/src/store/banner/banner-action.ts b/src/store/banner/banner-action.ts new file mode 100644 index 00000000..808ca822 --- /dev/null +++ b/src/store/banner/banner-action.ts @@ -0,0 +1,29 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from "redux"; +import { RootState } from "store/store"; +import { unionize, UnionOf } from 'common/unionize'; + +export const bannerReducerActions = unionize({ + OPEN_BANNER: {}, + CLOSE_BANNER: {}, +}); + +export type BannerAction = UnionOf<typeof bannerReducerActions>; + +export const openBanner = () => + async (dispatch: Dispatch, getState: () => RootState) => { + dispatch(bannerReducerActions.OPEN_BANNER()); + }; + +export const closeBanner = () => + async (dispatch: Dispatch<any>, getState: () => RootState) => { + dispatch(bannerReducerActions.CLOSE_BANNER()); + }; + +export default { + openBanner, + closeBanner +}; diff --git a/src/store/banner/banner-reducer.ts b/src/store/banner/banner-reducer.ts new file mode 100644 index 00000000..8009f4b2 --- /dev/null +++ b/src/store/banner/banner-reducer.ts @@ -0,0 +1,26 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { BannerAction, bannerReducerActions } from "./banner-action"; + +export interface BannerState { + isOpen: boolean; +} + +const initialState = { + isOpen: false, +}; + +export const bannerReducer = (state: BannerState = initialState, action: BannerAction) => + bannerReducerActions.match(action, { + default: () => state, + OPEN_BANNER: () => ({ + ...state, + isOpen: true, + }), + CLOSE_BANNER: () => ({ + ...state, + isOpen: false, + }), + }); diff --git a/src/store/store.ts b/src/store/store.ts index 94f110a0..899eb1cb 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -73,6 +73,7 @@ import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-pane import { Config } from 'common/config'; import { pluginConfig } from 'plugins'; import { MiddlewareListReducer } from 'common/plugintypes'; +import { bannerReducer } from './banner/banner-reducer'; declare global { interface Window { @@ -187,6 +188,7 @@ export function configureStore(history: History, services: ServiceRepository, co const createRootReducer = (services: ServiceRepository) => combineReducers({ auth: authReducer(services), + banner: bannerReducer, collectionPanel: collectionPanelReducer, collectionPanelFiles: collectionPanelFilesReducer, contextMenu: contextMenuReducer, diff --git a/src/views-components/baner/banner.test.tsx b/src/views-components/baner/banner.test.tsx new file mode 100644 index 00000000..1e820089 --- /dev/null +++ b/src/views-components/baner/banner.test.tsx @@ -0,0 +1,63 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { configure, shallow, mount } from "enzyme"; +import { BannerComponent } from './banner'; +import { Button } from "@material-ui/core"; +import Adapter from "enzyme-adapter-react-16"; +import servicesProvider from '../../common/service-provider'; + +configure({ adapter: new Adapter() }); + +jest.mock('../../common/service-provider', () => ({ + getServices: jest.fn(), +})); + +describe('<BannerComponent />', () => { + + let props; + + beforeEach(() => { + props = { + isOpen: false, + bannerUUID: undefined, + keepWebInlineServiceUrl: '', + openBanner: jest.fn(), + closeBanner: jest.fn(), + classes: {} as any, + } + }); + + it('renders without crashing', () => { + // when + const banner = shallow(<BannerComponent {...props} />); + + // then + expect(banner.find(Button)).toHaveLength(1); + }); + + it('calls collectionService', () => { + // given + props.isOpen = true; + props.bannerUUID = '123'; + const mocks = { + collectionService: { + files: jest.fn(() => ({ then: (callback) => callback([{ name: 'banner.html' }]) })), + getFileContents: jest.fn(() => ({ then: (callback) => callback('<h1>Test</h1>') })) + } + }; + (servicesProvider.getServices as any).mockImplementation(() => mocks); + + // when + const banner = mount(<BannerComponent {...props} />); + + // then + expect(servicesProvider.getServices).toHaveBeenCalled(); + expect(mocks.collectionService.files).toHaveBeenCalled(); + expect(mocks.collectionService.getFileContents).toHaveBeenCalled(); + expect(banner.html()).toContain('<h1>Test</h1>'); + }); +}); + diff --git a/src/views-components/baner/banner.tsx b/src/views-components/baner/banner.tsx new file mode 100644 index 00000000..9fae6381 --- /dev/null +++ b/src/views-components/baner/banner.tsx @@ -0,0 +1,111 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React, { useState, useCallback, useEffect } from 'react'; +import { Dialog, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from "@material-ui/core"; +import { connect } from "react-redux"; +import { RootState } from "store/store"; +import bannerActions from "store/banner/banner-action"; +import { ArvadosTheme } from 'common/custom-theme'; +import servicesProvider from 'common/service-provider'; +import { Dispatch } from 'redux'; + +type CssRules = 'dialogContent' | 'dialogContentIframe'; + +const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({ + dialogContent: { + minWidth: '550px', + minHeight: '500px', + display: 'block' + }, + dialogContentIframe: { + minWidth: '550px', + minHeight: '500px' + } +}); + +interface BannerProps { + isOpen: boolean; + bannerUUID?: string; + keepWebInlineServiceUrl: string; +}; + +type BannerComponentProps = BannerProps & WithStyles<CssRules> & { + openBanner: Function, + closeBanner: Function, +}; + +const mapStateToProps = (state: RootState): BannerProps => ({ + isOpen: state.banner.isOpen, + bannerUUID: state.auth.config.clusterConfig.Workbench.BannerUUID, + keepWebInlineServiceUrl: state.auth.config.keepWebInlineServiceUrl, +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + openBanner: () => dispatch<any>(bannerActions.openBanner()), + closeBanner: () => dispatch<any>(bannerActions.closeBanner()), +}); + +export const BANNER_LOCAL_STORAGE_KEY = 'bannerFileData'; + +export const BannerComponent = (props: BannerComponentProps) => { + const { + isOpen, + openBanner, + closeBanner, + bannerUUID, + keepWebInlineServiceUrl + } = props; + const [bannerContents, setBannerContents] = useState(`<h1>Loading ...</h1>`) + + const onConfirm = useCallback(() => { + closeBanner(); + }, [closeBanner]) + + useEffect(() => { + if (!!bannerUUID && bannerUUID !== "") { + servicesProvider.getServices().collectionService.files(bannerUUID) + .then(results => { + const bannerFileData = results.find(({name}) => name === 'banner.html'); + const result = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY); + + if (result && result === JSON.stringify(bannerFileData) && !isOpen) { + return; + } + + if (bannerFileData) { + servicesProvider.getServices() + .collectionService.getFileContents(bannerFileData) + .then(data => { + setBannerContents(data); + openBanner(); + localStorage.setItem(BANNER_LOCAL_STORAGE_KEY, JSON.stringify(bannerFileData)); + }); + } + }); + } + }, [bannerUUID, keepWebInlineServiceUrl, openBanner, isOpen]); + + return ( + <Dialog open={isOpen}> + <div data-cy='confirmation-dialog'> + <DialogContent className={props.classes.dialogContent}> + <div dangerouslySetInnerHTML={{ __html: bannerContents }}></div> + </DialogContent> + <DialogActions style={{ margin: '0px 24px 24px' }}> + <Button + data-cy='confirmation-dialog-ok-btn' + variant='contained' + color='primary' + type='submit' + onClick={onConfirm}> + Close + </Button> + </DialogActions> + </div> + </Dialog> + ); +} + +export const Banner = withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(BannerComponent)); diff --git a/src/views-components/main-app-bar/notifications-menu.tsx b/src/views-components/main-app-bar/notifications-menu.tsx index e27bdad5..30a5756f 100644 --- a/src/views-components/main-app-bar/notifications-menu.tsx +++ b/src/views-components/main-app-bar/notifications-menu.tsx @@ -3,21 +3,59 @@ // SPDX-License-Identifier: AGPL-3.0 import React from "react"; -import { Badge, MenuItem } from '@material-ui/core'; +import { Dispatch } from "redux"; +import { connect } from "react-redux"; +import { Badge, MenuItem } from "@material-ui/core"; import { DropdownMenu } from "components/dropdown-menu/dropdown-menu"; -import { NotificationIcon } from 'components/icon/icon'; - -export const NotificationsMenu = - () => - <DropdownMenu - icon={ - <Badge - badgeContent={0} - color="primary"> - <NotificationIcon /> - </Badge>} - id="account-menu" - title="Notifications"> - <MenuItem>You are up to date</MenuItem> - </DropdownMenu>; +import { NotificationIcon } from "components/icon/icon"; +import bannerActions from "store/banner/banner-action"; +import { BANNER_LOCAL_STORAGE_KEY } from "views-components/baner/banner"; +import { RootState } from "store/store"; +const mapStateToProps = (state: RootState): NotificationsMenuProps => ({ + isOpen: state.banner.isOpen, + bannerUUID: state.auth.config.clusterConfig.Workbench.BannerUUID, +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + openBanner: () => dispatch<any>(bannerActions.openBanner()), +}); + +type NotificationsMenuProps = { + isOpen: boolean; + bannerUUID?: string; +} + +type NotificationsMenuComponentProps = NotificationsMenuProps & { + openBanner: any; +} + +export const NotificationsMenuComponent = (props: NotificationsMenuComponentProps) => { + const { isOpen, openBanner } = props; + const result = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY); + const menuItems: any[] = []; + + if (!isOpen && result) { + menuItems.push(<MenuItem><span onClick={openBanner}>Restore Banner</span></MenuItem>); + } + + if (menuItems.length === 0) { + menuItems.push(<MenuItem>You are up to date</MenuItem>); + } + + return (<DropdownMenu + icon={ + <Badge + badgeContent={0} + color="primary"> + <NotificationIcon /> + </Badge>} + id="account-menu" + title="Notifications"> + { + menuItems.map(item => item) + } + </DropdownMenu>); +} + +export const NotificationsMenu = connect(mapStateToProps, mapDispatchToProps)(NotificationsMenuComponent); diff --git a/src/views/workbench/workbench.test.tsx b/src/views/workbench/workbench.test.tsx index 471ecc40..fe5dff8a 100644 --- a/src/views/workbench/workbench.test.tsx +++ b/src/views/workbench/workbench.test.tsx @@ -14,6 +14,8 @@ import { CustomTheme } from 'common/custom-theme'; import { createServices } from "services/services"; import 'jest-localstorage-mock'; +jest.mock('views-components/baner/banner', () => ({ Banner: () => 'Banner' })) + const history = createBrowserHistory(); it('renders without crashing', () => { diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index ae8a8f84..b6ce07ae 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'; import { Route, Switch } from "react-router"; import { ProjectPanel } from "views/project-panel/project-panel"; @@ -99,6 +99,7 @@ import { RestoreCollectionVersionDialog } from 'views-components/collections-dia import { WebDavS3InfoDialog } from 'views-components/webdav-s3-dialog/webdav-s3-dialog'; import { pluginConfig } from 'plugins'; import { ElementListReducer } from 'common/plugintypes'; +import { Banner } from 'views-components/baner/banner'; type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content'; @@ -185,18 +186,6 @@ const reduceRoutesFn: (a: React.ReactElement[], routes = React.createElement(React.Fragment, null, pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children))); -const Banner = () => { - const [visible, setVisible] = useState(true); - const hideBanner = useCallback(() => setVisible(false), []); - - return visible ? - <div id="banner" onClick={hideBanner} className="app-banner"> - <span> - This is important message - </span> - </div> : null; -} - export const WorkbenchPanel = withStyles(styles)((props: WorkbenchPanelProps) => <Grid container item xs className={props.classes.root}> -- 2.30.2