From c779e60ac5b8a6e309b8dc7900fc8be4f3602a78 Mon Sep 17 00:00:00 2001 From: Adrien Turmo Date: Wed, 29 Oct 2025 16:28:55 +0100 Subject: [PATCH] fix(pci-workflow): display distant region backup price ref: #TAPC-4894 Signed-off-by: Adrien Turmo --- .../src/api/hooks/{ => order}/order.tsx | 43 +++---- .../order/selector/order.selector.spec.ts | 117 ++++++++++++++++++ .../hooks/order/selector/order.selector.ts | 20 +++ .../pci-workflow/src/pages/new/New.page.tsx | 2 +- .../new/steps/WorkflowName.component.spec.tsx | 4 +- .../new/steps/WorkflowName.component.tsx | 2 +- .../WorkflowScheduling.component.spec.tsx | 37 ++++-- .../steps/WorkflowScheduling.component.tsx | 62 +++++++--- .../apps/pci-workflow/src/utils/index.tsx | 7 ++ .../pci-workflow/src/utils/utils.spec.tsx | 43 +++++++ 10 files changed, 287 insertions(+), 50 deletions(-) rename packages/manager/apps/pci-workflow/src/api/hooks/{ => order}/order.tsx (75%) create mode 100644 packages/manager/apps/pci-workflow/src/api/hooks/order/selector/order.selector.spec.ts create mode 100644 packages/manager/apps/pci-workflow/src/api/hooks/order/selector/order.selector.ts create mode 100644 packages/manager/apps/pci-workflow/src/utils/index.tsx create mode 100644 packages/manager/apps/pci-workflow/src/utils/utils.spec.tsx diff --git a/packages/manager/apps/pci-workflow/src/api/hooks/order.tsx b/packages/manager/apps/pci-workflow/src/api/hooks/order/order.tsx similarity index 75% rename from packages/manager/apps/pci-workflow/src/api/hooks/order.tsx rename to packages/manager/apps/pci-workflow/src/api/hooks/order/order.tsx index 706a0ec32305..44f123a06e5f 100644 --- a/packages/manager/apps/pci-workflow/src/api/hooks/order.tsx +++ b/packages/manager/apps/pci-workflow/src/api/hooks/order/order.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { usePrefetchQuery, useQueries } from '@tanstack/react-query'; @@ -10,12 +10,15 @@ import { import { useIsDistantBackupAvailable } from '@/api/hooks/feature'; import { TInstance } from '@/api/hooks/instance/selector/instances.selector'; +import { getRegionPricing } from '@/api/hooks/order/selector/order.selector'; import { useRegionTranslation } from '@/api/hooks/region'; import { useMe } from '@/api/hooks/user'; import { isSnapshotConsumption } from '@/pages/new/utils/is-snapshot-consumption'; +import { groupBy } from '@/utils'; export type ContinentRegion = Pick & { label: string; + price: number | null; }; export const useInstanceSnapshotPricing = (projectId: string, instanceId: TInstance['id']) => { @@ -32,33 +35,25 @@ export const useInstanceSnapshotPricing = (projectId: string, instanceId: TInsta ], }); - const snapshotPlan = useMemo( - () => - snapshotAvailabilities?.plans.find( - ({ code, regions }) => - isSnapshotConsumption(code) && regions.find((r) => r.name === instanceId.region), - ), - [snapshotAvailabilities, instanceId.region], - ); + const currentRegion = useMemo(() => { + const currentPlan = snapshotAvailabilities?.plans.find( + ({ code, regions }) => + isSnapshotConsumption(code) && regions.find((r) => r.name === instanceId.region), + ); - const catalogAddon = useMemo( - () => catalog?.addons.find(({ planCode }) => planCode === snapshotPlan.code), - [catalog, snapshotPlan], - ); + return currentPlan?.regions.find((r) => r.name === instanceId.region); + }, [snapshotAvailabilities, instanceId]); - const currentRegion = useMemo( - () => snapshotPlan?.regions.find((r) => r.name === instanceId.region), - [snapshotPlan, instanceId], - ); + const regionPriceCalculator = useCallback(getRegionPricing(snapshotAvailabilities, catalog), [ + snapshotAvailabilities, + catalog, + ]); return { isPending: !snapshotAvailabilities || !catalog, pricing: useMemo( - () => - catalogAddon?.pricings.find( - ({ intervalUnit }) => intervalUnit === 'none' || intervalUnit === 'hour', - ) ?? null, - [catalogAddon], + () => regionPriceCalculator(instanceId.region), + [instanceId, regionPriceCalculator], ), distantContinents: useMemo(() => { if ( @@ -70,7 +65,7 @@ export const useInstanceSnapshotPricing = (projectId: string, instanceId: TInsta ) return new Map(); - return Map.groupBy( + return groupBy( snapshotAvailabilities.plans .filter(({ code }) => isSnapshotConsumption(code)) .flatMap((p) => p.regions) @@ -83,6 +78,7 @@ export const useInstanceSnapshotPricing = (projectId: string, instanceId: TInsta .map((r) => ({ ...r, label: translateMicroRegion(r.name) || r.name, + price: regionPriceCalculator(r.name)?.price, })), (r) => translateContinent(r.name) || 'Internal', ); @@ -93,6 +89,7 @@ export const useInstanceSnapshotPricing = (projectId: string, instanceId: TInsta translateMicroRegion, translateContinent, isDistantBackupAvailable, + regionPriceCalculator, ]), }; }; diff --git a/packages/manager/apps/pci-workflow/src/api/hooks/order/selector/order.selector.spec.ts b/packages/manager/apps/pci-workflow/src/api/hooks/order/selector/order.selector.spec.ts new file mode 100644 index 000000000000..2d2c4e10d338 --- /dev/null +++ b/packages/manager/apps/pci-workflow/src/api/hooks/order/selector/order.selector.spec.ts @@ -0,0 +1,117 @@ +import { describe } from 'vitest'; + +import { TCatalog, TProductAvailability } from '@ovh-ux/manager-pci-common'; + +import { getRegionPricing } from '@/api/hooks/order/selector/order.selector'; + +describe('order selector', () => { + describe('getRegionPricing', () => { + it('should get the princing for the snapshot.consumption plan corresponding to the region', () => { + const region = 'currentRegion'; + + const snapshotAvailabilities = { + plans: [ + { + code: 'snapshot.consumption.3AZ', + regions: [{ name: region }], + }, + { + code: 'snapshot.consumption', + regions: [{ name: 'otherRegion' }], + }, + { + code: 'volume.snapshot.consumption', + regions: [{ name: region }], + }, + ], + } as TProductAvailability; + + const catalog = { + addons: [ + { + planCode: 'snapshot.consumption.3AZ', + pricings: [ + { intervalUnit: 'month', price: 10500 }, + { intervalUnit: 'hour', price: 2500 }, + ], + }, + { + planCode: 'snapshot.consumption', + pricings: [{ intervalUnit: 'month', price: 20000 }], + }, + ], + } as TCatalog; + + const result = getRegionPricing(snapshotAvailabilities, catalog)(region); + + expect(result).toEqual({ intervalUnit: 'hour', price: 2500 }); + }); + + it('should return null if no plan correspond to the region', () => { + const region = 'currentRegion'; + + const snapshotAvailabilities = { + plans: [ + { + code: 'snapshot.consumption', + regions: [{ name: 'otherRegion' }], + }, + ], + } as TProductAvailability; + + const catalog = { + addons: [ + { + planCode: 'snapshot.consumption.3AZ', + pricings: [ + { intervalUnit: 'month', price: 10500 }, + { intervalUnit: 'hour', price: 2500 }, + ], + }, + { + planCode: 'snapshot.consumption', + pricings: [{ intervalUnit: 'month', price: 20000 }], + }, + ], + } as TCatalog; + + const result = getRegionPricing(snapshotAvailabilities, catalog)(region); + + expect(result).toBeNull(); + }); + + it('should return null if no pricing correspond to plan', () => { + const region = 'currentRegion'; + + const snapshotAvailabilities = { + plans: [ + { + code: 'snapshot.consumption.3AZ', + regions: [{ name: region }], + }, + { + code: 'snapshot.consumption', + regions: [{ name: 'otherRegion' }], + }, + { + code: 'volume.snapshot.consumption', + regions: [{ name: region }], + }, + ], + } as TProductAvailability; + + const catalog = { + addons: [ + { + planCode: 'snapshot.consumption', + pricings: [{ intervalUnit: 'month', price: 20000 }], + }, + ], + } as TCatalog; + + const result = getRegionPricing(snapshotAvailabilities, catalog)(region); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/manager/apps/pci-workflow/src/api/hooks/order/selector/order.selector.ts b/packages/manager/apps/pci-workflow/src/api/hooks/order/selector/order.selector.ts new file mode 100644 index 000000000000..3827a11453fc --- /dev/null +++ b/packages/manager/apps/pci-workflow/src/api/hooks/order/selector/order.selector.ts @@ -0,0 +1,20 @@ +import { TCatalog, TProductAvailability } from '@ovh-ux/manager-pci-common'; + +import { isSnapshotConsumption } from '@/pages/new/utils/is-snapshot-consumption'; + +export const getRegionPricing = + (snapshotAvailabilities: TProductAvailability, catalog: TCatalog) => (region: string) => { + const regionPlan = + snapshotAvailabilities?.plans.find( + (plan) => isSnapshotConsumption(plan.code) && plan.regions.some((r) => r.name === region), + ) || null; + + if (!catalog || !regionPlan) return null; + + return ( + catalog.addons + .find((addon) => addon.planCode === regionPlan.code) + ?.pricings.find(({ intervalUnit }) => intervalUnit === 'none' || intervalUnit === 'hour') ?? + null + ); + }; diff --git a/packages/manager/apps/pci-workflow/src/pages/new/New.page.tsx b/packages/manager/apps/pci-workflow/src/pages/new/New.page.tsx index 7cfa94db5d6b..1f66eee1df75 100644 --- a/packages/manager/apps/pci-workflow/src/pages/new/New.page.tsx +++ b/packages/manager/apps/pci-workflow/src/pages/new/New.page.tsx @@ -21,7 +21,7 @@ import { } from '@ovh-ux/manager-react-components'; import { usePrefetchInstances } from '@/api/hooks/instance/useInstances'; -import { usePrefetchSnapshotPricing } from '@/api/hooks/order'; +import { usePrefetchSnapshotPricing } from '@/api/hooks/order/order'; import { useAddWorkflow } from '@/api/hooks/workflows'; import { useSafeParam } from '@/hooks/useSafeParam'; diff --git a/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowName.component.spec.tsx b/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowName.component.spec.tsx index 719904f39b24..c1ce01ab631c 100644 --- a/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowName.component.spec.tsx +++ b/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowName.component.spec.tsx @@ -4,12 +4,12 @@ import { vi } from 'vitest'; import { useCatalogPrice, useMe } from '@ovh-ux/manager-react-components'; import { buildInstanceId } from '@/api/hooks/instance/selector/instances.selector'; -import { useInstanceSnapshotPricing } from '@/api/hooks/order'; +import { useInstanceSnapshotPricing } from '@/api/hooks/order/order'; import { WorkflowName } from './WorkflowName.component'; vi.mock('@ovh-ux/manager-react-components'); -vi.mock('@/api/hooks/order'); +vi.mock('@/api/hooks/order/order'); describe('WorkflowName', () => { beforeEach(() => { diff --git a/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowName.component.tsx b/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowName.component.tsx index 263675b9010b..3c1e090123f6 100644 --- a/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowName.component.tsx +++ b/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowName.component.tsx @@ -21,7 +21,7 @@ import { import { convertHourlyPriceToMonthly, useCatalogPrice } from '@ovh-ux/manager-react-components'; import { TInstance } from '@/api/hooks/instance/selector/instances.selector'; -import { useInstanceSnapshotPricing } from '@/api/hooks/order'; +import { useInstanceSnapshotPricing } from '@/api/hooks/order/order'; import { StepState } from '@/pages/new/hooks/useStep'; interface WorkflowNameProps { diff --git a/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowScheduling.component.spec.tsx b/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowScheduling.component.spec.tsx index d25bdfabb0d5..7e717910fdab 100644 --- a/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowScheduling.component.spec.tsx +++ b/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowScheduling.component.spec.tsx @@ -4,12 +4,18 @@ import { describe, it, vi } from 'vitest'; import { renderWithMockedWrappers } from '@/__tests__/renderWithMockedWrappers'; import { buildInstanceId } from '@/api/hooks/instance/selector/instances.selector'; -import { ContinentRegion, useInstanceSnapshotPricing } from '@/api/hooks/order'; +import { ContinentRegion, useInstanceSnapshotPricing } from '@/api/hooks/order/order'; import { StepState } from '@/pages/new/hooks/useStep'; import { WorkflowScheduling } from './WorkflowScheduling.component'; -vi.mock('@/api/hooks/order'); +vi.mock('@/api/hooks/order/order'); +vi.mock('@ovh-ux/manager-react-components', () => ({ + convertHourlyPriceToMonthly: vi.fn(), + useCatalogPrice: vi.fn().mockReturnValue({ + getFormattedCatalogPrice: (input: number) => `${input}`, + }), +})); describe('WorkflowScheduling Component', () => { const mockOnSubmit = vi.fn(); @@ -90,14 +96,24 @@ describe('WorkflowScheduling Component', () => { it('can select distant region when distantContinents is provided', async () => { vi.mocked(useInstanceSnapshotPricing).mockReturnValue({ distantContinents: new Map([ - ['Europe', [{ label: 'Region 1', name: 'region1', enabled: true } as ContinentRegion]], + [ + 'Europe', + [ + { + label: 'Region 1', + name: 'region1', + enabled: true, + price: 1500, + } as ContinentRegion, + ], + ], ]), pricing: null, isPending: false, }); const user = userEvent.setup(); - const { getByLabelText, getByRole } = renderWithMockedWrappers( + const { getByLabelText, getByRole, findByText } = renderWithMockedWrappers( { ); const distantToggle = getByLabelText(/pci_workflow_create_distant_label/); - expect(distantToggle).toBeInTheDocument(); + expect(distantToggle).toBeVisible(); await act(async () => { await user.click(distantToggle); @@ -114,14 +130,19 @@ describe('WorkflowScheduling Component', () => { // ODS 19 combobox labels are broken so we get by role hidden instead const distantRegion = getByRole('combobox'); - expect(distantRegion).toBeInTheDocument(); + expect(distantRegion).toBeVisible(); await act(async () => { await user.click(distantRegion); }); // We only test if the option is here because ODS a11y doesn't work and doesn't trigger a value change - const region1Option = getByRole('option', { name: 'Region 1', hidden: true }); - expect(region1Option).toBeInTheDocument(); + const region1Option = getByRole('option', { name: 'Region 1' }); + expect(region1Option).toBeVisible(); + + // It would have been better to use user.click on the element but it doesn't work + act(() => region1Option.click()); + + expect(await findByText('pci_workflow_create_price_monthly')).toBeVisible(); }); }); diff --git a/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowScheduling.component.tsx b/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowScheduling.component.tsx index dbbd237abbf1..57f5bc3223b5 100644 --- a/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowScheduling.component.tsx +++ b/packages/manager/apps/pci-workflow/src/pages/new/steps/WorkflowScheduling.component.tsx @@ -22,9 +22,10 @@ import { } from '@ovhcloud/ods-react'; import { Badge, RegionChipByType } from '@ovh-ux/manager-pci-common'; +import { convertHourlyPriceToMonthly, useCatalogPrice } from '@ovh-ux/manager-react-components'; import { TInstance } from '@/api/hooks/instance/selector/instances.selector'; -import { useInstanceSnapshotPricing } from '@/api/hooks/order'; +import { useInstanceSnapshotPricing } from '@/api/hooks/order/order'; import { ButtonLink } from '@/components/button-link/ButtonLink.component'; import { useSafeParam } from '@/hooks/useSafeParam'; import { CronInput } from '@/pages/new/components/CronInput.component'; @@ -80,6 +81,10 @@ export function WorkflowScheduling({ step, onSubmit, instanceId }: Readonly(ROTATE_7); const [distantBackup, setDistantBackup] = useState(false); const [distantRegion, setDistantRegion] = useState(null); @@ -88,22 +93,19 @@ export function WorkflowScheduling({ step, onSubmit, instanceId }: Readonly( () => - distantContinents - .entries() - .map(([continent, regions]) => ({ - label: continent, - options: regions.map((region) => ({ - label: region.label, - value: region.name, - })), - })) - .toArray(), + Array.from(distantContinents.entries()).map(([continent, regions]) => ({ + label: continent, + options: regions.map((region) => ({ + label: region.label, + value: region.name, + })), + })), [distantContinents], ); const comboboxRegionRender = useMemo(() => { const regionsById = new Map( - distantContinents.values().flatMap((regions) => regions.map((r) => [r.name, r])), + Array.from(distantContinents.values()).flatMap((regions) => regions.map((r) => [r.name, r])), ); // Not a React component @@ -126,12 +128,22 @@ export function WorkflowScheduling({ step, onSubmit, instanceId }: Readonly !!distantRegion && - !!distantContinents - .values() - .find((regions) => regions.find((r) => r.name === distantRegion)?.enabled === false), + !!Array.from(distantContinents.values()).find( + (regions) => regions.find((r) => r.name === distantRegion)?.enabled === false, + ), [distantRegion, distantContinents], ); + const distantRegionPrice = useMemo(() => { + const region = new Map( + Array.from(distantContinents.values()).flatMap((regions) => regions.map((r) => [r.name, r])), + ).get(distantRegion); + + return region?.price + ? getFormattedCatalogPrice(convertHourlyPriceToMonthly(region.price)) + : null; + }, [distantRegion, distantContinents, getFormattedCatalogPrice]); + const handleDistantRegionChange = (changeDetails: ComboboxValueChangeDetails) => setDistantRegion(changeDetails.value[0] ?? null); @@ -214,6 +226,26 @@ export function WorkflowScheduling({ step, onSubmit, instanceId }: Readonly + {distantRegion && ( +
+ + {t('pci_workflow_create_price_title')}{' '} + {distantRegionPrice && ( + + {t('pci_workflow_create_price_monthly', { + price: distantRegionPrice, + })} + + )} + {!distantRegionPrice && ( + + {t('pci_workflow_create_price_not_available')}{' '} + + )} + +
+ )} + {showActivateRegionWarning && ( diff --git a/packages/manager/apps/pci-workflow/src/utils/index.tsx b/packages/manager/apps/pci-workflow/src/utils/index.tsx new file mode 100644 index 000000000000..6523c092a645 --- /dev/null +++ b/packages/manager/apps/pci-workflow/src/utils/index.tsx @@ -0,0 +1,7 @@ +export const groupBy = (array: T[], getValue: (item: T) => K) => { + return array.reduce((acc, item) => { + const value = getValue(item); + acc.set(value, (acc.get(value) || []).concat(item)); + return acc; + }, new Map()); +}; diff --git a/packages/manager/apps/pci-workflow/src/utils/utils.spec.tsx b/packages/manager/apps/pci-workflow/src/utils/utils.spec.tsx new file mode 100644 index 000000000000..75c392a6ff30 --- /dev/null +++ b/packages/manager/apps/pci-workflow/src/utils/utils.spec.tsx @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'vitest'; + +import { groupBy } from './index'; + +describe('Utility functions', () => { + describe('Considering the groupBy function', () => { + const array = [ + { id: 1, group: 'a' }, + { id: 2, group: 'b' }, + { id: 3, group: 'c' }, + { id: 4, group: 'b' }, + { id: 5, group: 'a' }, + { id: 6, group: 'a' }, + ]; + + const valueMethod = (item: { id: number; group: string }) => item.group; + + const expected = new Map([ + [ + 'a', + [ + { id: 1, group: 'a' }, + { id: 5, group: 'a' }, + { id: 6, group: 'a' }, + ], + ], + [ + 'b', + [ + { id: 2, group: 'b' }, + { id: 4, group: 'b' }, + ], + ], + ['c', [{ id: 3, group: 'c' }]], + ]); + + test(`Then, expect the output to be grouped by the group value`, () => { + const result = groupBy(array, valueMethod); + + expect(result).toEqual(expected); + }); + }); +});