diff --git a/packages/manager-react-components/src/components/index.ts b/packages/manager-react-components/src/components/index.ts index 259a91e17804..fc3e9b1c165f 100644 --- a/packages/manager-react-components/src/components/index.ts +++ b/packages/manager-react-components/src/components/index.ts @@ -28,3 +28,4 @@ export * from './ManagerText/ManagerText'; export * from './pci-maintenance-banner'; export * from './region/region.component'; +export * from './order/Order.component'; diff --git a/packages/manager-react-components/src/components/order/Order.component.spec.tsx b/packages/manager-react-components/src/components/order/Order.component.spec.tsx new file mode 100644 index 000000000000..8ab15f39dbb6 --- /dev/null +++ b/packages/manager-react-components/src/components/order/Order.component.spec.tsx @@ -0,0 +1,131 @@ +import React, { fireEvent } from '@testing-library/react'; +import { vi, vitest } from 'vitest'; +import { Order } from './Order.component'; +import { render } from '../../utils/test.provider'; + +describe(' tests suite', () => { + // Mock global window.open + vi.stubGlobal('open', vi.fn()); + + const onCancelSpy = vi.fn(); + const onValidateSpy = vi.fn(); + const onFinishSpy = vi.fn(); + const onClickLinkSpy = vi.fn(); + const orderLink = 'https://order-link'; + + afterEach(() => { + vitest.resetAllMocks(); + }); + + const renderComponent = ( + isValid: boolean, + link: string, + productName: string, + ) => + render( + + +

Order steps

+
+ +
, + ); + + it.each([{ valid: true }, { valid: false }])( + 'when order configuration validity is $valid confirm button disabled attribute should be $valid', + ({ valid }) => { + const { getByTestId, getByText } = renderComponent(valid, null, null); + + expect(getByText('Order steps')).toBeVisible(); + + const orderButton = getByTestId('cta-order-configuration-order'); + expect(orderButton).toHaveAttribute('label', 'Commander'); + expect(orderButton).toHaveAttribute('is-disabled', `${!valid}`); + }, + ); + + it('confirm button should be enabled and clickable when order configuration is valid', () => { + const { getByTestId } = renderComponent(true, null, null); + + fireEvent.click(getByTestId('cta-order-configuration-order')); + + expect(onValidateSpy).toHaveBeenCalled(); + }); + + it('should cancel order configuration when cancel button is clicked', () => { + const { getByTestId } = renderComponent(false, null, null); + + fireEvent.click(getByTestId('cta-order-configuration-cancel')); + + expect(onCancelSpy).toHaveBeenCalled(); + }); + + it('should open order link and display order summary when order configuration is confirmed ', () => { + vi.spyOn(window, 'open'); + const { getByTestId, queryByText } = renderComponent(true, orderLink, null); + + fireEvent.click(getByTestId('cta-order-configuration-order')); + + // order configuration is hidden + expect(queryByText('Order steps')).not.toBeInTheDocument(); + + expect(getByTestId('order-summary-title')).toBeVisible(); + expect(getByTestId('order-summary-link')).toBeVisible(); + + expect(window.open).toHaveBeenCalledTimes(1); + expect(window.open).toHaveBeenCalledWith( + orderLink, + '_blank', + 'noopener,noreferrer', + ); + }); + + it('should open order link when order link is clicked', () => { + vi.spyOn(window, 'open'); + const { getByTestId } = renderComponent(true, orderLink, null); + + fireEvent.click(getByTestId('cta-order-configuration-order')); + fireEvent.click(getByTestId('order-summary-link')); + + expect(onClickLinkSpy).toHaveBeenCalled(); + }); + + it('should close order summary when finish button is clicked', () => { + vi.spyOn(window, 'open'); + const { getByTestId } = renderComponent(true, orderLink, null); + + fireEvent.click(getByTestId('cta-order-configuration-order')); + fireEvent.click(getByTestId('cta-order-summary-finish')); + + expect(onFinishSpy).toHaveBeenCalled(); + expect(getByTestId('cta-order-configuration-order')).toBeVisible(); + }); + + it.each([{ productName: '' }, { productName: 'OVHcloud product' }])( + 'should display given product name with value $serviceName', + ({ productName }) => { + const link = 'https://order-link'; + vi.spyOn(window, 'open'); + const { getByTestId, getByText } = renderComponent( + true, + link, + productName, + ); + + fireEvent.click(getByTestId('cta-order-configuration-order')); + fireEvent.click(getByTestId('order-summary-link')); + + const product = productName || 'service'; + expect(getByText(`Commande de votre ${product} initiée`)).toBeVisible(); + }, + ); +}); diff --git a/packages/manager-react-components/src/components/order/Order.component.tsx b/packages/manager-react-components/src/components/order/Order.component.tsx new file mode 100644 index 000000000000..f189306ff338 --- /dev/null +++ b/packages/manager-react-components/src/components/order/Order.component.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { OrderContextProvider } from './Order.context'; +import { OrderConfiguration } from './OrderConfiguration.component'; +import { OrderSummary } from './OrderSummary.component'; +import './translations'; + +export const Order = ({ children }: { children: JSX.Element[] }) => { + return {children}; +}; + +Order.Configuration = OrderConfiguration; +Order.Summary = OrderSummary; diff --git a/packages/manager-react-components/src/components/order/Order.context.tsx b/packages/manager-react-components/src/components/order/Order.context.tsx new file mode 100644 index 000000000000..85b5fb3d6e0d --- /dev/null +++ b/packages/manager-react-components/src/components/order/Order.context.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useContext, useState } from 'react'; + +export type TOrderContext = { + setIsOrderInitialized: (isOrderInitialized: boolean) => void; + isOrderInitialized: boolean; +}; + +const OrderContext = createContext({} as TOrderContext); + +export const OrderContextProvider: React.FC = ({ + children, +}) => { + const [isOrderInitialized, setIsOrderInitialized] = useState(false); + + const context = { + isOrderInitialized, + setIsOrderInitialized, + } as TOrderContext; + + return ( + {children} + ); +}; + +export const useOrderContext = (): TOrderContext => { + const context = useContext(OrderContext); + if (context === undefined) { + throw new Error('Order-related components must be used within '); + } + return context; +}; diff --git a/packages/manager-react-components/src/components/order/Order.stories.tsx b/packages/manager-react-components/src/components/order/Order.stories.tsx new file mode 100644 index 000000000000..7325812a3b7f --- /dev/null +++ b/packages/manager-react-components/src/components/order/Order.stories.tsx @@ -0,0 +1,57 @@ +import React, { ComponentType } from 'react'; + +import { OdsText } from '@ovhcloud/ods-components/react'; +import { Meta, StoryObj } from '@storybook/react'; +import { Order } from './Order.component'; + +function renderComponent(args) { + return ( + + +

+ + ...|order configuration steps| ... + +

+
+ +
+ ); +} + +type Story = StoryObj & + StoryObj & + StoryObj; + +export const DemoOrder: Story = { + args: { + isValid: true, + orderLink: 'https://www.ovh.com', + productName: '', + onCancel: () => {}, + onConfirm: () => {}, + onFinish: () => {}, + onClickLink: () => {}, + }, + render: renderComponent, +}; + +const meta: Meta = { + title: 'Components/Order', + component: Order, + subcomponents: { + 'Order.Summary': Order.Summary as ComponentType, + 'Order.Configuration': Order.Configuration as ComponentType, + }, +}; + +export default meta; diff --git a/packages/manager-react-components/src/components/order/OrderConfiguration.component.tsx b/packages/manager-react-components/src/components/order/OrderConfiguration.component.tsx new file mode 100644 index 000000000000..d7e4a03b1cf2 --- /dev/null +++ b/packages/manager-react-components/src/components/order/OrderConfiguration.component.tsx @@ -0,0 +1,61 @@ +import { + ODS_BUTTON_COLOR, + ODS_BUTTON_ICON_ALIGNMENT, + ODS_BUTTON_SIZE, + ODS_BUTTON_VARIANT, + ODS_ICON_NAME, +} from '@ovhcloud/ods-components'; +import { OdsButton } from '@ovhcloud/ods-components/react'; +import React, { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOrderContext } from './Order.context'; + +export type TOrderConfiguration = { + children: ReactNode; + onCancel: () => void; + onConfirm: () => void; + isValid: boolean; +}; + +export const OrderConfiguration: React.FC = ({ + children, + onCancel, + onConfirm, + isValid, +}: TOrderConfiguration): JSX.Element => { + const { isOrderInitialized, setIsOrderInitialized } = useOrderContext(); + const { t } = useTranslation('order'); + + if (isOrderInitialized) { + return <>; + } + + return ( + <> + {children} +
+ + { + onConfirm(); + setIsOrderInitialized(true); + }} + icon={ODS_ICON_NAME.externalLink} + iconAlignment={ODS_BUTTON_ICON_ALIGNMENT.left} + label={t('order_configuration_order')} + data-testid="cta-order-configuration-order" + /> +
+ + ); +}; diff --git a/packages/manager-react-components/src/components/order/OrderSummary.component.tsx b/packages/manager-react-components/src/components/order/OrderSummary.component.tsx new file mode 100644 index 000000000000..5e4b020aa083 --- /dev/null +++ b/packages/manager-react-components/src/components/order/OrderSummary.component.tsx @@ -0,0 +1,85 @@ +import { + ODS_BUTTON_COLOR, + ODS_BUTTON_SIZE, + ODS_TEXT_PRESET, +} from '@ovhcloud/ods-components'; +import { OdsButton, OdsText } from '@ovhcloud/ods-components/react'; +import { Trans, useTranslation } from 'react-i18next'; +import React, { useEffect } from 'react'; +import { Links, LinkType } from '../typography'; +import { useOrderContext } from './Order.context'; + +export type TOrderSummary = { + onFinish: () => void; + onClickLink?: () => void; + orderLink: string; + productName?: string; +}; + +export const OrderSummary: React.FC = ({ + onFinish, + onClickLink, + orderLink, + productName, +}: TOrderSummary): JSX.Element => { + const { t } = useTranslation('order'); + const { isOrderInitialized, setIsOrderInitialized } = useOrderContext(); + + useEffect(() => { + if (orderLink && isOrderInitialized) { + window.open(orderLink, '_blank', 'noopener,noreferrer'); + } + }, [orderLink, isOrderInitialized]); + + if (!isOrderInitialized) { + return <>; + } + + // set default label if no product name provided + const product = productName || t('order_summary_product_default_label'); + + return ( + <> +
+
+ + {t('order_summary_order_initiated_title', { product })} + + + + ), + }} + > + + + {t('order_summary_order_initiated_info', { product })} + +
+ { + onFinish(); + setIsOrderInitialized(false); + }} + label={t('order_summary_finish')} + /> +
+ + ); +}; diff --git a/packages/manager-react-components/src/components/order/translations/Messages_fr_FR.json b/packages/manager-react-components/src/components/order/translations/Messages_fr_FR.json new file mode 100644 index 000000000000..bcd5d287da15 --- /dev/null +++ b/packages/manager-react-components/src/components/order/translations/Messages_fr_FR.json @@ -0,0 +1,9 @@ +{ + "order_configuration_cancel": "Annuler", + "order_configuration_order": "Commander", + "order_summary_finish": "Terminer", + "order_summary_product_default_label": "service", + "order_summary_order_initiated_title": "Commande de votre {{product}} initiée", + "order_summary_order_initiated_subtitle": "Si vous n'avez pas pu finaliser votre commande, merci de la compléter en cliquant sur le ", + "order_summary_order_initiated_info": "Nous vous informerons de la disponibilité de votre {{product}} par e-mail." +} diff --git a/packages/manager-react-components/src/components/order/translations/index.ts b/packages/manager-react-components/src/components/order/translations/index.ts new file mode 100644 index 000000000000..00f65f725ba7 --- /dev/null +++ b/packages/manager-react-components/src/components/order/translations/index.ts @@ -0,0 +1,27 @@ +import i18next from 'i18next'; + +// import de_DE from './Messages_de_DE.json'; +// import en_GB from './Messages_en_GB.json'; +// import es_ES from './Messages_es_ES.json'; +// import fr_CA from './Messages_fr_CA.json'; +import fr_FR from './Messages_fr_FR.json'; +// import it_IT from './Messages_it_IT.json'; +// import pl_PL from './Messages_pl_PL.json'; +// import pt_PT from './Messages_pt_PT.json'; + +function addTranslations() { + // i18next.addResources('de_DE', 'order', de_DE); + // i18next.addResources('en_GB', 'order', en_GB); + // i18next.addResources('es_ES', 'order', es_ES); + // i18next.addResources('fr_CA', 'order', fr_CA); + i18next.addResources('fr_FR', 'order', fr_FR); + // i18next.addResources('it_IT', 'order', it_IT); + // i18next.addResources('pl_PL', 'order', pl_PL); + // i18next.addResources('pt_PT', 'order', pt_PT); +} + +if (i18next.isInitialized) { + addTranslations(); +} else { + i18next.on('initialized', addTranslations); +}