Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(container): added an invitation modal to accept contract #14041

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
5 changes: 5 additions & 0 deletions packages/components/ovh-shell/src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ export default function exposeApi(shellClient: ShellClient) {
plugin: 'ux',
method: 'showPreloader',
}),
notifyModalActionDone: () =>
shellClient.invokePluginMethod<void>({
plugin: 'ux',
method: 'notifyModalActionDone',
}),
},
navigation: clientNavigation(shellClient),
tracking: exposeTrackingAPI(shellClient),
Expand Down
15 changes: 15 additions & 0 deletions packages/components/ovh-shell/src/plugin/ux/index.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we not do a separated PR for the common changes ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They will disappear once their dedicated PR will be merged (#14025)

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface IUXPlugin {
toggleNotificationsSidebarVisibility(): void;
toggleAccountSidebarVisibility(): void;
getUserIdCookie(): string;
registerModalActionDoneListener(callback: CallableFunction): void;
notifyModalActionDone(): void;
}

// TODO: remove this once we have a more generic Plugin class
Expand All @@ -34,6 +36,8 @@ export class UXPlugin implements IUXPlugin {

private sidebarMenuUpdateItemLabelListener?: CallableFunction;

private onModalActionDone?: CallableFunction;

constructor(shell: Shell) {
this.shell = shell;

Expand Down Expand Up @@ -253,4 +257,15 @@ export class UXPlugin implements IUXPlugin {
requestClientSidebarOpen() {
this.shell.emitEvent('ux:client-sidebar-open');
}

/* ----------- Modal action methods -----------*/
registerModalActionDoneListener(callback: CallableFunction) {
this.onModalActionDone = callback;
}

notifyModalActionDone() {
if (this.onModalActionDone) {
this.onModalActionDone();
}
}
}
11 changes: 11 additions & 0 deletions packages/manager/apps/container/src/api/agreements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { fetchIcebergV6, FilterComparator } from "@ovh-ux/manager-core-api";

const fetchAgreementsUpdates = async () => {
const { data } = await fetchIcebergV6({
route: '/me/agreements',
filters: [{ key: 'agreed', comparator: FilterComparator.IsIn, value: ['todo', 'ko'] }],
});
return data;
};

export default fetchAgreementsUpdates;
23 changes: 23 additions & 0 deletions packages/manager/apps/container/src/api/authorizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { fetchIcebergV2 } from "@ovh-ux/manager-core-api";

type IamResource = {
id: string;
urn: string;
name: string;
displayName: string;
type: string;
owner: string;
};

export const fetchAccountUrn = async (): Promise<string> => {
const { data } = await fetchIcebergV2<IamResource>({
route: '/iam/resource?resourceType=account',
});
/*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a note to say why it s commented

const { data } = await v2.get(
'/iam/resource?resourceType=account',
{ adapter: 'fetch',fetchOptions: { priority: 'low' } },
);
*/
return data[0]?.urn;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React, { useContext, useEffect } from 'react';
import useAgreementsUpdate from '@/hooks/agreements/useAgreementsUpdate';
import { ODS_THEME_COLOR_HUE, ODS_THEME_COLOR_INTENT, ODS_THEME_TYPOGRAPHY_SIZE } from '@ovhcloud/ods-common-theming';
import { OsdsButton, OsdsModal, OsdsText } from '@ovhcloud/ods-components/react';
import { ODS_BUTTON_SIZE, ODS_BUTTON_VARIANT, ODS_TEXT_LEVEL } from '@ovhcloud/ods-components';
import { useTranslation } from 'react-i18next';
import ApplicationContext from '@/context';
import ovhCloudLogo from '@/assets/images/logo-ovhcloud.png';
import { useAuthorizationIam } from '@ovh-ux/manager-react-components/src/hooks/iam';
import useAccountUrn from '@/hooks/accountUrn/useAccountUrn';
import { ModalTypes } from '@/context/modals/modals.context';
import { useModals } from '@/context/modals';

export default function AgreementsUpdateModal () {
const { shell } = useContext(ApplicationContext);
const region: string = shell
.getPlugin('environment')
.getEnvironment()
.getRegion();
const navigation = shell.getPlugin('navigation');
const { current } = useModals();
const myContractsLink = navigation.getURL(
'dedicated',
'#/billing/autoRenew/agreements',
);
const { t } = useTranslation('agreements-update-modal');
const { data: urn } = useAccountUrn({ enabled: region !== 'US' && current === ModalTypes.agreements && window.location.href !== myContractsLink });
const { isAuthorized: canUserAcceptAgreements } = useAuthorizationIam(['account:apiovh:me/agreements/accept'], urn);
const { data: agreements } = useAgreementsUpdate({ enabled: canUserAcceptAgreements });
const goToContractPage = () => {
navigation.navigateTo('dedicated', `#/billing/autoRenew/agreements`);
};

useEffect(() => {
if (canUserAcceptAgreements && !agreements?.length && current === ModalTypes.agreements) {
shell.getPlugin('ux').notifyModalActionDone();
}
}, [canUserAcceptAgreements, agreements, current]);

return agreements?.length ? (
<>
<OsdsModal
dismissible={false}
className="text-center"
color={ODS_THEME_COLOR_INTENT.info}
data-testid="agreements-update-modal"
>
<div className="w-full flex justify-center items-center mb-6">
<img
src={ovhCloudLogo} alt="ovh-cloud-logo"
height={40}
/>
</div>
<OsdsText
level={ODS_TEXT_LEVEL.heading}
color={ODS_THEME_COLOR_INTENT.primary}
size={ODS_THEME_TYPOGRAPHY_SIZE._400}
hue={ODS_THEME_COLOR_HUE._800}
>
{t('agreements_update_modal_title')}
</OsdsText>
<OsdsText
level={ODS_TEXT_LEVEL.body}
color={ODS_THEME_COLOR_INTENT.primary}
size={ODS_THEME_TYPOGRAPHY_SIZE._400}
hue={ODS_THEME_COLOR_HUE._800}
>
<p
className="mt-6"
dangerouslySetInnerHTML={{
__html: t('agreements_update_modal_description', {
link: myContractsLink,
}),
}}
></p>
</OsdsText>

<OsdsButton
onClick={goToContractPage}
slot="actions"
color={ODS_THEME_COLOR_INTENT.primary}
variant={ODS_BUTTON_VARIANT.flat}
size={ODS_BUTTON_SIZE.sm}
>
{t('agreements_update_modal_action')}
</OsdsButton>
</OsdsModal>
</>
) : null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import { vi } from 'vitest';
import {
ShellContext,
ShellContextType,
} from '@ovh-ux/manager-react-shell-client';
import AgreementsUpdateModal from '@/components/AgreementsUpdateModal/AgreementsUpdateModal.component';

const mocks = vi.hoisted(() => ({
isAuthorized: false,
region: 'US',
agreements: [],
}));

const shellContext = {
shell: {
getPlugin: (plugin: string) => {
if (plugin === 'navigation') {
return {
getURL: vi.fn(
() =>
new Promise((resolve) => {
setTimeout(() => resolve('http://fakelink.com'), 50);
}),
),
};
}
return {
getEnvironment: () => ({
getRegion: vi.fn(() => mocks.region),
})
};
},
}
};

const queryClient = new QueryClient();
const renderComponent = () => {
return render(
<QueryClientProvider client={queryClient}>
<ShellContext.Provider
value={(shellContext as unknown) as ShellContextType}
>
<AgreementsUpdateModal />
</ShellContext.Provider>
</QueryClientProvider>,
);
};

vi.mock('react', async (importOriginal) => {
const module = await importOriginal<typeof import('react')>();
return {
...module,
useContext: () => shellContext
}
});

vi.mock('@/hooks/accountUrn/useAccountUrn', () => ({
default: () => () => 'urn'
}));

vi.mock('@ovh-ux/manager-react-components/src/hooks/iam', () => ({
useAuthorizationIam: () => () => ({ isAuthorized: mocks.isAuthorized })
}));

vi.mock('@/hooks/agreements/useAgreementsUpdate', () => ({
default: () => ({ data: mocks.agreements })
}));

describe('AgreementsUpdateModal', () => {
it('should display nothing for US customers', () => {
const { queryByTestId } = renderComponent();
expect(queryByTestId('agreements-update-modal')).not.toBeInTheDocument();
});
it('should display nothing for non US and non authorized customers', () => {
mocks.region = 'EU';
const { queryByTestId } = renderComponent();
expect(queryByTestId('agreements-update-modal')).not.toBeInTheDocument();
});
it('should display a modal for non US and authorized customers without new contract', () => {
mocks.isAuthorized = true;
const { queryByTestId } = renderComponent();
expect(queryByTestId('agreements-update-modal')).not.toBeInTheDocument();
});
it('should display a modal for non US and authorized customers', () => {
mocks.agreements.push({ agreed: false, id: 9999, contractId: 9999 });
const { getByTestId } = renderComponent();
expect(getByTestId('agreements-update-modal')).not.toBeNull();
});
})
26 changes: 16 additions & 10 deletions packages/manager/apps/container/src/container/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import SSOAuthModal from '@/sso-auth-modal/SSOAuthModal';
import PaymentModal from '@/payment-modal/PaymentModal';
import LiveChat from '@/components/LiveChat';
import { IdentityDocumentsModal } from '@/identity-documents-modal/IdentityDocumentsModal';
import AgreementsUpdateModal from '@/components/AgreementsUpdateModal/AgreementsUpdateModal.component';
import useModals from '@/context/modals/useModals';
import { ModalsProvider } from '@/context/modals';

export default function Container(): JSX.Element {
const {
Expand Down Expand Up @@ -81,16 +84,19 @@ export default function Container(): JSX.Element {
<Suspense fallback="">
<SSOAuthModal />
</Suspense>
{isCookiePolicyApplied &&
<Suspense fallback="">
<PaymentModal />
</Suspense>
}
{isCookiePolicyApplied &&
<Suspense fallback="">
<IdentityDocumentsModal />
</Suspense>
}
{isCookiePolicyApplied && (
<ModalsProvider>
<Suspense fallback="">
<AgreementsUpdateModal />
</Suspense>
<Suspense fallback="">
<PaymentModal />
</Suspense>
<Suspense fallback="">
<IdentityDocumentsModal />
</Suspense>
</ModalsProvider>
)}
<Suspense fallback="...">
<CookiePolicy shell={shell} onValidate={cookiePolicyHandler} />
</Suspense>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { useEffect, useState } from 'react';

import ModalsContext, { ModalsContextType, ModalTypes } from './modals.context';

import { useShell } from '@/context';

type Props = {
children: JSX.Element | JSX.Element[];
};

export const ModalsProvider = ({ children = null }: Props): JSX.Element => {
const shell = useShell();
const uxPlugin = shell.getPlugin('ux');
const [current, setCurrent] = useState<ModalTypes>(ModalTypes.kyc);

useEffect(() => {
uxPlugin.registerModalActionDoneListener(() => {
setCurrent((previous) => {
if (previous === null) {
return null;
}
return (previous < ModalTypes.agreements) ? (previous + 1 as ModalTypes) : null;
});
});
}, []);

const modalsContext: ModalsContextType = {
current,
};

return (
<ModalsContext.Provider value={modalsContext}>
{children}
</ModalsContext.Provider>
);
};

export default ModalsProvider;
3 changes: 3 additions & 0 deletions packages/manager/apps/container/src/context/modals/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './ModalsProvider';

export { default as useModals } from './useModals';
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext } from 'react';

export enum ModalTypes {
kyc,
payment,
agreements,
}

export type ModalsContextType = {
current: ModalTypes;
};

const ModalsContext = createContext<ModalsContextType>({} as ModalsContextType);

export default ModalsContext;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useContext } from 'react';
import ModalsContext from '@/context/modals/modals.context';

const useModals = () => useContext(ModalsContext);

export default useModals;
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Shell } from '@ovh-ux/shell';
import { useCookies } from 'react-cookie';
import { useTranslation } from 'react-i18next';
import { User } from '@ovh-ux/manager-config';
import ovhCloudLogo from './assets/logo-ovhcloud.png';
import ovhCloudLogo from '../assets/images/logo-ovhcloud.png';
import links from './links';
import { useApplication } from '@/context';
import { Subtitle, Links, LinksProps } from '@ovh-ux/manager-react-components';
Expand Down Expand Up @@ -64,7 +64,7 @@ const CookiePolicy = ({ shell, onValidate }: Props): JSX.Element => {
setCookies('MANAGER_TRACKING', agreed ? 1 : 0);
trackingPlugin.onUserConsentFromModal(agreed);
setShow(false);
onValidate(true);
onValidate();
}

useEffect(() => {
Expand Down
Loading
Loading