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

Merged
merged 22 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4795865
feat(container): added an invitation modal to accept contract
Oct 25, 2024
4b43203
feat(container): add agreements update modal tests
Oct 25, 2024
b5b2333
feat(container): added aggreements modal and reviewed modals display
Nov 8, 2024
697fbb2
feat(container): added aggreements modal and reviewed modals display
Nov 12, 2024
87404d3
feat(container): added aggreements modal and reviewed modals display
Nov 13, 2024
5d980d4
feat(container): remove unnecessary comment
Nov 14, 2024
4f5fe6d
feat(container): fixed typo in agreements update modal redirection url
Nov 15, 2024
a002e34
feat(container): fixed agreements update modal display management
Nov 15, 2024
4a216a2
feat(container): fixed agreements update modal tests
Nov 15, 2024
236c855
feat(container): fixed kyc modal management
Nov 18, 2024
62a2c44
feat(container): fixed agreements and kyc india modals workflows
Nov 27, 2024
ad73f5c
fix(i18n): add missing translations [CDS 3592]
Jan 2, 2025
ee682fc
feat(billing): backport the new code to billing app
Jan 2, 2025
6d0f61c
fix(container): update variable names and dependencies
Jan 3, 2025
1da363a
feat(container): enhanced readability by splitting code and comments
Jan 6, 2025
4deee7f
feat(container): adjusted condition to handle agreement modal lifecycle
Jan 8, 2025
8f12791
feat(container): handle redirection for dedicated and new-billing
Jan 9, 2025
49000d3
feat(container): adjusted agreements modal lifecycle management
Jan 9, 2025
b2d6293
feat(container): adjusted mocks for agreements modal tests
Jan 9, 2025
4a3efbf
feat(account): backport account code from dedicated
Jan 10, 2025
1e082ad
feat(container): fixed agreements page links
Jan 10, 2025
d6acbae
feat(container): fixed agreement redirection url
Jan 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/manager/apps/container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@emotion/styled": "^11.10.0",
"@ovh-ux/manager-config": "^8.0.2",
"@ovh-ux/manager-core-api": "^0.9.0",
"@ovh-ux/manager-react-components": "^1.41.1",
"@ovh-ux/manager-react-components": "^1.43.0",
"@ovh-ux/manager-react-shell-client": "^0.8.5",
"@ovh-ux/manager-vite-config": "^0.8.3",
"@ovh-ux/ovh-payment-method": "^0.5.1",
Expand Down
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 fetchPendingAgreements = async () => {
const { data } = await fetchIcebergV6({
route: '/me/agreements',
filters: [{ key: 'agreed', comparator: FilterComparator.IsIn, value: ['todo', 'ko'] }],
});
return data;
};

export default fetchPendingAgreements;
18 changes: 18 additions & 0 deletions packages/manager/apps/container/src/api/authorizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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',
});

return data[0]?.urn;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import {
ODS_THEME_COLOR_HUE,
ODS_THEME_COLOR_INTENT,
ODS_THEME_TYPOGRAPHY_SIZE,
} from '@ovhcloud/ods-common-theming';
import {
OsdsButton,
OsdsLink,
OsdsModal,
OsdsText,
} from '@ovhcloud/ods-components/react';
import { OdsHTMLAnchorElementTarget } from '@ovhcloud/ods-common-core';
import {
ODS_BUTTON_SIZE,
ODS_BUTTON_VARIANT,
ODS_TEXT_LEVEL,
} from '@ovhcloud/ods-components';
import { useAuthorizationIam } from '@ovh-ux/manager-react-components';
import usePendingAgreements from '@/hooks/agreements/usePendingAgreements';
import ApplicationContext from '@/context';
import ovhCloudLogo from '@/assets/images/logo-ovhcloud.png';
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 environment = shell
.getPlugin('environment')
.getEnvironment();
const region: string = environment.getRegion();
const navigation = shell.getPlugin('navigation');
const { current } = useModals();
// TODO: simplify this once new-billing is fully open to the public
const isNewBillingAvailable = Boolean(environment.getApplicationURL('new-billing'));
const billingAppName = isNewBillingAvailable ? 'new-billing' : 'dedicated';
const billingAppPath = `#${isNewBillingAvailable ? '' : 'billing/'}/autorenew/agreements`;
const myContractsLink = navigation.getURL(billingAppName, billingAppPath);
const [ showModal, setShowModal ] = useState(false);
const isCurrentModalActive = useMemo(() => current === ModalTypes.agreements, [current]);
const isOnAgreementsPage = useMemo(() => window.location.href === myContractsLink, [window.location.href]);
const isFeatureAvailable = useMemo(() => region !== 'US', [region]);
const { t } = useTranslation('agreements-update-modal');
const { data: urn } = useAccountUrn({ enabled: isCurrentModalActive && !isOnAgreementsPage && isFeatureAvailable });
const { isAuthorized: canUserAcceptAgreements, isLoading: isAuthorizationLoading } = useAuthorizationIam(['account:apiovh:me/agreements/accept'], urn);
const { data: agreements, isLoading: areAgreementsLoading } = usePendingAgreements({ enabled: canUserAcceptAgreements });
const goToContractPage = () => {
setShowModal(false);
navigation.navigateTo(billingAppName, billingAppPath);
};

