diff --git a/web/packages/shared/utils/assertUnreachable.ts b/web/packages/shared/utils/assertUnreachable.ts new file mode 100644 index 0000000000000..4d91e495874f1 --- /dev/null +++ b/web/packages/shared/utils/assertUnreachable.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function assertUnreachable(x: never): never { + throw new Error(`Unhandled case: ${x}`); +} diff --git a/web/packages/teleport/src/Navigation/Navigation.tsx b/web/packages/teleport/src/Navigation/Navigation.tsx index a979dff351262..2c77ed0f60906 100644 --- a/web/packages/teleport/src/Navigation/Navigation.tsx +++ b/web/packages/teleport/src/Navigation/Navigation.tsx @@ -17,20 +17,17 @@ limitations under the License. import React, { useCallback, useEffect, useState } from 'react'; import styled, { useTheme } from 'styled-components'; import { matchPath, useHistory, useLocation } from 'react-router'; - import { Image } from 'design'; import { NavigationSwitcher } from 'teleport/Navigation/NavigationSwitcher'; import cfg from 'teleport/config'; - import { NAVIGATION_CATEGORIES, NavigationCategory, } from 'teleport/Navigation/categories'; - import { useFeatures } from 'teleport/FeaturesContext'; - import { NavigationCategoryContainer } from 'teleport/Navigation/NavigationCategoryContainer'; +import { NotificationKind } from 'teleport/stores/storeNotifications'; import { useTeleport } from '..'; @@ -176,7 +173,15 @@ export function Navigation({ )} diff --git a/web/packages/teleport/src/Navigation/NavigationItem.test.tsx b/web/packages/teleport/src/Navigation/NavigationItem.test.tsx index 1c97c35efc140..31be645607358 100644 --- a/web/packages/teleport/src/Navigation/NavigationItem.test.tsx +++ b/web/packages/teleport/src/Navigation/NavigationItem.test.tsx @@ -20,7 +20,7 @@ import { render, screen } from 'design/utils/testing'; import { generatePath, Router } from 'react-router'; -import { createMemoryHistory } from 'history'; +import { createMemoryHistory, MemoryHistory } from 'history'; import TeleportContextProvider from 'teleport/TeleportContextProvider'; import TeleportContext from 'teleport/teleportContext'; @@ -30,8 +30,9 @@ import { NavigationCategory } from 'teleport/Navigation/categories'; import { NavigationItem } from 'teleport/Navigation/NavigationItem'; import { NavigationItemSize } from 'teleport/Navigation/common'; import { makeUserContext } from 'teleport/services/user'; +import { NotificationKind } from 'teleport/stores/storeNotifications'; -class MockFeature implements TeleportFeature { +class MockUserFeature implements TeleportFeature { category = NavigationCategory.Resources; route = { @@ -55,32 +56,50 @@ class MockFeature implements TeleportFeature { }; } +class MockAccessListFeature implements TeleportFeature { + category = NavigationCategory.Resources; + + route = { + title: 'Users', + path: '/web/cluster/:clusterId/feature', + exact: true, + component: () =>
Test!
, + }; + + hasAccess() { + return true; + } + + navigationItem = { + title: NavTitle.AccessLists, + icon:
, + exact: true, + getLink(clusterId: string) { + return generatePath('/web/cluster/:clusterId/feature', { clusterId }); + }, + }; +} + describe('navigation items', () => { - it('should render the feature link correctly', () => { - const history = createMemoryHistory({ + let ctx: TeleportContext; + let history: MemoryHistory; + + beforeEach(() => { + history = createMemoryHistory({ initialEntries: ['/web/cluster/root/feature'], }); - const ctx = new TeleportContext(); + ctx = new TeleportContext(); ctx.storeUser.state = makeUserContext({ cluster: { name: 'test-cluster', lastConnected: Date.now(), }, }); + }); - render( - - - - - - ); + it('should render the feature link correctly', () => { + render(getNavigationItem({ ctx, history })); expect(screen.getByText('Users').closest('a')).toHaveAttribute( 'href', @@ -89,30 +108,7 @@ describe('navigation items', () => { }); it('should change the feature link to the leaf cluster when navigating to a leaf cluster', () => { - const history = createMemoryHistory({ - initialEntries: ['/web/cluster/root/feature'], - }); - - const ctx = new TeleportContext(); - ctx.storeUser.state = makeUserContext({ - cluster: { - name: 'test-cluster', - lastConnected: Date.now(), - }, - }); - - render( - - - - - - ); + render(getNavigationItem({ ctx, history })); expect(screen.getByText('Users').closest('a')).toHaveAttribute( 'href', @@ -126,4 +122,56 @@ describe('navigation items', () => { '/web/cluster/leaf/feature' ); }); + + it('rendeirng of attention dot for access list', () => { + const { rerender } = render( + getNavigationItem({ ctx, history, feature: new MockAccessListFeature() }) + ); + + expect( + screen.queryByTestId('nav-item-attention-dot') + ).not.toBeInTheDocument(); + + // Add in some notifications + ctx.storeNotifications.setNotifications([ + { + item: { + kind: NotificationKind.AccessList, + resourceName: 'banana', + route: '', + }, + id: 'abc', + date: new Date(), + }, + ]); + + rerender( + getNavigationItem({ ctx, history, feature: new MockAccessListFeature() }) + ); + + expect(screen.getByTestId('nav-item-attention-dot')).toBeInTheDocument(); + }); }); + +function getNavigationItem({ + ctx, + history, + feature = new MockUserFeature(), +}: { + ctx: TeleportContext; + history: MemoryHistory; + feature?: TeleportFeature; +}) { + return ( + + + + + + ); +} diff --git a/web/packages/teleport/src/Navigation/NavigationItem.tsx b/web/packages/teleport/src/Navigation/NavigationItem.tsx index 7b96af039d85a..7022d58216a6b 100644 --- a/web/packages/teleport/src/Navigation/NavigationItem.tsx +++ b/web/packages/teleport/src/Navigation/NavigationItem.tsx @@ -28,14 +28,11 @@ import { LinkContent, NavigationItemSize, } from 'teleport/Navigation/common'; - import useStickyClusterId from 'teleport/useStickyClusterId'; - import localStorage from 'teleport/services/localStorage'; - import { useTeleport } from 'teleport'; - import { NavTitle, RecommendationStatus } from 'teleport/types'; +import { NotificationKind } from 'teleport/stores/storeNotifications'; import type { TeleportFeature, @@ -170,6 +167,18 @@ export function NavigationItem(props: NavigationItemProps) { // renderHighlightFeature returns red dot component if the feature recommendation state is 'NOTIFY' function renderHighlightFeature(featureName: NavTitle): JSX.Element { + if (featureName === NavTitle.AccessLists) { + const hasNotifications = ctx.storeNotifications.hasNotificationsByKind( + NotificationKind.AccessList + ); + + if (hasNotifications) { + return ; + } + + return null; + } + // Get onboarding status. We'll only recommend features once user completes // initial onboarding (i.e. connect resources to Teleport cluster). const onboard = localStorage.getOnboardDiscover(); @@ -183,7 +192,7 @@ export function NavigationItem(props: NavigationItemProps) { featureName === NavTitle.TrustedDevices && recommendFeatureStatus?.TrustedDevices === RecommendationStatus.Notify ) { - return ; + return ; } return null; } @@ -264,7 +273,9 @@ export function NavigationItem(props: NavigationItemProps) { ); } -const RedDot = styled.div` +const AttentionDot = styled.div.attrs(() => ({ + 'data-testid': 'nav-item-attention-dot', +}))` margin-left: 15px; margin-top: 2px; width: 7px; diff --git a/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx b/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx new file mode 100644 index 0000000000000..0d66caf2a1f5a --- /dev/null +++ b/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx @@ -0,0 +1,79 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import Flex from 'design/Flex'; + +import { NavigationSwitcher } from './NavigationSwitcher'; +import { NavigationCategory } from './categories'; + +export default { + title: 'Teleport/Navigation', +}; + +const navItems = [ + { category: NavigationCategory.Management }, + { category: NavigationCategory.Resources }, +]; + +export function SwitcherResource() { + return ( + null} + items={navItems} + value={NavigationCategory.Resources} + /> + ); +} + +export function SwitcherManagement() { + return ( + null} + items={navItems} + value={NavigationCategory.Management} + /> + ); +} + +export function SwitcherRequiresManagementAttention() { + return ( + + null} + items={[ + { category: NavigationCategory.Resources }, + { category: NavigationCategory.Management, requiresAttention: true }, + ]} + value={NavigationCategory.Resources} + /> + + ); +} + +export function SwitcherRequiresResourcesAttention() { + return ( + + null} + items={[ + { category: NavigationCategory.Resources, requiresAttention: true }, + { category: NavigationCategory.Management }, + ]} + value={NavigationCategory.Management} + /> + + ); +} diff --git a/web/packages/teleport/src/Navigation/NavigationSwitcher.test.tsx b/web/packages/teleport/src/Navigation/NavigationSwitcher.test.tsx new file mode 100644 index 0000000000000..9cb3b3e1043bd --- /dev/null +++ b/web/packages/teleport/src/Navigation/NavigationSwitcher.test.tsx @@ -0,0 +1,91 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, screen, userEvent } from 'design/utils/testing'; + +import { NavigationSwitcher } from './NavigationSwitcher'; +import { NavigationCategory } from './categories'; + +test('not requiring attention', async () => { + render( + null} + items={[ + { category: NavigationCategory.Management }, + { category: NavigationCategory.Resources }, + ]} + value={NavigationCategory.Resources} + /> + ); + + expect( + screen.queryByTestId('nav-switch-attention-dot') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeInTheDocument(); + + // Test clicking + await userEvent.click(screen.getByTestId('nav-switch-button')); + expect( + screen.queryByTestId('nav-switch-attention-dot') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeInTheDocument(); +}); + +test('requires attention: not at nav category target (management)', async () => { + render( + null} + items={[ + { category: NavigationCategory.Management, requiresAttention: true }, + { category: NavigationCategory.Resources }, + ]} + value={NavigationCategory.Resources} + /> + ); + + expect(screen.getByTestId('nav-switch-attention-dot')).toBeInTheDocument(); + expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeVisible(); + + // Test clicking + await userEvent.click(screen.getByTestId('nav-switch-button')); + expect(screen.getByTestId('nav-switch-attention-dot')).toBeInTheDocument(); + expect(screen.getByTestId('dd-item-attention-dot')).toBeVisible(); +}); + +test('requires attention: being at the nav category target (management) should NOT render attention dot', async () => { + render( + null} + items={[ + { category: NavigationCategory.Management, requiresAttention: true }, + { category: NavigationCategory.Resources }, + ]} + value={NavigationCategory.Management} + /> + ); + + expect( + screen.queryByTestId('nav-switch-attention-dot') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeInTheDocument(); + + // Test clicking + await userEvent.click(screen.getByTestId('nav-switch-button')); + expect( + screen.queryByTestId('nav-switch-attention-dot') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeInTheDocument(); +}); diff --git a/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx b/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx index 86783ca0cf96b..24bb2d7b29e4d 100644 --- a/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx +++ b/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx @@ -21,10 +21,15 @@ import { ChevronDownIcon } from 'design/SVGIcon/ChevronDown'; import { NavigationCategory } from 'teleport/Navigation/categories'; +type NavigationItems = { + category: NavigationCategory; + requiresAttention?: boolean; +}; + interface NavigationSwitcherProps { onChange: (value: NavigationCategory) => void; value: NavigationCategory; - items: NavigationCategory[]; + items: NavigationItems[]; } interface OpenProps { @@ -117,7 +122,10 @@ export function NavigationSwitcher(props: NavigationSwitcherProps) { const activeValueRef = useRef(); const firstValueRef = useRef(); - const activeItem = props.items.find(item => item === props.value); + const activeItem = props.items.find(item => item.category === props.value); + const requiresAttentionButNotActive = props.items.some( + item => item.requiresAttention && item.category !== activeItem.category + ); const handleClickOutside = useCallback( (event: MouseEvent) => { @@ -219,28 +227,35 @@ export function NavigationSwitcher(props: NavigationSwitcherProps) { items.push( handleKeyDownLink(event, item)} + onKeyDown={event => handleKeyDownLink(event, item.category)} tabIndex={open ? 0 : -1} - onClick={() => handleChange(item)} + onClick={() => handleChange(item.category)} key={index} open={open} - active={item === props.value} + active={item.category === props.value} > - {item} + {item.category} + {item.requiresAttention && item.category !== activeItem.category && ( + + )} ); } return ( + {requiresAttentionButNotActive && ( + + )} setOpen(!open)} open={open} tabIndex={0} onKeyDown={handleKeyDown} + data-testid="nav-switch-button" > - {activeItem} + {activeItem.category} @@ -251,3 +266,24 @@ export function NavigationSwitcher(props: NavigationSwitcherProps) { ); } + +const NavSwitcherAttentionDot = styled.div` + position: absolute; + background-color: ${props => props.theme.colors.error.main}; + width: 10px; + height: 10px; + border-radius: 50%; + right: -3px; + top: -4px; + z-index: 100; +`; + +const DropDownItemAttentionDot = styled.div` + display: inline-block; + margin-left: 10px; + margin-top: 2px; + width: 7px; + height: 7px; + border-radius: 50%; + background-color: ${props => props.theme.colors.error.main}; +`; diff --git a/web/packages/teleport/src/TopBar/ClusterSelector/ClusterSelector.tsx b/web/packages/teleport/src/TopBar/ClusterSelector/ClusterSelector.tsx index e813671b77034..edddfe6f7e7e7 100644 --- a/web/packages/teleport/src/TopBar/ClusterSelector/ClusterSelector.tsx +++ b/web/packages/teleport/src/TopBar/ClusterSelector/ClusterSelector.tsx @@ -82,7 +82,11 @@ export default function ClusterSelector({ } return ( - + ({ open, setOpen }); + + let transitionDelay = STARTING_TRANSITION_DELAY; + const items = notices.map(notice => { + const currentTransitionDelay = transitionDelay; + transitionDelay += INCREMENT_TRANSITION_DELAY; + + return ( + + setOpen(false)} /> + + ); + }); + + return ( + + setOpen(!open)} + data-testid="tb-note-button" + > + {items.length > 0 && } + + + + + {items.length ? ( + items + ) : ( + + No notifications + + )} + + + ); +} + +function NotificationItem({ + notice, + close, +}: { + notice: Notification; + close(): void; +}) { + switch (notice.item.kind) { + case NotificationKind.AccessList: + return ( + + + + + + + Access list {notice.item.resourceName} needs your review + within {formatDistanceToNow(notice.date)}. + + + + ); + default: + assertUnreachable(notice.item.kind); + } +} + +const NotificationButtonContainer = styled.div` + position: relative; +`; + +const AttentionDot = styled.div` + position: absolute; + width: 7px; + height: 7px; + border-radius: 100px; + background-color: ${p => p.theme.colors.buttons.warning.default}; + top: 10px; + right: 15px; +`; + +const NotificationItemButton = styled(DropdownItemButton)` + align-items: flex-start; + line-height: 20px; +`; + +const NotificationLink = styled(DropdownItemLink)` + padding: 0; +`; diff --git a/web/packages/teleport/src/TopBar/Notifications/index.ts b/web/packages/teleport/src/TopBar/Notifications/index.ts new file mode 100644 index 0000000000000..0d5bf6f3eb944 --- /dev/null +++ b/web/packages/teleport/src/TopBar/Notifications/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Notifications } from './Notifications'; diff --git a/web/packages/teleport/src/TopBar/Shared.tsx b/web/packages/teleport/src/TopBar/Shared.tsx new file mode 100644 index 0000000000000..59fe7d617738a --- /dev/null +++ b/web/packages/teleport/src/TopBar/Shared.tsx @@ -0,0 +1,33 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import styled from 'styled-components'; + +export const ButtonIconContainer = styled.div` + padding: 0 10px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + cursor: pointer; + user-select: none; + margin-right: 5px; + + &:hover { + background: ${props => props.theme.colors.spotBackground[0]}; + } +`; diff --git a/web/packages/teleport/src/TopBar/TopBar.story.tsx b/web/packages/teleport/src/TopBar/TopBar.story.tsx index 85b3673cc1ead..10244365e470c 100644 --- a/web/packages/teleport/src/TopBar/TopBar.story.tsx +++ b/web/packages/teleport/src/TopBar/TopBar.story.tsx @@ -24,6 +24,7 @@ import TeleportContext from 'teleport/teleportContext'; import { makeUserContext } from 'teleport/services/user'; import TeleportContextProvider from 'teleport/TeleportContextProvider'; import { LayoutContextProvider } from 'teleport/Main/LayoutContext'; +import { NotificationKind } from 'teleport/stores/storeNotifications'; import { TopBar } from './TopBar'; @@ -55,5 +56,59 @@ export function Story() { ); } - Story.storyName = 'TopBar'; + +export function TopBarWithNotifications() { + const ctx = new TeleportContext(); + + ctx.storeUser.state = makeUserContext({ + userName: 'admin', + cluster: { + name: 'test-cluster', + lastConnected: Date.now(), + }, + }); + ctx.storeNotifications.state = { + notifications: [ + { + item: { + kind: NotificationKind.AccessList, + resourceName: 'banana', + route: '', + }, + id: '111', + date: new Date(), + }, + { + item: { + kind: NotificationKind.AccessList, + resourceName: 'apple', + route: '', + }, + id: '222', + date: new Date(), + }, + { + item: { + kind: NotificationKind.AccessList, + resourceName: 'carrot', + route: '', + }, + id: '333', + date: new Date(), + }, + ], + }; + + return ( + + + + + + + + + + ); +} diff --git a/web/packages/teleport/src/TopBar/TopBar.test.tsx b/web/packages/teleport/src/TopBar/TopBar.test.tsx index 41a145f506ff6..12ce0141dbf80 100644 --- a/web/packages/teleport/src/TopBar/TopBar.test.tsx +++ b/web/packages/teleport/src/TopBar/TopBar.test.tsx @@ -15,8 +15,7 @@ */ import React from 'react'; -import { render, screen } from 'design/utils/testing'; - +import { render, screen, userEvent } from 'design/utils/testing'; import { Router } from 'react-router'; import { createMemoryHistory } from 'history'; @@ -27,17 +26,16 @@ import { getOSSFeatures } from 'teleport/features'; import TeleportContext, { disabledFeatureFlags, } from 'teleport/teleportContext'; - import { makeUserContext } from 'teleport/services/user'; - import { mockUserContextProviderWith } from 'teleport/User/testHelpers/mockUserContextWith'; import { makeTestUserContext } from 'teleport/User/testHelpers/makeTestUserContext'; +import { NotificationKind } from 'teleport/stores/storeNotifications'; import { clusters } from 'teleport/Clusters/fixtures'; import { TopBar } from './TopBar'; -let ctx; +let ctx: TeleportContext; function setup(): void { ctx = new TeleportContext(); @@ -57,56 +55,82 @@ function setup(): void { mockUserContextProviderWith(makeTestUserContext()); } -test('does not show assist popup if hidePopup is true', () => { +test('does not show assist popup if hidePopup is true', async () => { setup(); - render( - - - - - - - - - - ); + render(getTopBar({ hidePopup: true })); + await screen.findByTestId('cluster-selector'); expect(screen.queryByTestId('assistPopup')).not.toBeInTheDocument(); }); -test('shows assist popup if hidePopup is absent', () => { +test('shows assist popup if hidePopup is absent', async () => { setup(); - render( - - - - - - - - - - ); + render(getTopBar({})); + await screen.findByTestId('cluster-selector'); + + expect(screen.getByTestId('assistPopup')).toBeInTheDocument(); +}); + +test('shows assist popup if hidePopup is false', async () => { + setup(); + + render(getTopBar({ hidePopup: false })); + await screen.findByTestId('cluster-selector'); expect(screen.getByTestId('assistPopup')).toBeInTheDocument(); }); -test('shows assist popup if hidePopup is false', () => { +test('notification bell without notification', async () => { setup(); - render( + render(getTopBar({})); + await screen.findByTestId('cluster-selector'); + + expect(screen.getByTestId('tb-note')).toBeInTheDocument(); + expect(screen.queryByTestId('tb-note-attention')).not.toBeInTheDocument(); +}); + +test('notification bell with notification', async () => { + setup(); + ctx.storeNotifications.state = { + notifications: [ + { + item: { + kind: NotificationKind.AccessList, + resourceName: 'banana', + route: '', + }, + id: 'abc', + date: new Date(), + }, + ], + }; + + render(getTopBar({})); + await screen.findByTestId('cluster-selector'); + + expect(screen.getByTestId('tb-note')).toBeInTheDocument(); + expect(screen.getByTestId('tb-note-attention')).toBeInTheDocument(); + + // Test clicking and rendering of dropdown. + expect(screen.getByTestId('tb-note-dropdown')).not.toBeVisible(); + + await userEvent.click(screen.getByTestId('tb-note-button')); + expect(screen.getByTestId('tb-note-dropdown')).toBeVisible(); +}); + +const getTopBar = ({ hidePopup = null }: { hidePopup?: boolean }) => { + return ( - + ); - - expect(screen.getByTestId('assistPopup')).toBeInTheDocument(); -}); +}; diff --git a/web/packages/teleport/src/TopBar/TopBar.tsx b/web/packages/teleport/src/TopBar/TopBar.tsx index edd0d3a9288be..743602e806baf 100644 --- a/web/packages/teleport/src/TopBar/TopBar.tsx +++ b/web/packages/teleport/src/TopBar/TopBar.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { Suspense, useState } from 'react'; +import React, { Suspense, useState, lazy } from 'react'; import styled, { useTheme } from 'styled-components'; import { Flex, Text, TopNav } from 'design'; @@ -46,24 +46,10 @@ import { } from 'teleport/Assist/Popup/Popup'; import ClusterSelector from './ClusterSelector'; +import { Notifications } from './Notifications'; +import { ButtonIconContainer } from './Shared'; -const Assist = React.lazy(() => import('teleport/Assist')); - -const AssistButton = styled.div` - padding: 0 10px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 5px; - cursor: pointer; - user-select: none; - margin-right: 5px; - - &:hover { - background: ${props => props.theme.colors.spotBackground[0]}; - } -`; +const Assist = lazy(() => import('teleport/Assist')); const AssistButtonContainer = styled.div` position: relative; @@ -189,9 +175,9 @@ export function TopBar({ hidePopup = false }: TopBarProps) { {!hasDockedElement && assistEnabled && ( - setShowAssist(true)}> + setShowAssist(true)}> - + {showAssistPopup && !hidePopup && ( <> @@ -217,6 +203,7 @@ export function TopBar({ hidePopup = false }: TopBarProps) { )} )} + diff --git a/web/packages/teleport/src/components/Dropdown/Dropdown.tsx b/web/packages/teleport/src/components/Dropdown/Dropdown.tsx index 1350b42eb8472..e4d701a3b5bf6 100644 --- a/web/packages/teleport/src/components/Dropdown/Dropdown.tsx +++ b/web/packages/teleport/src/components/Dropdown/Dropdown.tsx @@ -67,17 +67,11 @@ export const DropdownItem = styled.div` `; export const commonDropdownItemStyles = css` - opacity: 0.8; align-items: center; display: flex; padding: ${p => p.theme.space[1] * 3}px; color: ${props => props.theme.colors.text.main}; text-decoration: none; - transition: opacity 0.15s ease-in; - - &:hover { - opacity: 1; - } svg { height: 18px; diff --git a/web/packages/teleport/src/stores/index.ts b/web/packages/teleport/src/stores/index.ts index 0be09b9c0c712..f3c4288599038 100644 --- a/web/packages/teleport/src/stores/index.ts +++ b/web/packages/teleport/src/stores/index.ts @@ -16,4 +16,6 @@ import StoreNav, { defaultNavState } from './storeNav'; import StoreUserContext from './storeUserContext'; -export { StoreNav, StoreUserContext, defaultNavState }; +import { StoreNotifications } from './storeNotifications'; + +export { StoreNav, StoreUserContext, StoreNotifications, defaultNavState }; diff --git a/web/packages/teleport/src/stores/storeNotifications.test.ts b/web/packages/teleport/src/stores/storeNotifications.test.ts new file mode 100644 index 0000000000000..1a870efb2a9eb --- /dev/null +++ b/web/packages/teleport/src/stores/storeNotifications.test.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Notification, + NotificationKind, + StoreNotifications, +} from './storeNotifications'; + +test('get/set/update notifications', async () => { + const store = new StoreNotifications(); + + expect(store.getNotifications()).toStrictEqual([]); + expect(store.hasNotificationsByKind(NotificationKind.AccessList)).toBeFalsy(); + + // set some notifications, sorted by earliest date. + const newerNote: Notification = { + item: { + kind: NotificationKind.AccessList, + resourceName: 'apple', + route: '', + }, + id: '111', + date: new Date('2023-10-04T09:09:22-07:00'), + }; + const olderNote: Notification = { + item: { + kind: NotificationKind.AccessList, + resourceName: 'banana', + route: '', + }, + id: '222', + date: new Date('2023-10-01T09:09:22-07:00'), + }; + + store.setNotifications([newerNote, olderNote]); + expect(store.getNotifications()).toStrictEqual([olderNote, newerNote]); + + // Update notes, sorted by earliest date. + const newestNote: Notification = { + item: { + kind: NotificationKind.AccessList, + resourceName: 'carrot', + route: '', + }, + id: '333', + date: new Date('2023-11-23T09:09:22-07:00'), + }; + const newestOlderNote: Notification = { + item: { + kind: NotificationKind.AccessList, + resourceName: 'carrot', + route: '', + }, + id: '444', + date: new Date('2023-10-03T09:09:22-07:00'), + }; + const newestOldestNote: Notification = { + item: { + kind: NotificationKind.AccessList, + resourceName: 'carrot', + route: '', + }, + id: '444', + date: new Date('2023-10-01T09:09:22-07:00'), + }; + store.setNotifications([newestNote, newestOldestNote, newestOlderNote]); + expect(store.getNotifications()).toStrictEqual([ + newestOldestNote, + newestOlderNote, + newestNote, + ]); + + // Test has notifications + expect( + store.hasNotificationsByKind(NotificationKind.AccessList) + ).toBeTruthy(); +}); diff --git a/web/packages/teleport/src/stores/storeNotifications.ts b/web/packages/teleport/src/stores/storeNotifications.ts new file mode 100644 index 0000000000000..4d2f9169ba905 --- /dev/null +++ b/web/packages/teleport/src/stores/storeNotifications.ts @@ -0,0 +1,86 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Store } from 'shared/libs/stores'; +import { assertUnreachable } from 'shared/utils/assertUnreachable'; + +export enum NotificationKind { + AccessList = 'access-list', +} + +type AccessListNotification = { + kind: NotificationKind.AccessList; + resourceName: string; + route: string; +}; + +export type Notification = { + item: AccessListNotification; + id: string; + date: Date; +}; + +// TODO?: based on a feedback, consider representing +// notifications as a collection of maps indexed by id +// which is then converted to a sorted list as needed +// (may be easier to work with) +export type NotificationState = { + notifications: Notification[]; +}; + +const defaultNotificationState: NotificationState = { + notifications: [], +}; + +export class StoreNotifications extends Store { + state: NotificationState = defaultNotificationState; + + getNotifications() { + return this.state.notifications; + } + + setNotifications(notices: Notification[]) { + // Sort by earliest dates. + const sortedNotices = notices.sort((a, b) => { + return a.date.getTime() - b.date.getTime(); + }); + this.setState({ notifications: [...sortedNotices] }); + } + + updateNotificationsByKind(notices: Notification[], kind: NotificationKind) { + switch (kind) { + case NotificationKind.AccessList: + const filtered = this.state.notifications.filter( + n => n.item.kind !== NotificationKind.AccessList + ); + this.setNotifications([...filtered, ...notices]); + return; + default: + assertUnreachable(kind); + } + } + + hasNotificationsByKind(kind: NotificationKind) { + switch (kind) { + case NotificationKind.AccessList: + return this.getNotifications().some( + n => n.item.kind === NotificationKind.AccessList + ); + default: + assertUnreachable(kind); + } + } +} diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index e7bcf83c836fe..babefbe3329de 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -16,7 +16,7 @@ limitations under the License. import cfg from 'teleport/config'; -import { StoreNav, StoreUserContext } from './stores'; +import { StoreNav, StoreUserContext, StoreNotifications } from './stores'; import * as types from './types'; import AuditService from './services/audit'; import RecordingsService from './services/recordings'; @@ -39,6 +39,7 @@ class TeleportContext implements types.Context { // stores storeNav = new StoreNav(); storeUser = new StoreUserContext(); + storeNotifications = new StoreNotifications(); // services auditService = new AuditService(); diff --git a/web/packages/teleterm/src/ui/utils/assertUnreachable.ts b/web/packages/teleterm/src/ui/utils/assertUnreachable.ts index 0b9aed7fc3b88..0eb634554301e 100644 --- a/web/packages/teleterm/src/ui/utils/assertUnreachable.ts +++ b/web/packages/teleterm/src/ui/utils/assertUnreachable.ts @@ -14,6 +14,9 @@ * limitations under the License. */ -export function assertUnreachable(x: never): never { - throw new Error(`Unhandled case: ${x}`); -} +import { assertUnreachable } from 'shared/utils/assertUnreachable'; + +/** + * @deprecated Import assertUnreachable from `shared/utils/assertUnreachable` instead. + */ +export { assertUnreachable };