diff --git a/packages/manager/apps/hub/src/data/api/notifications.ts b/packages/manager/apps/hub/src/data/api/notifications.ts new file mode 100644 index 000000000000..6b5d45b1570f --- /dev/null +++ b/packages/manager/apps/hub/src/data/api/notifications.ts @@ -0,0 +1,16 @@ +import { aapi } from '@ovh-ux/manager-core-api'; +import { ApiEnvelope } from '@/types/apiEnvelope.type'; +import { Notification, NotificationsList } from '@/types/notifications.type'; + +const hubNotificationStatuses = ['warning', 'error']; + +export const getNotifications: () => Promise = async () => { + const { data } = await aapi.get>( + `/hub/notifications`, + ); + return ( + data.data?.notifications.data || [] + ).filter((notification: Notification) => + hubNotificationStatuses.includes(notification.level), + ); +}; diff --git a/packages/manager/apps/hub/src/data/hooks/notifications/useNotifications.spec.tsx b/packages/manager/apps/hub/src/data/hooks/notifications/useNotifications.spec.tsx new file mode 100644 index 000000000000..82a41d227aaa --- /dev/null +++ b/packages/manager/apps/hub/src/data/hooks/notifications/useNotifications.spec.tsx @@ -0,0 +1,42 @@ +import { PropsWithChildren } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, it, vi } from 'vitest'; +import { aapi as Api } from '@ovh-ux/manager-core-api'; +import { useFetchHubNotifications } from '@/data/hooks/notifications/useNotifications'; +import { ApiEnvelope } from '@/types/apiEnvelope.type'; +import { NotificationsList } from '@/types/notifications.type'; + +const queryClient = new QueryClient(); + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('useFetchHubNotifications', () => { + it('should return notifications after extracting them from api envelope', async () => { + const notifications: ApiEnvelope = { + data: { + notifications: { + data: [], + status: 'OK', + }, + }, + status: 'OK', + }; + const getNotifications = vi + .spyOn(Api, 'get') + .mockReturnValue(Promise.resolve(notifications)); + + const { result } = renderHook(() => useFetchHubNotifications(), { + wrapper, + }); + + await waitFor(() => { + expect(getNotifications).toHaveBeenCalled(); + expect(result.current.data).toEqual( + notifications.data.notifications.data, + ); + }); + }); +}); diff --git a/packages/manager/apps/hub/src/data/hooks/notifications/useNotifications.tsx b/packages/manager/apps/hub/src/data/hooks/notifications/useNotifications.tsx new file mode 100644 index 000000000000..affa4f412449 --- /dev/null +++ b/packages/manager/apps/hub/src/data/hooks/notifications/useNotifications.tsx @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { Notification } from '@/types/notifications.type'; +import { getNotifications } from '@/data/api/notifications'; + +export const useFetchHubNotifications = () => + useQuery({ + queryKey: ['getHubNotifications'], + queryFn: getNotifications, + retry: 0, + refetchOnWindowFocus: false, + }); diff --git a/packages/manager/apps/hub/src/pages/layout.test.tsx b/packages/manager/apps/hub/src/pages/layout.test.tsx new file mode 100644 index 000000000000..385521f1738b --- /dev/null +++ b/packages/manager/apps/hub/src/pages/layout.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as reactShellClientModule from '@ovh-ux/manager-react-shell-client'; +import { + ShellContext, + ShellContextType, +} from '@ovh-ux/manager-react-shell-client'; +import Layout from '@/pages/layout'; + +const shellContext = { + environment: { + getUser: vi.fn(), + }, + shell: { + ux: { + hidePreloader: vi.fn(), + }, + }, +}; + +const renderComponent = () => + render( + + + , + ); + +const mockPath = '/foo'; + +vi.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: mockPath, + }), +})); + +vi.mock('@ovh-ux/manager-react-shell-client', async (importOriginal) => { + const original: typeof reactShellClientModule = await importOriginal(); + return { + ...original, + useOvhTracking: vi.fn(() => ({ + trackPage: vi.fn(), + trackClick: vi.fn(), + trackCurrentPage: vi.fn(), + usePageTracking: vi.fn(), + })), + useRouteSynchro: vi.fn(() => {}), + }; +}); + +describe('Form.page', () => { + it('should render select LegalForms correctly when the sub is FR and legalForms is other', async () => { + const { getByText } = renderComponent(); + + expect(getByText('Layout')).not.toBeNull(); + }); +}); diff --git a/packages/manager/apps/hub/src/pages/layout.tsx b/packages/manager/apps/hub/src/pages/layout.tsx new file mode 100644 index 000000000000..2255576cefc8 --- /dev/null +++ b/packages/manager/apps/hub/src/pages/layout.tsx @@ -0,0 +1,29 @@ +import React, { useEffect, useContext } from 'react'; +import { defineCurrentPage } from '@ovh-ux/request-tagger'; +import { useLocation } from 'react-router-dom'; +import { + useOvhTracking, + useRouteSynchro, + ShellContext, +} from '@ovh-ux/manager-react-shell-client'; + +export default function Layout() { + const location = useLocation(); + const { shell } = useContext(ShellContext); + const { trackCurrentPage } = useOvhTracking(); + useRouteSynchro(); + + useEffect(() => { + defineCurrentPage(`app.dashboard`); + }, []); + + useEffect(() => { + trackCurrentPage(); + }, [location]); + + useEffect(() => { + shell.ux.hidePreloader(); + }, []); + + return
Layout
; +} diff --git a/packages/manager/apps/hub/src/pages/layout/NotificationsCarousel.component.tsx b/packages/manager/apps/hub/src/pages/layout/NotificationsCarousel.component.tsx new file mode 100644 index 000000000000..b54eccbc53c5 --- /dev/null +++ b/packages/manager/apps/hub/src/pages/layout/NotificationsCarousel.component.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { + OsdsIcon, + OsdsMessage, + OsdsText, +} from '@ovhcloud/ods-components/react'; +import { + ODS_ICON_NAME, + ODS_ICON_SIZE, + ODS_MESSAGE_TYPE, + ODS_TEXT_COLOR_INTENT, + ODS_TEXT_LEVEL, + ODS_TEXT_SIZE, +} from '@ovhcloud/ods-components'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { useOvhTracking } from '@ovh-ux/manager-react-shell-client'; +import { useFetchHubNotifications } from '@/data/hooks/notifications/useNotifications'; +import { Notification, NotificationType } from '@/types/notifications.type'; + +const getMessageColor = (type: NotificationType) => { + switch (type) { + case NotificationType.Success: + return ODS_TEXT_COLOR_INTENT.success; + case NotificationType.Error: + return ODS_TEXT_COLOR_INTENT.error; + case NotificationType.Warning: + return ODS_TEXT_COLOR_INTENT.warning; + case NotificationType.Info: + return ODS_TEXT_COLOR_INTENT.info; + default: + return ODS_TEXT_COLOR_INTENT.info; + } +}; + +const getMessageType = (type: NotificationType) => { + switch (type) { + case NotificationType.Success: + return ODS_MESSAGE_TYPE.success; + case NotificationType.Error: + return ODS_MESSAGE_TYPE.error; + case NotificationType.Warning: + return ODS_MESSAGE_TYPE.warning; + case NotificationType.Info: + return ODS_MESSAGE_TYPE.info; + default: + return ODS_MESSAGE_TYPE.info; + } +}; + +const getTextColor = (type: NotificationType) => { + switch (type) { + case NotificationType.Success: + return ODS_THEME_COLOR_INTENT.success; + case NotificationType.Error: + return ODS_THEME_COLOR_INTENT.error; + case NotificationType.Warning: + return ODS_THEME_COLOR_INTENT.warning; + case NotificationType.Info: + return ODS_THEME_COLOR_INTENT.info; + default: + return ODS_THEME_COLOR_INTENT.info; + } +}; + +export default function NotificationsCarousel() { + const { trackClick } = useOvhTracking(); + const { data: notifications } = useFetchHubNotifications(); + const [currentIndex, setCurrentIndex] = useState(0); + + const showNextNotification = () => { + setCurrentIndex( + (previousIndex) => (previousIndex + 1) % notifications.length, + ); + trackClick({ + actionType: 'action', + actions: ['hub', 'dashboard', 'alert', 'action'], + }); + }; + + return ( + <> + {notifications?.length > 0 && ( + 1 ? '!pb-8' : ''}`} + role="alert" + color={getMessageColor(notifications[currentIndex].level)} + type={getMessageType(notifications[currentIndex].level)} + data-testid="notifications_carousel" + > + + + + {notifications?.length > 1 && ( + <> + +
+ {notifications.map( + (notification: Notification, index: number) => ( + 0 ? 'ml-2' : '' + }`} + name={ODS_ICON_NAME.SHAPE_DOT} + size={ODS_ICON_SIZE.xxs} + color={getTextColor(notification.level)} + contrasted={currentIndex === index || undefined} + onClick={() => setCurrentIndex((previousIndex) => index)} + /> + ), + )} +
+ + )} +
+ )} + + ); +} diff --git a/packages/manager/apps/hub/src/pages/layout/layout.test.tsx b/packages/manager/apps/hub/src/pages/layout/layout.test.tsx index 218bf2120eea..c3e547347c37 100644 --- a/packages/manager/apps/hub/src/pages/layout/layout.test.tsx +++ b/packages/manager/apps/hub/src/pages/layout/layout.test.tsx @@ -31,6 +31,8 @@ import SiretModal from '@/pages/layout/SiretModal.component'; import KycIndiaBanner from '@/pages/layout/KycIndiaBanner.component'; import KycFraudBanner from '@/pages/layout/KycFraudBanner.component'; import { KycStatus } from '@/types/kyc.type'; +import NotificationsCarousel from '@/pages/layout/NotificationsCarousel.component'; +import { Notification, NotificationType } from '@/types/notifications.type'; const queryClient = new QueryClient(); @@ -255,6 +257,27 @@ vi.mock('@/data/hooks/kyc/useKyc', () => ({ }), })); +vi.mock('@/data/hooks/notifications/useNotifications', () => ({ + useFetchHubNotifications: (): { + data: Notification[]; + isPending: boolean; + } => ({ + data: [ + { + data: {}, + date: '2022-02-08', + description: + 'Fraudulent emails circulate and direct to scam websites claiming to be OVHcloud. Find out more', + level: 'error' as NotificationType, + id: 'GLOBAL_COMMUNICATION_PHISHING', + status: 'acknowledged', + subject: 'General information', + }, + ], + isPending: false, + }), +})); + const useBillingServicesMockValue: any = { data: null, isLoading: true, @@ -296,7 +319,7 @@ describe('Layout.page', () => { expect(welcome).not.toBeNull(); expect(queryByText('Banner')).not.toBeInTheDocument(); - expect(queryByText('ovh-manager-hub-carousel')).not.toBeInTheDocument(); + expect(queryByTestId('notifications_carousel')).not.toBeInTheDocument(); expect(queryByTestId('siret_banner')).not.toBeInTheDocument(); expect(queryByTestId('siret_modal')).not.toBeInTheDocument(); expect(queryByText('Payment Status')).not.toBeInTheDocument(); @@ -344,6 +367,7 @@ describe('Layout.page', () => { queryByText, findByText, findByTestId, + queryByTestId, } = renderComponent(); expect(getByTestId('banner_skeleton')).not.toBeNull(); @@ -353,7 +377,7 @@ describe('Layout.page', () => { expect(welcome).not.toBeNull(); expect(banner).not.toBeNull(); - expect(getByText('ovh-manager-hub-carousel')).not.toBeNull(); + expect(queryByTestId('notifications_carousel')).not.toBeNull(); expect(getByTestId('siret_banner')).not.toBeNull(); expect(getByTestId('siret_modal')).not.toBeNull(); expect(getByTestId('kyc_india_banner')).not.toBeNull(); @@ -946,4 +970,16 @@ describe('Layout.page', () => { expect(queryByTestId('kyc_fraud_banner')).not.toBeInTheDocument(); }); }); + + describe('NotificationsCarousel component', () => { + it('should render a single notification without "navigation"', async () => { + const { getByTestId, queryByTestId } = renderComponent( + , + ); + + expect(getByTestId('notification_content')).not.toBeNull(); + expect(queryByTestId('next-notification-button')).not.toBeInTheDocument(); + expect(queryByTestId('notification-navigation')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/manager/apps/hub/src/pages/layout/layout.tsx b/packages/manager/apps/hub/src/pages/layout/layout.tsx index f4182118c6d5..f2f533b6d7d3 100644 --- a/packages/manager/apps/hub/src/pages/layout/layout.tsx +++ b/packages/manager/apps/hub/src/pages/layout/layout.tsx @@ -58,6 +58,9 @@ const KycIndiaBanner = lazy(() => const KycFraudBanner = lazy(() => import('@/pages/layout/KycFraudBanner.component'), ); +const NotificationsCarousel = lazy(() => + import('@/pages/layout/NotificationsCarousel.component'), +); export default function Layout() { const location = useLocation(); @@ -164,7 +167,16 @@ export default function Layout() { > -
ovh-manager-hub-carousel
+ + } + > + + )} diff --git a/packages/manager/apps/hub/src/types/notifications.type.ts b/packages/manager/apps/hub/src/types/notifications.type.ts new file mode 100644 index 000000000000..7675aa2febd4 --- /dev/null +++ b/packages/manager/apps/hub/src/types/notifications.type.ts @@ -0,0 +1,22 @@ +import { ApiEnvelope } from '@/types/apiEnvelope.type'; + +export enum NotificationType { + Success = 'success', + Error = 'error', + Info = 'info', + Warning = 'warning', +} + +export type Notification = { + data: any; + date: string; + description: string; + id: string; + level: NotificationType; + status: string; + subject: string; +}; + +export type NotificationsList = { + notifications: ApiEnvelope; +};