/*
Since we don't want to display multiple modals at the same time we "watch" the `current` modal, and once it is
the agreements modal turn, we will try to display it (if conditions are met) or switch to the next one otherwise.
As a result, only once the agreements modal is the current one will we manage the modal lifecycle.
Lifecycle management:
- If user is on the agreements page, we will not display the modal and let the page notify for modal change
once the user accept non-validated agreements or leave the page
- Wait until all necessary data (IAM authorization, non-validated agreements list) are loaded
- Once we have the data, check if they allow the display of the modal (IAM authorized + at least one non-validated
agreement), if the conditions are met, we show the modal, otherwise we switch to the next one
*/
useEffect(() => {
if (!isCurrentModalActive) return;

if (isFeatureAvailable) {
const hasFullyLoaded =
urn &&
!isAuthorizationLoading &&
(!canUserAcceptAgreements || !areAgreementsLoading);
if (isOnAgreementsPage || !hasFullyLoaded) return;

if (!agreements?.length) {
shell.getPlugin('ux').notifyModalActionDone();
} else {
setShowModal(true);
}
} else {
shell.getPlugin('ux').notifyModalActionDone();
}
return;
}, [
isCurrentModalActive,
isFeatureAvailable,
isOnAgreementsPage,
isAuthorizationLoading,
canUserAcceptAgreements,
areAgreementsLoading,
agreements,
]);

return showModal ? (
<>
<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">
<Trans
i18nKey="agreements_update_modal_description"
t={t}
components={{
anchor: (
<OsdsLink
href={myContractsLink}
target={OdsHTMLAnchorElementTarget._top}
onClick={() => setShowModal(false)}
></OsdsLink>
),
}}
/>
</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,100 @@
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';
import { ModalTypes } from '@/context/modals/modals.context';

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

const shellContext = {
shell: {
getPlugin: (plugin: string) => {
switch (plugin) {
case 'navigation': return {
getURL: vi.fn(
() =>
new Promise((resolve) => {
setTimeout(() => resolve('http://fakelink.com'), 50);
}),
),
};
case 'ux': return {
notifyModalActionDone: vi.fn(),
};
case 'environment': return {
getEnvironment: () => ({
getRegion: vi.fn(() => mocks.region),
getApplicationURL: vi.fn((app) => `https://ovh.com/manager/${app}`)
})
};
}
},
}
};

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: () => ({ data: 'urn' }),
}));

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

vi.mock('@/context/modals', () => ({
useModals: () => ({ current: ModalTypes.agreements })
}));

vi.mock('@/hooks/agreements/usePendingAgreements', () => ({
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 nothing 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);
Copy link
Contributor

Choose a reason for hiding this comment

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

A custom hook should imho check if the hook is used inside the provider and otherwise throw an error. Sth like:

import { useContext } from 'react';
import ModalsContext from '@/context/modals/modals.context';

const useModals = () => {
  const context = useContext(ModalsContext);
  if (!context) throw new Error('useModals must be inside ModalsProvider');
  return context;
}
export default useModals;


export default useModals;
Loading
Loading