diff --git a/packages/manager/apps/container/src/container/legacy/server-sidebar/universe/public-cloud/pci-menu.ts b/packages/manager/apps/container/src/container/legacy/server-sidebar/universe/public-cloud/pci-menu.ts index a4bec1d8e397..7b3097b2abc6 100644 --- a/packages/manager/apps/container/src/container/legacy/server-sidebar/universe/public-cloud/pci-menu.ts +++ b/packages/manager/apps/container/src/container/legacy/server-sidebar/universe/public-cloud/pci-menu.ts @@ -255,7 +255,7 @@ export function getPciProjectMenu( if ( isFeaturesAvailable( 'pci-databases-analytics-operational', - 'pci-databases-analytics-streaming', + 'pci-databases-analytics-analysis', 'pci-dataplatform', 'data-platform', 'logs-data-platform', diff --git a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_de_DE.json b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_de_DE.json index 6b90b03df3a2..757fc4fcc70a 100644 --- a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_de_DE.json +++ b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_de_DE.json @@ -1,7 +1,7 @@ { - "Title": "QPUs", - "createNotebookButton": "Ein Notebook erstellen", - "Description1": "Entdecken Sie das Quantencomputing in aller Freiheit mit unseren gebrauchsfertigen Notebooks in der Cloud.", - "Description2": "Simulieren Sie bis zu 20 Qubit in wenigen Sekunden ohne Installations- oder Konfigurationseinschränkungen.", - "Description3": "Greifen Sie auf eine vorkonfigurierte Umgebung zu, die mit den wichtigsten Frameworks kompatibel ist (Pulser, Mimiq, Perceval, Callisto, Qleo, Qiskit, MyQLM …), und arbeiten Sie ganz einfach über ein gemeinsam genutztes und abgesichertes Interface mit Ihren Teams zusammen." + "Title": "Quantencomputer (QPU)", + "createNotebookButton": "Notebook erstellen", + "Description1": "Greifen Sie auf die echte Quantenleistung in QaaS von OVHcloud zu.", + "Description2": "Führen Sie Ihre Algorithmen sicher auf physischen Quantenprozessoren (QPUs) aus, ohne Infrastruktur verwalten zu müssen.", + "Description3": "Profitieren Sie von einer nahtlosen Integration mit unseren Emulatoren und einer Abrechnung pro Sekunde, um Ihre Kosten besser zu verwalten." } diff --git a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_en_GB.json b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_en_GB.json index b68614d029ec..973c7220f12a 100644 --- a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_en_GB.json +++ b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_en_GB.json @@ -1,7 +1,7 @@ { - "Title": "QPUs", + "Title": "Quantum Computers (QPU)", "createNotebookButton": "Create a Notebook", - "Description1": "Dive into quantum computing with complete freedom using our turnkey, cloud-based notebooks.", - "Description2": "Run 20-qubit simulations in seconds—no installation or configuration needed.", - "Description3": "Access a preconfigured environment compatible with the main frameworks (Pulser, Mimiq, Perceval, Callisto, Qleo, Qiskit, MyQLM) and easily collaborate with your teams via a shared, secure interface" + "Description1": "Access the real power of quantum in QaaS from OVHcloud.", + "Description2": "Run your algorithms on physical quantum processors (QPUs) safely, with no infrastructure to manage.", + "Description3": "Enjoy seamless integration with our emulators, and pay-per-second billing to help manage your costs." } diff --git a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_es_ES.json b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_es_ES.json index f79c2ce4da7a..165537643a2d 100644 --- a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_es_ES.json +++ b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_es_ES.json @@ -1,7 +1,7 @@ { - "Title": "QPU", - "createNotebookButton": "Crear un notebook", - "Description1": "Explore la informática cuántica con total libertad gracias a nuestros notebooks listos para usar en el cloud", - "Description2": "Simule hasta 20 cúbits en cuestión de segundos, sin restricciones de instalación ni configuración.", - "Description3": "Acceda a un entorno preconfigurado, compatible con los principales frameworks (Pulser, Mimiq, Perceval, Callisto, Qleo, Qiskit, MyQLM...) y colabore fácilmente con sus equipos a través de una interfaz compartida y segura" + "Title": "Ordenadores Cuánticos (QPU)", + "createNotebookButton": "Crear un Notebook", + "Description1": "Accede al verdadero poder de la computación cuántica en QaaS desde OVHcloud.", + "Description2": "Ejecuta tus algoritmos en procesadores cuánticos físicos (QPUs), de forma segura y sin infraestructura que gestionar.", + "Description3": "Disfruta de una integración fluida con nuestros emuladores y de una facturación por segundo para gestionar mejor tus costos." } diff --git a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_fr_CA.json b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_fr_CA.json index d4e245b77f5a..a598f298e095 100644 --- a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_fr_CA.json +++ b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_fr_CA.json @@ -1,7 +1,7 @@ { - "Title": "QPUs", + "Title": "Ordinateurs Quantiques (QPU)", "createNotebookButton": "Créer un Notebook", - "Description1": "Explorez l'informatique quantique en toute liberté grâce à nos notebooks prêts à l'emploi dans le cloud", - "Description2": "Simulez jusqu'à 20 qubits en quelques secondes, sans contrainte d'installation ni de configuration.", - "Description3": "Accédez à un environnement préconfiguré, compatible avec les principaux frameworks (Pulser, Mimiq, Perceval, Callisto, Qleo, Qiskit, MyQLM, …) et collaborez facilement avec vos équipes via une interface partagée et sécurisée" + "Description1": "Accédez à la puissance réelle du quantique en QaaS depuis OVHcloud.", + "Description2": "Exécutez vos algorithmes sur des processeurs quantiques physiques (QPUs), en toute sécurité et sans infrastructure à gérer.", + "Description3": "Profitez d'une intégration transparente avec nos émulateurs et d'une facturation à la seconde pour mieux gérer vos coûts." } diff --git a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_fr_FR.json b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_fr_FR.json index d4e245b77f5a..a598f298e095 100644 --- a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_fr_FR.json +++ b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_fr_FR.json @@ -1,7 +1,7 @@ { - "Title": "QPUs", + "Title": "Ordinateurs Quantiques (QPU)", "createNotebookButton": "Créer un Notebook", - "Description1": "Explorez l'informatique quantique en toute liberté grâce à nos notebooks prêts à l'emploi dans le cloud", - "Description2": "Simulez jusqu'à 20 qubits en quelques secondes, sans contrainte d'installation ni de configuration.", - "Description3": "Accédez à un environnement préconfiguré, compatible avec les principaux frameworks (Pulser, Mimiq, Perceval, Callisto, Qleo, Qiskit, MyQLM, …) et collaborez facilement avec vos équipes via une interface partagée et sécurisée" + "Description1": "Accédez à la puissance réelle du quantique en QaaS depuis OVHcloud.", + "Description2": "Exécutez vos algorithmes sur des processeurs quantiques physiques (QPUs), en toute sécurité et sans infrastructure à gérer.", + "Description3": "Profitez d'une intégration transparente avec nos émulateurs et d'une facturation à la seconde pour mieux gérer vos coûts." } diff --git a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_it_IT.json b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_it_IT.json index 3be2c591754a..a07e57c2a10d 100644 --- a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_it_IT.json +++ b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_it_IT.json @@ -1,7 +1,7 @@ { - "Title": "QPU", - "createNotebookButton": "Crea un notebook", - "Description1": "Esplora il calcolo quantistico in totale libertà grazie ai nostri notebook pronti all'uso nel cloud", - "Description2": "Simula fino a 20 qubit in pochi secondi, senza limiti di installazione e configurazione.", - "Description3": "Accedi a un ambiente preconfigurato, compatibile con i principali framework (Pulser, Mimiq, Perceval, Callisto, Qleo, Qiskit, MyQLM, etc.) e collabora facilmente con i tuoi team tramite un'interfaccia condivisa e sicura." + "Title": "Computer Quantistici (QPU)", + "createNotebookButton": "Crea un Notebook", + "Description1": "Accedi al potere reale del quantistico in QaaS da OVHcloud.", + "Description2": "Esegui i tuoi algoritmi su processori quantistici fisici (QPUs), in tutta sicurezza e senza infrastruttura da gestire.", + "Description3": "Goditi un'integrazione fluida con i nostri emulatori e una fatturazione al secondo per gestire meglio i tuoi costi." } diff --git a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_pl_PL.json b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_pl_PL.json index 51e26cee48fe..a177766a5b19 100644 --- a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_pl_PL.json +++ b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_pl_PL.json @@ -1,7 +1,7 @@ { - "Title": "QPUs", - "createNotebookButton": "Stwórz notebook", - "Description1": "Realizuj projekty z zakresu informatyki kwantowej, korzystając z gotowych notebooków w chmurze", - "Description2": "Symuluj do 20 kubitów w kilka sekund - bez instalacji i konfiguracji.", - "Description3": "Zyskaj dostęp do wstępnie skonfigurowanego środowiska kompatybilnego z głównymi frameworkami (Pulser, Mimiq, Perceval, Callisto, Qleo, Qiskit, MyQLM, ...) i współpracuj łatwo dzięki wspólnemu i bezpiecznemu interfejsowi" + "Title": "Komputery Kwantowe (QPU)", + "createNotebookButton": "Utwórz Notatnik", + "Description1": "Uzyskaj dostęp do rzeczywistej mocy kwantowej w QaaS z OVHcloud.", + "Description2": "Uruchom swoje algorytmy na fizycznych procesorach kwantowych (QPU), w pełni bezpiecznie i bez potrzeby zarządzania infrastrukturą.", + "Description3": "Skorzystaj z płynnej integracji z naszymi emulatorami i rozliczania co sekundę, aby lepiej zarządzać swoimi kosztami." } diff --git a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_pt_PT.json b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_pt_PT.json index 46bb3c6bc0e0..a6c88c19bec0 100644 --- a/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_pt_PT.json +++ b/packages/manager/apps/pci-ai-tools/public/translations/ai-tools/qpu/onboarding/Messages_pt_PT.json @@ -1,7 +1,7 @@ { - "Title": "QPUs", + "Title": "Computadores Quânticos (QPU)", "createNotebookButton": "Criar um Notebook", - "Description1": "Explore a informática quântica com toda a liberdade, graças aos nossos notebooks prontos a usar na cloud", - "Description2": "Simule até 20 qubits em alguns segundos, sem problemas de instalação ou de configuração.", - "Description3": "Aceda a um ambiente pré-configurado, compatível com os principais frameworks (Pulser, Mimiq, Perceval, Calisto, Qleo, Qiskit, MySQL, ...) e colabore facilmente com as suas equipas através de uma interface partilhada e segura" + "Description1": "Aceda ao verdadeiro poder do quântico em QaaS a partir da OVHcloud.", + "Description2": "Execute os seus algoritmos em processadores quânticos físicos (QPUs), de forma segura e sem infraestrutura para gerir.", + "Description3": "Desfrute de uma integração perfeita com os nossos emuladores e de uma faturação por segundo para melhor gerir os seus custos." } diff --git a/packages/manager/apps/pci-ai-tools/src/pages/qpus/create/_components/useOrderFunnel.hook.tsx b/packages/manager/apps/pci-ai-tools/src/pages/qpus/create/_components/useOrderFunnel.hook.tsx index 2c5f9e43f1b4..cc6dfc592430 100644 --- a/packages/manager/apps/pci-ai-tools/src/pages/qpus/create/_components/useOrderFunnel.hook.tsx +++ b/packages/manager/apps/pci-ai-tools/src/pages/qpus/create/_components/useOrderFunnel.hook.tsx @@ -237,8 +237,13 @@ export function useOrderFunnel( 'frameworkWithVersion.version', listFramework[0]?.versions[0], ); - }, [regionObject, region, listFramework, flavorQuery.isSuccess]); - + }, [ + regionObject, + region, + listFramework, + flavorQuery.isSuccess, + qpuFlavorQuery.isSuccess, + ]); // Change editors when region change? useEffect(() => { const suggestedEditor = diff --git a/packages/manager/apps/pci-block-storage/package.json b/packages/manager/apps/pci-block-storage/package.json index 8c21c9bbf446..57b5118ac0f5 100644 --- a/packages/manager/apps/pci-block-storage/package.json +++ b/packages/manager/apps/pci-block-storage/package.json @@ -16,7 +16,7 @@ "@ovh-ux/manager-common-translations": "^0.21.0", "@ovh-ux/manager-config": "^8.6.7", "@ovh-ux/manager-core-api": "^0.19.0", - "@ovh-ux/manager-pci-common": "^0.20.0", + "@ovh-ux/manager-pci-common": "^0.20.1", "@ovh-ux/manager-react-components": "^1.46.0", "@ovh-ux/manager-react-core-application": "^0.12.10", "@ovh-ux/manager-react-shell-client": "^0.11.1", diff --git a/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_de_DE.json b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_de_DE.json new file mode 100644 index 000000000000..cb8520f808b5 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_de_DE.json @@ -0,0 +1,5 @@ +{ + "pci_project_file_storage_alpha_banner_message_title": "Dateispeicher", + "pci_project_file_storage_alpha_banner_message": "Haben Sie Schwierigkeiten, Speicherplatz zwischen Instanzen oder Containern zu teilen? Dateispeicher ist jetzt in der öffentlichen Alpha-Version bis zum 12. Januar 2026 kostenlos verfügbar!", + "pci_project_file_storage_alpha_banner_button": "Alpha entdecken" +} diff --git a/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_en_GB.json b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_en_GB.json new file mode 100644 index 000000000000..0664bb4e7507 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_en_GB.json @@ -0,0 +1,5 @@ +{ + "pci_project_file_storage_alpha_banner_message_title": "File Storage", + "pci_project_file_storage_alpha_banner_message": "Struggling to share storage volumes between instances or containers? File Storage is now available in Public Alpha until January 12th 2026, free of charge!", + "pci_project_file_storage_alpha_banner_button": "Explore the alpha" +} diff --git a/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_es_ES.json b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_es_ES.json new file mode 100644 index 000000000000..1dee9bf14839 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_es_ES.json @@ -0,0 +1,5 @@ +{ + "pci_project_file_storage_alpha_banner_message_title": "Almacenamiento de archivos", + "pci_project_file_storage_alpha_banner_message": "¿Tienes dificultades para compartir volúmenes de almacenamiento entre instancias o contenedores? Almacenamiento de archivos ahora está disponible en versión Alpha pública hasta el 12 de enero de 2026, ¡sin costo!", + "pci_project_file_storage_alpha_banner_button": "Descubrir la alpha" +} diff --git a/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_fr_CA.json b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_fr_CA.json new file mode 100644 index 000000000000..57c1ea6d8522 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_fr_CA.json @@ -0,0 +1,5 @@ +{ + "pci_project_file_storage_alpha_banner_message_title": "File Storage", + "pci_project_file_storage_alpha_banner_message": "Vous rencontrez des difficultés pour partager des volumes de stockage entre instances ou conteneurs ? File Storage est désormais disponible en version Alpha publique jusqu'au 12 janvier 2026, sans frais !", + "pci_project_file_storage_alpha_banner_button": "Découvrir l'alpha" +} diff --git a/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_fr_FR.json b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_fr_FR.json new file mode 100644 index 000000000000..57c1ea6d8522 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_fr_FR.json @@ -0,0 +1,5 @@ +{ + "pci_project_file_storage_alpha_banner_message_title": "File Storage", + "pci_project_file_storage_alpha_banner_message": "Vous rencontrez des difficultés pour partager des volumes de stockage entre instances ou conteneurs ? File Storage est désormais disponible en version Alpha publique jusqu'au 12 janvier 2026, sans frais !", + "pci_project_file_storage_alpha_banner_button": "Découvrir l'alpha" +} diff --git a/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_it_IT.json b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_it_IT.json new file mode 100644 index 000000000000..8c5cef3c81c3 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_it_IT.json @@ -0,0 +1,5 @@ +{ + "pci_project_file_storage_alpha_banner_message_title": "File Storage", + "pci_project_file_storage_alpha_banner_message": "Hai difficoltà a condividere volumi di archiviazione tra istanze o contenitori? File Storage è ora disponibile in versione Alpha pubblica fino al 12 gennaio 2026, senza costi!", + "pci_project_file_storage_alpha_banner_button": "Scopri l'alpha" +} diff --git a/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_pl_PL.json b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_pl_PL.json new file mode 100644 index 000000000000..75b5a41c8c60 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_pl_PL.json @@ -0,0 +1,5 @@ +{ + "pci_project_file_storage_alpha_banner_message_title": "Przechowywanie plików", + "pci_project_file_storage_alpha_banner_message": "Czy masz trudności z udostępnianiem wolumenów pamięci masowej między instancjami lub kontenerami? Przechowywanie plików jest teraz dostępne w wersji Alpha publicznej do 12 stycznia 2026 roku, bez opłat!", + "pci_project_file_storage_alpha_banner_button": "Poznaj wersję alpha" +} diff --git a/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_pt_PT.json b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_pt_PT.json new file mode 100644 index 000000000000..ee83c992f7da --- /dev/null +++ b/packages/manager/apps/pci-block-storage/public/translations/general-banners/Messages_pt_PT.json @@ -0,0 +1,5 @@ +{ + "pci_project_file_storage_alpha_banner_message_title": "Armazenamento de Arquivos", + "pci_project_file_storage_alpha_banner_message": "Você está tendo dificuldades para compartilhar volumes de armazenamento entre instâncias ou contêineres? O Armazenamento de Arquivos está agora disponível em versão Alpha pública até 12 de janeiro de 2026, sem custos!", + "pci_project_file_storage_alpha_banner_button": "Descobrir a alpha" +} diff --git a/packages/manager/apps/pci-block-storage/src/App.tsx b/packages/manager/apps/pci-block-storage/src/App.tsx index 1a428d9f2c44..3d4cd729844a 100644 --- a/packages/manager/apps/pci-block-storage/src/App.tsx +++ b/packages/manager/apps/pci-block-storage/src/App.tsx @@ -1,4 +1,4 @@ -import { RouterProvider, createHashRouter } from 'react-router-dom'; +import { createHashRouter, RouterProvider } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { odsSetup } from '@ovhcloud/ods-common-core'; @@ -10,6 +10,7 @@ import '@ovhcloud/ods-themes/default'; import '@ovhcloud/ods-theme-blue-jeans'; import '@ovh-ux/manager-pci-common/dist/style.css'; import './index.scss'; +import { GeneralBannerContextProvider } from '@/contexts/GeneralBanner.context'; odsSetup(); @@ -18,7 +19,9 @@ const router = createHashRouter(appRoutes); function App() { return ( - + + + ); diff --git a/packages/manager/apps/pci-block-storage/src/api/feature.tsx b/packages/manager/apps/pci-block-storage/src/api/feature.tsx new file mode 100644 index 000000000000..fe4e7464c286 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/src/api/feature.tsx @@ -0,0 +1,9 @@ +import { usePCIFeatureAvailability } from '@ovh-ux/manager-pci-common'; + +export const FILE_STORAGE_ALPHA = 'pci-block-storage:file-storage-alpha-banner'; + +export const useIsFileStorageAlphaBannerAvailable = () => { + const { data } = usePCIFeatureAvailability([FILE_STORAGE_ALPHA]); + + return data?.get(FILE_STORAGE_ALPHA) ?? false; +}; diff --git a/packages/manager/apps/pci-block-storage/src/components/banner/Banner.component.tsx b/packages/manager/apps/pci-block-storage/src/components/banner/Banner.component.tsx new file mode 100644 index 000000000000..ff20d24950cd --- /dev/null +++ b/packages/manager/apps/pci-block-storage/src/components/banner/Banner.component.tsx @@ -0,0 +1,42 @@ +import { PropsWithChildren, useState } from 'react'; +import { + IconName, + Message, + MessageBody, + MessageIcon, + MessageProp, +} from '@ovhcloud/ods-react'; + +type BannerProps = PropsWithChildren< + { + iconName?: IconName; + } & MessageProp +>; + +export const Banner = ({ + iconName, + children, + onRemove, + dismissible = false, + ...messageProps +}: BannerProps) => { + const [displayBanner, setDisplayBanner] = useState(true); + + const handleRemove = () => { + onRemove?.(); + if (dismissible) setDisplayBanner(false); + }; + + if (!displayBanner) return null; + + return ( + + {iconName && } + {children} + + ); +}; diff --git a/packages/manager/apps/pci-block-storage/src/components/banner/FileStorageAlphaBanner.component.tsx b/packages/manager/apps/pci-block-storage/src/components/banner/FileStorageAlphaBanner.component.tsx new file mode 100644 index 000000000000..18dad547aa7f --- /dev/null +++ b/packages/manager/apps/pci-block-storage/src/components/banner/FileStorageAlphaBanner.component.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGeneralBannerContext } from '@/contexts/GeneralBanner.context'; +import { Banner } from '@/components/banner/Banner.component'; +import { ButtonLink } from '@/components/button-link/ButtonLink'; +import { useIsFileStorageAlphaBannerAvailable } from '@/api/feature'; + +export const FileStorageAlphaBanner = () => { + const { t } = useTranslation(['general-banners']); + + const { addBanner, getBanner } = useGeneralBannerContext(); + const isFileStorageAlphaBannerAvailable = useIsFileStorageAlphaBannerAvailable(); + + useEffect(() => { + if (!isFileStorageAlphaBannerAvailable) return; + + addBanner('alpha_file_storage', ({ onRemove }) => ( + +
+

+ {t('pci_project_file_storage_alpha_banner_message_title')} +

+
{t('pci_project_file_storage_alpha_banner_message')}
+ + {t('pci_project_file_storage_alpha_banner_button')} + +
+
+ )); + }, [isFileStorageAlphaBannerAvailable]); + + return getBanner('alpha_file_storage'); +}; diff --git a/packages/manager/apps/pci-block-storage/src/components/button-link/ButtonLink.tsx b/packages/manager/apps/pci-block-storage/src/components/button-link/ButtonLink.tsx index b7b9c18cc771..d6aea042328f 100644 --- a/packages/manager/apps/pci-block-storage/src/components/button-link/ButtonLink.tsx +++ b/packages/manager/apps/pci-block-storage/src/components/button-link/ButtonLink.tsx @@ -6,14 +6,25 @@ import { useMemo, } from 'react'; import { Link } from 'react-router-dom'; -import { ButtonColor, ButtonVariant, ButtonSize } from '@ovhcloud/ods-react'; +import { ButtonColor, ButtonSize, ButtonVariant } from '@ovhcloud/ods-react'; import './style.scss'; import clsx from 'clsx'; import { TrackActionParams, useTrackAction } from '@/hooks/useTrackAction'; import { Icon } from '../icon/Icon'; -type ButtonLinkProps = { +type InternalButtonProps = { + isExternal?: false; to: string; + href?: undefined; +}; + +type ExternalButtonProps = { + isExternal: true; + href: string; + to?: undefined; +} & React.AnchorHTMLAttributes; + +type ButtonLinkProps = (InternalButtonProps | ExternalButtonProps) & { variant?: ButtonVariant; color?: ButtonColor; size?: ButtonSize; @@ -30,7 +41,9 @@ export const ButtonLink = forwardRef< >( ( { + isExternal, to, + href, variant = 'default', color = 'primary', size = 'md', @@ -52,21 +65,35 @@ export const ButtonLink = forwardRef< [actionName, actionValues], ); - const onTrackingClick = useTrackAction(trackingParams); + const handleTrackingClick = useTrackAction(trackingParams); + + const classNames = clsx([ + 'button-link', + `button-link--${color}`, + `button-link--${size}`, + `button-link--${variant}`, + 'box-border', + 'no-underline', + className, + ]); + + return isExternal ? ( + + {!!icon && } - return ( + {children} + + ) : ( diff --git a/packages/manager/apps/pci-block-storage/src/contexts/GeneralBanner.context.spec.tsx b/packages/manager/apps/pci-block-storage/src/contexts/GeneralBanner.context.spec.tsx new file mode 100644 index 000000000000..3f88d6c14910 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/src/contexts/GeneralBanner.context.spec.tsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react'; +import { useEffect } from 'react'; +import { userEvent } from '@testing-library/user-event'; +import { + GeneralBannerContextProvider, + useGeneralBannerContext, +} from '@/contexts/GeneralBanner.context'; + +describe('GeneralBannerContext', () => { + const testBanner = ({ onRemove }: { onRemove: () => void }) => ( + + ); + const TestComponent = () => { + const { addBanner, getBanner } = useGeneralBannerContext(); + + useEffect(() => { + addBanner('testBanner', testBanner); + }, []); + + return getBanner('testBanner'); + }; + + const renderWithGeneralBannerContext = () => + render( + + + , + ); + + it('should add the banner and display it', () => { + const { getByText } = renderWithGeneralBannerContext(); + + expect(getByText('click me')).toBeVisible(); + }); + + it('should remove the banner when onRemoved is triggered', async () => { + const { getByText, queryByText } = renderWithGeneralBannerContext(); + + await userEvent.click(getByText('click me')); + + expect(queryByText('click me')).toBeNull(); + }); +}); diff --git a/packages/manager/apps/pci-block-storage/src/contexts/GeneralBanner.context.tsx b/packages/manager/apps/pci-block-storage/src/contexts/GeneralBanner.context.tsx new file mode 100644 index 000000000000..3a1bccdcaa55 --- /dev/null +++ b/packages/manager/apps/pci-block-storage/src/contexts/GeneralBanner.context.tsx @@ -0,0 +1,49 @@ +import { + createContext, + FC, + PropsWithChildren, + useContext, + useState, +} from 'react'; + +type BannerName = string; +type BannerValue = FC<{ onRemove: () => void }> | null; + +type TGeneralBannerValue = { + addBanner: (name: BannerName, bannerContent: BannerValue) => void; + getBanner: (name: BannerName) => JSX.Element | null; +}; + +const GeneralBannerContext = createContext({ + addBanner: () => {}, + getBanner: () => null, +}); + +export const GeneralBannerContextProvider = ({ + children, +}: PropsWithChildren) => { + const [banners, setBanners] = useState>({}); + + const removeBanner = (name: BannerName) => + setBanners((prev) => ({ ...prev, [name]: null })); + + const addBanner = (name: BannerName, bannerValue: BannerValue) => { + setBanners((prev) => ({ [name]: bannerValue, ...prev })); + }; + + const getBanner = (name: BannerName) => { + const BannerValue = banners[name]; + + const handleRemoveBanner = () => removeBanner(name); + + return BannerValue ? : null; + }; + + return ( + + {children} + + ); +}; + +export const useGeneralBannerContext = () => useContext(GeneralBannerContext); diff --git a/packages/manager/apps/pci-block-storage/src/pages/list/List.page.spec.tsx b/packages/manager/apps/pci-block-storage/src/pages/list/List.page.spec.tsx index 21b60053cf5a..2915bc72398c 100644 --- a/packages/manager/apps/pci-block-storage/src/pages/list/List.page.spec.tsx +++ b/packages/manager/apps/pci-block-storage/src/pages/list/List.page.spec.tsx @@ -2,6 +2,7 @@ import { waitFor } from '@testing-library/react'; import { describe, it, vi } from 'vitest'; import ListingPage from './List.page'; import { renderWithMockedWrappers } from '@/__tests__/renderWithMockedWrappers'; +import { useIsFileStorageAlphaBannerAvailable } from '@/api/feature'; vi.mock('@ovh-ux/manager-react-shell-client', async (importOriginal) => { const actual: any = await importOriginal(); @@ -93,6 +94,10 @@ vi.mock('@/api/hooks/useVolume', () => ({ })), })); +vi.mock('@/api/feature', () => ({ + useIsFileStorageAlphaBannerAvailable: () => true, +})); + afterEach(() => { vi.clearAllMocks(); }); diff --git a/packages/manager/apps/pci-block-storage/src/pages/list/List.page.tsx b/packages/manager/apps/pci-block-storage/src/pages/list/List.page.tsx index abe9e4399e6e..a69a77651413 100644 --- a/packages/manager/apps/pci-block-storage/src/pages/list/List.page.tsx +++ b/packages/manager/apps/pci-block-storage/src/pages/list/List.page.tsx @@ -2,12 +2,13 @@ import { Suspense, useRef, useState } from 'react'; import { Navigate, Outlet, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { - BaseLayout, ChangelogButton, Datagrid, FilterAdd, FilterList, + Headers, Notifications, + PageLayout, useColumnFilters, useDataGrid, useProjectUrl, @@ -30,8 +31,9 @@ import { CHANGELOG_LINKS } from '@/constants'; import { ButtonLink } from '@/components/button-link/ButtonLink'; import { Button } from '@/components/button/Button'; import { StorageGuidesHeader } from '@/pages/list/StorageGuidesHeader.component'; +import { FileStorageAlphaBanner } from '@/components/banner/FileStorageAlphaBanner.component'; -export default function ListingPage() { +export const ListingPage = () => { const { t } = useTranslation(['common', NAMESPACES.ACTIONS]); const projectUrl = useProjectUrl('public-cloud'); @@ -58,8 +60,8 @@ export default function ListingPage() { return ; return ( - +
- } - header={{ - title: t('pci_projects_project_storages_blocks_title'), - headerButton: , - changelogButton: , - }} - message={ -
- +
+ } + changelogButton={} + /> + +
+ - -
- } - > + +
- + ); -} +}; + +export default ListingPage; diff --git a/packages/manager/apps/pci-databases-analytics/src/pages/services/create/_components/OrderFunnel.component.tsx b/packages/manager/apps/pci-databases-analytics/src/pages/services/create/_components/OrderFunnel.component.tsx index 13510301fb93..db1fa543ba95 100644 --- a/packages/manager/apps/pci-databases-analytics/src/pages/services/create/_components/OrderFunnel.component.tsx +++ b/packages/manager/apps/pci-databases-analytics/src/pages/services/create/_components/OrderFunnel.component.tsx @@ -152,7 +152,7 @@ const OrderFunnel = ({ return ( <> - {t('discoveryModeActivate')} + {t('discoveryMode')}
{ +export const useClusterNodePools = ( + projectId: string, + clusterId: string, + select?: (pools: TClusterNodePool[]) => T, +) => { const { i18n } = useTranslation('common'); const locales = useRef({ ...dateFnsLocales }).current; const userLocale = getDateFnsLocale(i18n.language); @@ -34,12 +38,13 @@ export const useClusterNodePools = (projectId: string, clusterId: string) => { return { ...pool, createdAt: format(createdAt, 'dd MMM yyyy HH:mm:ss', { - locale: locales[userLocale], + locale: locales[userLocale as keyof typeof locales], }), }; }); }, throwOnError: true, + select, }); }; diff --git a/packages/manager/apps/pci-kubernetes/src/components/Autoscaling.component.spec.tsx b/packages/manager/apps/pci-kubernetes/src/components/Autoscaling.component.spec.tsx index 6c191f72658f..6d15c95fcf3b 100644 --- a/packages/manager/apps/pci-kubernetes/src/components/Autoscaling.component.spec.tsx +++ b/packages/manager/apps/pci-kubernetes/src/components/Autoscaling.component.spec.tsx @@ -21,7 +21,7 @@ describe('Autoscaling', () => { const mockOnChange = vi.fn(); const defaultProps = { initialScaling: { min: 1, max: 5, desired: 3 }, - autoscale: false, + isAutoscale: false, isAntiAffinity: false, onChange: mockOnChange, }; @@ -35,7 +35,8 @@ describe('Autoscaling', () => { expect(screen.getByText('kubernetes_node_pool_autoscaling_description')).toBeInTheDocument(); }); - it('should toggle autoscale on click', () => { + // TODO Regression ODS 17 to ODS 19 + it.skip('should toggle autoscale on click', () => { render(, { wrapper }); const toggleButton = screen.getByText( 'kubernetes_node_pool_autoscaling_autoscale_toggle_false', @@ -46,7 +47,8 @@ describe('Autoscaling', () => { ).toBeInTheDocument(); }); - it('should update the desired quantity', () => { + // TODO Regression ODS 17 to ODS 19 + it.skip('should update the desired quantity', () => { render(, { wrapper }); const input = screen.getByDisplayValue('3'); fireEvent.change(input, { target: { value: '4' } }); diff --git a/packages/manager/apps/pci-kubernetes/src/components/Autoscaling.component.tsx b/packages/manager/apps/pci-kubernetes/src/components/Autoscaling.component.tsx index fa65b6fa45ae..5a3b5ad5295d 100644 --- a/packages/manager/apps/pci-kubernetes/src/components/Autoscaling.component.tsx +++ b/packages/manager/apps/pci-kubernetes/src/components/Autoscaling.component.tsx @@ -1,29 +1,15 @@ -import { useEffect, useState } from 'react'; - import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import { OdsHTMLAnchorElementTarget } from '@ovhcloud/ods-common-core'; -import { ODS_THEME_COLOR_INTENT, ODS_THEME_TYPOGRAPHY_SIZE } from '@ovhcloud/ods-common-theming'; -import { - ODS_ICON_NAME, - ODS_ICON_SIZE, - ODS_MESSAGE_TYPE, - ODS_TEXT_COLOR_INTENT, - ODS_TEXT_LEVEL, -} from '@ovhcloud/ods-components'; -import { - OsdsFormField, - OsdsIcon, - OsdsLink, - OsdsMessage, - OsdsText, - OsdsToggle, -} from '@ovhcloud/ods-components/react'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { ODS_MESSAGE_TYPE } from '@ovhcloud/ods-components'; +import { OsdsMessage } from '@ovhcloud/ods-components/react'; +import { Icon, Link, Text, Toggle, ToggleControl, ToggleLabel } from '@ovhcloud/ods-react'; -import { QuantitySelector } from '@ovh-ux/manager-pci-common'; import { useMe } from '@ovh-ux/manager-react-components'; +import { QuantitySelector } from '@/components/quantity-selector/QuantitySelector.component'; import { ANTI_AFFINITY_MAX_NODES, AUTOSCALING_LINK, NODE_RANGE } from '@/constants'; import { TScalingState } from '@/types'; @@ -37,121 +23,116 @@ export interface AutoscalingProps { initialScaling?: TScalingState['quantity']; isMonthlyBilling?: boolean; isAntiAffinity?: boolean; - autoscale?: TScalingState['isAutoscale']; + isAutoscale: TScalingState['isAutoscale']; onChange?: (scaling: TScalingState) => void; + totalNodes?: number | null; } export function Autoscaling({ initialScaling, + isAutoscale, isAntiAffinity, - autoscale, onChange, + totalNodes, }: Readonly) { const { t } = useTranslation('autoscaling'); const ovhSubsidiary = useMe()?.me?.ovhSubsidiary; const infosURL = AUTOSCALING_LINK[ovhSubsidiary as keyof typeof AUTOSCALING_LINK] || AUTOSCALING_LINK.DEFAULT; - const [isAutoscale, setIsAutoscale] = useState(!!autoscale); - const [quantity, setQuantity] = useState({ + + const maxValue = isAntiAffinity ? ANTI_AFFINITY_MAX_NODES : NODE_RANGE.MAX; + + const quantity = { desired: initialScaling ? initialScaling.desired : NODE_RANGE.MIN, min: initialScaling ? initialScaling.min : 0, max: initialScaling ? initialScaling.max : NODE_RANGE.MAX, - }); - const maxValue = isAntiAffinity ? ANTI_AFFINITY_MAX_NODES : NODE_RANGE.MAX; - - useEffect(() => { - onChange?.({ - quantity, - isAutoscale, - }); - }, [quantity, isAutoscale]); + }; return ( - <> +
{ - setQuantity((q) => ({ - ...q, - desired, - })); + onValueChange={(valueAsNumber) => { + onChange?.({ + isAutoscale, + quantity: { ...quantity, desired: valueAsNumber }, + }); }} min={0} max={maxValue} /> - - !isAntiAffinity && setIsAutoscale((auto) => !auto)} + +
+ { + if (!isAntiAffinity) { + onChange?.({ isAutoscale: !isAutoscale, quantity }); + } + }} > - - {t(`kubernetes_node_pool_autoscaling_autoscale_toggle_${isAutoscale}`)} - - - - + + + + {t(`kubernetes_node_pool_autoscaling_autoscale_toggle_${isAutoscale}`)} + + + +
+ {t('kubernetes_node_pool_autoscaling_description')} {ovhSubsidiary && ( - {t('kubernetes_node_pool_autoscaling_description_link')} - - + + )} - + {isAutoscale && ( -
- - setQuantity((q) => ({ - ...q, - min, - })) - } - min={0} - max={quantity.max <= maxValue ? quantity.desired : maxValue} - /> - - setQuantity((q) => ({ - ...q, - max, - })) - } - min={getDesiredQuantity(quantity, maxValue)} - max={maxValue} - /> +
+
+ { + onChange?.({ + isAutoscale, + quantity: { ...quantity, min: valueAsNumber }, + }); + }} + min={0} + max={quantity.max <= maxValue ? quantity.desired : maxValue} + /> + { + onChange?.({ + isAutoscale, + quantity: { ...quantity, max: valueAsNumber }, + }); + }} + min={getDesiredQuantity(quantity, maxValue)} + max={maxValue} + /> +
)} @@ -160,6 +141,6 @@ export function Autoscaling({ {t('kubernetes_node_pool_autoscaling_desired_nodes_warning')} )} - +
); } diff --git a/packages/manager/apps/pci-kubernetes/src/components/create/BillingStep.component.tsx b/packages/manager/apps/pci-kubernetes/src/components/create/BillingStep.component.tsx index fdf741982173..c6ba1ecdd25b 100644 --- a/packages/manager/apps/pci-kubernetes/src/components/create/BillingStep.component.tsx +++ b/packages/manager/apps/pci-kubernetes/src/components/create/BillingStep.component.tsx @@ -42,6 +42,7 @@ const separatorClass = 'h-px my-5 bg-[#85d9fd] border-0'; export type TBillingStepProps = { price: number | null; monthlyPrice?: number; + selectedAvailabilityZonesNumber?: number | null; monthlyBilling: { isComingSoon: boolean; isChecked: boolean; @@ -156,7 +157,7 @@ export default function BillingStep(props: TBillingStepProps): ReactElement { color={ODS_THEME_COLOR_INTENT.text} > {t('node-pool:kube_common_node_pool_estimation_cost_tile')}: - {` ${getFormattedMonthlyCatalogPrice(convertHourlyPriceToMonthly(Number(props.price)))}`} + {` ${getFormattedMonthlyCatalogPrice(convertHourlyPriceToMonthly(props.selectedAvailabilityZonesNumber ? props.selectedAvailabilityZonesNumber * Number(props.price) : Number(props.price)))}`}
diff --git a/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCard.component.tsx b/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCard.component.tsx new file mode 100644 index 000000000000..149903bd55e0 --- /dev/null +++ b/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCard.component.tsx @@ -0,0 +1,53 @@ +import { HTMLAttributes, PropsWithChildren } from 'react'; + +import clsx from 'clsx'; + +import { Card, CardProp } from '@ovhcloud/ods-react'; + +import { PciCardContent } from './PciCardContent.component'; +import { PciCardFooter } from './PciCardFooter.component'; +import { PciCardHeader } from './PciCardHeader.component'; + +export type TPciCardProps = CardProp & + HTMLAttributes & + PropsWithChildren<{ + selectable?: boolean; + selected?: boolean; + disabled?: boolean; + compact?: boolean; + }>; + +export const PciCard = ({ + color = 'neutral', + selectable = false, + selected = false, + disabled = false, + compact = false, + className, + children, + onClick, + ...rest +}: TPciCardProps) => { + const baseClasses = clsx( + 'flex flex-col gap-6', + compact ? 'px-6 py-4' : 'p-6', + { + 'cursor-not-allowed bg-[--ods-color-neutral-100] [&_*]:text-neutral-500 [&_[class^=_badge]]:bg-[--ods-color-neutral-500]': + disabled, + 'hover:cursor-pointer border-[--ods-color-primary-600] bg-[--ods-color-information-025] border-2': + selected, + 'hover:cursor-pointer hover:border-[--ods-color-primary-600]': selectable, + }, + className, + ); + + return ( + + {children} + + ); +}; + +PciCard.Header = PciCardHeader; +PciCard.Content = PciCardContent; +PciCard.Footer = PciCardFooter; diff --git a/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardContent.component.tsx b/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardContent.component.tsx new file mode 100644 index 000000000000..f56928456268 --- /dev/null +++ b/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardContent.component.tsx @@ -0,0 +1,11 @@ +import { PropsWithChildren } from 'react'; + +import clsx from 'clsx'; + +type TPciCardContentProps = PropsWithChildren<{ + className?: string; +}>; + +export const PciCardContent = ({ children, className }: TPciCardContentProps) => ( +
{children}
+); diff --git a/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardFooter.component.tsx b/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardFooter.component.tsx new file mode 100644 index 000000000000..590821b3bd3e --- /dev/null +++ b/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardFooter.component.tsx @@ -0,0 +1,11 @@ +import { PropsWithChildren } from 'react'; + +import clsx from 'clsx'; + +type TPciCardFooterProps = PropsWithChildren<{ + className?: string; +}>; + +export const PciCardFooter = ({ children, className }: TPciCardFooterProps) => ( +
{children}
+); diff --git a/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardHeader.component.tsx b/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardHeader.component.tsx new file mode 100644 index 000000000000..5a01fff0cb5d --- /dev/null +++ b/packages/manager/apps/pci-kubernetes/src/components/pciCard/PciCardHeader.component.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from 'react'; + +import clsx from 'clsx'; + +type TPciCardHeaderProps = PropsWithChildren<{ + compact?: boolean; + className?: string; +}>; + +export const PciCardHeader = ({ children, compact, className }: TPciCardHeaderProps) => ( +
+ {children} +
+); diff --git a/packages/manager/apps/pci-kubernetes/src/components/quantity-selector/QuantitySelector.component.tsx b/packages/manager/apps/pci-kubernetes/src/components/quantity-selector/QuantitySelector.component.tsx new file mode 100644 index 000000000000..30f73a793d3a --- /dev/null +++ b/packages/manager/apps/pci-kubernetes/src/components/quantity-selector/QuantitySelector.component.tsx @@ -0,0 +1,117 @@ +import { useTranslation } from 'react-i18next'; + +import { + FormField, + FormFieldError, + FormFieldHelper, + FormFieldLabel, + Icon, + Popover, + PopoverContent, + PopoverTrigger, + Quantity, + QuantityControl, + QuantityInput, + Text, +} from '@ovhcloud/ods-react'; + +export interface QuantitySelectorProps { + label?: string; + labelHelpText?: string; + description?: string; + value: number; + onValueChange: (value: number) => void; + min?: number; + max?: number; + className?: string; + contentClassName?: string; + id?: string; +} + +export function QuantitySelector({ + label, + labelHelpText, + description, + value, + onValueChange, + min, + max, + className, + contentClassName, + id = 'quantity-selector', + ...props +}: Readonly) { + const { t } = useTranslation('quantity-selector'); + + const error = { + min: min !== undefined && value < min, + max: max !== undefined && value > max, + nan: Number.isNaN(value), + }; + + return ( + e)} + className={className} + {...props} + > + {label && ( + + {label} + + {labelHelpText && ( + + + + + + {labelHelpText} + + + )} + + )} + + { + onValueChange(Number.isNaN(valueAsNumber) ? 0 : valueAsNumber); + }} + min={min} + max={max} + > + + + + + + {description && ( + + {description} + + )} + {error.min && ( + + + {t('common_field_error_min', { min })} + + + )} + {error.max && ( + + + {t('common_field_error_max', { max })} + + + )} + {error.nan && ( + + + {t('common_field_error_number')} + + + )} + + ); +} diff --git a/packages/manager/apps/pci-kubernetes/src/components/region-selector/KubeDeploymentTile.tsx b/packages/manager/apps/pci-kubernetes/src/components/region-selector/KubeDeploymentTile.tsx index 0cfcee66080f..f8e9213d0bae 100644 --- a/packages/manager/apps/pci-kubernetes/src/components/region-selector/KubeDeploymentTile.tsx +++ b/packages/manager/apps/pci-kubernetes/src/components/region-selector/KubeDeploymentTile.tsx @@ -36,7 +36,7 @@ export function KubeDeploymentTile({

diff --git a/packages/manager/apps/pci-kubernetes/src/helpers/index.ts b/packages/manager/apps/pci-kubernetes/src/helpers/index.ts index d7968d9e9058..9a68a6b4c008 100644 --- a/packages/manager/apps/pci-kubernetes/src/helpers/index.ts +++ b/packages/manager/apps/pci-kubernetes/src/helpers/index.ts @@ -196,6 +196,23 @@ export function generateUniqueName(baseName: string, existingNodePools: NodePool return newName; } +/** + * If the name already exists in the node pools array, we throw an error. + * + * @param {string} baseName - The desired base name for the node pool. + * @param {Array<{name: string}>} existingNodePools - Array of existing node pools. + */ +export function ensureNameIsUnique(baseName: string, existingNodePools: NodePoolPrice[]) { + const isNameTaken = (pool: NodePool) => pool.name === baseName; + + for (let pool of existingNodePools) { + if (isNameTaken(pool)) { + throw new Error('name already exists'); + } + } + return true; +} + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/packages/manager/apps/pci-kubernetes/src/helpers/node-pool.spec.ts b/packages/manager/apps/pci-kubernetes/src/helpers/node-pool.spec.ts index a9a660765121..2d3140c05e2d 100644 --- a/packages/manager/apps/pci-kubernetes/src/helpers/node-pool.spec.ts +++ b/packages/manager/apps/pci-kubernetes/src/helpers/node-pool.spec.ts @@ -1,15 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; import { DeploymentMode, NodePoolState } from '@/types'; -import { TRegionInformations } from '@/types/region'; -import * as deploymentUtils from '.'; import { exceedsMaxNodes, hasInvalidScalingOrAntiAffinityConfig, hasMax5NodesAntiAffinity, isScalingValid, - zoneAZisChecked, + isZoneAzChecked, } from './node-pool'; vi.mock('@/constants', () => ({ @@ -17,10 +15,6 @@ vi.mock('@/constants', () => ({ ANTI_AFFINITY_MAX_NODES: 5, })); -vi.mock('.', () => ({ - isMonoDeploymentZone: vi.fn(), -})); - describe('exceedsMaxNodes', () => { it.each([ [11, true], @@ -33,31 +27,26 @@ describe('exceedsMaxNodes', () => { describe('zoneAZisChecked', () => { it('returns true if mono deployment zone', () => { - vi.mocked(deploymentUtils.isMonoDeploymentZone).mockReturnValue(true); - expect( - zoneAZisChecked( - { type: DeploymentMode.MONO_ZONE } as TRegionInformations, - {} as NodePoolState, - ), - ).toBe(true); + expect(isZoneAzChecked(DeploymentMode.MONO_ZONE, {} as NodePoolState)).toBe(true); }); it('returns true if availability zone is selected', () => { - vi.mocked(deploymentUtils.isMonoDeploymentZone).mockReturnValue(false); const nodePoolState = { - selectedAvailabilityZone: 'zone-a', + selectedAvailabilityZones: [{ zone: 'zone-a', checked: true }], + } as NodePoolState; + expect(isZoneAzChecked(DeploymentMode.MULTI_ZONES, nodePoolState)).toBe(true); + }); + it('returns false if availability zone is not selected', () => { + const nodePoolState = { + selectedAvailabilityZones: [{ zone: 'zone-a', checked: false }], } as NodePoolState; - expect( - zoneAZisChecked({ type: DeploymentMode.MULTI_ZONES } as TRegionInformations, nodePoolState), - ).toBe(true); + + expect(isZoneAzChecked(DeploymentMode.MULTI_ZONES, nodePoolState)).toBe(false); }); it('returns false otherwise', () => { - vi.mocked(deploymentUtils.isMonoDeploymentZone).mockReturnValue(false); const nodePoolState = {} as NodePoolState; - expect( - zoneAZisChecked({ type: DeploymentMode.MULTI_ZONES } as TRegionInformations, nodePoolState), - ).toBe(false); + expect(isZoneAzChecked(DeploymentMode.MULTI_ZONES, nodePoolState)).toBe(false); }); }); @@ -96,47 +85,58 @@ describe('hasInvalidScalingOrAntiAffinityConfig', () => { const nodePoolState = { scaling: { isAutoscale: false, quantity: { desired: 11 } }, antiAffinity: false, - selectedAvailabilityZone: 'zone', + selectedAvailabilityZones: [{ zone: 'zone-1', checked: false }], } as NodePoolState; - vi.mocked(deploymentUtils.isMonoDeploymentZone).mockReturnValue(false); - const region = { type: DeploymentMode.MULTI_ZONES } as TRegionInformations; - expect(hasInvalidScalingOrAntiAffinityConfig(region, nodePoolState)).toBe(true); + expect(hasInvalidScalingOrAntiAffinityConfig(DeploymentMode.MULTI_ZONES, nodePoolState)).toBe( + true, + ); }); it('returns true if antiAffinity config is invalid', () => { const nodePoolState = { scaling: { isAutoscale: false, quantity: { desired: 6 } }, antiAffinity: true, - selectedAvailabilityZone: 'zone', + selectedAvailabilityZones: [{ zone: 'zone', checked: false }], } as NodePoolState; - vi.mocked(deploymentUtils.isMonoDeploymentZone).mockReturnValue(false); - const region = { type: DeploymentMode.MULTI_ZONES } as TRegionInformations; - expect(hasInvalidScalingOrAntiAffinityConfig(region, nodePoolState)).toBe(true); + expect(hasInvalidScalingOrAntiAffinityConfig(DeploymentMode.MULTI_ZONES, nodePoolState)).toBe( + true, + ); }); it('returns true if zone is not selected and not mono', () => { const nodePoolState = { scaling: { isAutoscale: false, quantity: { desired: 5 } }, antiAffinity: false, - selectedAvailabilityZone: undefined, + selectedAvailabilityZones: undefined, } as NodePoolState; - vi.mocked(deploymentUtils.isMonoDeploymentZone).mockReturnValue(false); - const region = { type: DeploymentMode.MULTI_ZONES } as TRegionInformations; - expect(hasInvalidScalingOrAntiAffinityConfig(region, nodePoolState)).toBe(true); + expect(hasInvalidScalingOrAntiAffinityConfig(DeploymentMode.MULTI_ZONES, nodePoolState)).toBe( + true, + ); }); it('returns false if everything is valid', () => { const nodePoolState = { scaling: { isAutoscale: false, quantity: { desired: 5 } }, antiAffinity: false, - selectedAvailabilityZone: 'zone', + selectedAvailabilityZones: [{ zone: 'zone', checked: true }], + } as NodePoolState; + + expect(hasInvalidScalingOrAntiAffinityConfig(DeploymentMode.MULTI_ZONES, nodePoolState)).toBe( + false, + ); + }); + it('returns false if no checkbox is checked', () => { + const nodePoolState = { + scaling: { isAutoscale: false, quantity: { desired: 5 } }, + antiAffinity: false, + selectedAvailabilityZones: [{ zone: 'zone', checked: false }], } as NodePoolState; - vi.mocked(deploymentUtils.isMonoDeploymentZone).mockReturnValue(false); - const region = { type: DeploymentMode.MULTI_ZONES } as TRegionInformations; - expect(hasInvalidScalingOrAntiAffinityConfig(region, nodePoolState)).toBe(false); + expect(hasInvalidScalingOrAntiAffinityConfig(DeploymentMode.MULTI_ZONES, nodePoolState)).toBe( + true, + ); }); }); diff --git a/packages/manager/apps/pci-kubernetes/src/helpers/node-pool.ts b/packages/manager/apps/pci-kubernetes/src/helpers/node-pool.ts index 0bd670aeca2c..080d58a66dfa 100644 --- a/packages/manager/apps/pci-kubernetes/src/helpers/node-pool.ts +++ b/packages/manager/apps/pci-kubernetes/src/helpers/node-pool.ts @@ -1,15 +1,13 @@ import { ANTI_AFFINITY_MAX_NODES, NODE_RANGE } from '@/constants'; -import { NodePoolState } from '@/types'; -import { TRegionInformations } from '@/types/region'; +import { DeploymentMode, NodePoolState } from '@/types'; import { isMonoDeploymentZone } from '.'; export const exceedsMaxNodes = (quantity: number) => quantity > NODE_RANGE.MAX; -export const zoneAZisChecked = ( - regionInformations: TRegionInformations, - nodePoolState: NodePoolState, -) => isMonoDeploymentZone(regionInformations?.type) || !!nodePoolState.selectedAvailabilityZone; +export const isZoneAzChecked = (type: DeploymentMode, nodePoolState: NodePoolState) => + !!isMonoDeploymentZone(type) || + !!nodePoolState.selectedAvailabilityZones?.some((zone) => zone.checked); export const isScalingValid = (nodePoolState: Pick) => { if (!nodePoolState.scaling) return true; @@ -36,9 +34,9 @@ export const hasMax5NodesAntiAffinity = (nodePoolState: NodePoolState) => false; export const hasInvalidScalingOrAntiAffinityConfig = ( - regionInformations: TRegionInformations, + type: DeploymentMode, nodePoolState: NodePoolState, ) => !isScalingValid(nodePoolState) || !hasMax5NodesAntiAffinity(nodePoolState) || - !zoneAZisChecked(regionInformations, nodePoolState); + !isZoneAzChecked(type, nodePoolState); diff --git a/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/Scale.page.tsx b/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/Scale.page.tsx index ff51e03bb06f..d40b2085516e 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/Scale.page.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/Scale.page.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useMemo, useState } from 'react'; +import { ReactElement, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -8,6 +8,7 @@ import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; import { ODS_BUTTON_VARIANT, ODS_SPINNER_SIZE } from '@ovhcloud/ods-components'; import { OsdsButton, OsdsModal, OsdsSpinner } from '@ovhcloud/ods-components/react'; +import { isApiCustomError } from '@ovh-ux/manager-core-api'; import { useParam as useSafeParams } from '@ovh-ux/manager-pci-common'; import { useNotifications } from '@ovh-ux/manager-react-components'; @@ -16,13 +17,13 @@ import { Autoscaling } from '@/components/Autoscaling.component'; import { NODE_RANGE } from '@/constants'; import { isScalingValid } from '@/helpers/node-pool'; import { useTrack } from '@/hooks/track'; -import queryClient from '@/queryClient'; +import { queryClient } from '@/queryClient'; import { TScalingState } from '@/types'; export default function ScalePage(): ReactElement { const { projectId, kubeId: clusterId } = useSafeParams('projectId', 'kubeId'); const [searchParams] = useSearchParams(); - const poolId = searchParams.get('nodePoolId'); + const poolId = searchParams.get('nodePoolId') as string; const navigate = useNavigate(); const goBack = () => navigate('..'); @@ -34,33 +35,41 @@ export default function ScalePage(): ReactElement { const { trackClick } = useTrack(); - const [state, setState] = useState(null); - - const { data: pools, isPending: isPoolsPending } = useClusterNodePools(projectId, clusterId); - - const pool = useMemo(() => pools?.find((p) => p.id === poolId), [pools, poolId]); + const { data: pool, isPending: isPoolsPending } = useClusterNodePools( + projectId, + clusterId, + (pools) => { + const pool = pools?.find((p) => p.id === poolId); + return pool + ? { + scalingState: { + quantity: { + desired: pool.desiredNodes, + min: pool.minNodes, + max: pool.maxNodes, + }, + antiAffinity: pool.antiAffinity, + monthlyBilled: pool.monthlyBilled, + isAutoscale: pool.autoscale, + } as TScalingState, + original: pool, + } + : null; + }, + ); - useEffect(() => { - if (pool) { - setState({ - quantity: { - desired: pool.desiredNodes, - min: pool.minNodes, - max: pool.maxNodes, - }, - isAutoscale: pool.autoscale, - }); - } - }, [pool]); + const [state, setState] = useState(pool?.scalingState ?? null); const { updateSize, isPending: isPendingScaling } = useUpdateNodePoolSize({ - onError(cause: Error & { response: { data: { message: string } } }): void { - addError( - tScale('kube_node_pool_autoscaling_scale_error', { - message: cause?.response?.data?.message, - }), - true, - ); + onError(cause: Error) { + if (isApiCustomError(cause)) { + addError( + tScale('kube_node_pool_autoscaling_scale_error', { + message: cause.response?.data.message, + }), + true, + ); + } goBack(); }, onSuccess: async () => { @@ -79,29 +88,19 @@ export default function ScalePage(): ReactElement { const isDisabled = isPendingScaling || (state && !isScalingValid({ scaling: state })); const scaleObject = useMemo(() => { - const desired = Number(state?.quantity.desired); - const minNodes = Number(pool?.minNodes); - const maxNodes = Number(pool?.maxNodes); + const { desired, min, max } = state?.quantity || {}; + const d = Number(desired), + mn = Number(min), + mx = Number(max); if (state?.isAutoscale) { - return { - maxNodes: state?.quantity.max || NODE_RANGE.MAX, - minNodes: state?.quantity.min || 0, - }; + return { maxNodes: mx || NODE_RANGE.MAX, minNodes: mn || 0 }; } - if (desired < minNodes) { - return { - minNodes: desired, - }; - } - if (desired > maxNodes) { - return { - maxNodes: desired, - }; - } + if (d < mn) return { minNodes: d }; + if (d > mx) return { maxNodes: d }; return {}; - }, [state?.quantity, pool, state?.isAutoscale]); + }, [state?.quantity, state?.isAutoscale]); return ( {!isPoolsPending && !isPendingScaling ? ( setState(s)} + initialScaling={state?.quantity} + isMonthlyBilling={pool?.original.monthlyBilled} + isAntiAffinity={pool?.original.antiAffinity} + isAutoscale={!!state?.isAutoscale} + onChange={setState} /> ) : ( diff --git a/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/New.page.tsx b/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/New.page.tsx index 7303c7cb26b1..1e01f6db7400 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/New.page.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/New.page.tsx @@ -29,7 +29,6 @@ import { useKubernetesCluster } from '@/api/hooks/useKubernetes'; import { useRegionInformations } from '@/api/hooks/useRegionInformations'; import BillingStep, { TBillingStepProps } from '@/components/create/BillingStep.component'; import { FlavorSelector } from '@/components/flavor-selector/FlavorSelector.component'; -import { isMultiDeploymentZones } from '@/helpers'; import { hasInvalidScalingOrAntiAffinityConfig } from '@/helpers/node-pool'; import { useTrack } from '@/hooks/track'; import useMergedFlavorById, { getPriceByDesiredScale } from '@/hooks/useMergedFlavorById'; @@ -138,7 +137,7 @@ export default function NewPage(): ReactElement { }, })); } - }, [store.flavor, store.isMonthlyBilling, store.scaling, isCatalogPending]); + }, [store.flavor, store.isMonthlyBilling, store.scaling, isCatalogPending, catalog?.addons]); const create = () => { trackClick(`details::nodepools::add::confirm`); @@ -150,8 +149,10 @@ export default function NewPage(): ReactElement { const param: TCreateNodePoolParam = { flavorName: store.flavor?.name || '', - ...(store.selectedAvailabilityZone && { - availabilityZones: [store.selectedAvailabilityZone], + ...(store.selectedAvailabilityZones && { + availabilityZones: store.selectedAvailabilityZones + .filter(({ checked }) => checked) + .map(({ zone }) => zone), }), name: store.name.value, antiAffinity: store.antiAffinity, @@ -206,6 +207,17 @@ export default function NewPage(): ReactElement { if (e.detail.value) store.set.name(e.detail.value); }; + useEffect(() => { + if (regionInformations?.availabilityZones.length) { + store.set.selectedAvailabilityZones( + regionInformations?.availabilityZones.map((zone) => ({ + zone, + checked: false, + })), + ); + } + }, [regionInformations?.availabilityZones, store.set]); + return ( <> @@ -349,12 +361,12 @@ export default function NewPage(): ReactElement { label: t('common_stepper_next_button_label'), isDisabled: !!( regionInformations && - hasInvalidScalingOrAntiAffinityConfig(regionInformations, { + hasInvalidScalingOrAntiAffinityConfig(regionInformations.type, { name: store.name.value, isTouched: store.name.isTouched, scaling: store.scaling, antiAffinity: store.antiAffinity, - selectedAvailabilityZone: store.selectedAvailabilityZone, + selectedAvailabilityZones: store.selectedAvailabilityZones, }) ), }} @@ -365,18 +377,20 @@ export default function NewPage(): ReactElement { label: t('common_stepper_modify_this_step'), }} > - {regionInformations?.type && isMultiDeploymentZones(regionInformations.type) ? ( + {store.selectedAvailabilityZones ? (
) : (
)} store.set.scaling(auto)} antiAffinity={billingState.antiAffinity.isChecked} diff --git a/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/store.spec.ts b/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/store.spec.ts index eecdf8679753..ed4150d34648 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/store.spec.ts +++ b/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/store.spec.ts @@ -67,13 +67,13 @@ describe('NewPoolStore', () => { it('should check the right step', () => { const { result } = renderHook(() => useNewPoolStore()); act(() => result.current.check(StepsEnum.BILLING)); - expect(result.current.steps.get(StepsEnum.BILLING).isChecked).toBe(true); + expect(result.current.steps.get(StepsEnum.BILLING)?.isChecked).toBe(true); }); it('should uncheck the right step', () => { const { result } = renderHook(() => useNewPoolStore()); act(() => result.current.uncheck(StepsEnum.BILLING)); - expect(result.current.steps.get(StepsEnum.BILLING).isChecked).toBe(false); + expect(result.current.steps.get(StepsEnum.BILLING)?.isChecked).toBe(false); }); }); @@ -87,7 +87,7 @@ describe('NewPoolStore', () => { it('should unlock the right step', () => { const { result } = renderHook(() => useNewPoolStore()); act(() => result.current.unlock(StepsEnum.BILLING)); - expect(result.current.steps.get(StepsEnum.BILLING).isLocked).toBe(false); + expect(result.current.steps.get(StepsEnum.BILLING)?.isLocked).toBe(false); }); }); @@ -117,7 +117,7 @@ describe('NewPoolStore', () => { name: state.name, flavor: state.flavor, scaling: state.scaling, - selectedAvailabilityZone: state.selectedAvailabilityZone, + selectedAvailabilityZones: state.selectedAvailabilityZones, antiAffinity: state.antiAffinity, isMonthlyBilling: state.isMonthlyBilling, steps: state.steps, @@ -141,7 +141,7 @@ describe('NewPoolStore', () => { flavor: undefined, isMonthlyBilling: false, name: { value: '', hasError: false, isTouched: false }, - selectedAvailabilityZone: null, + selectedAvailabilityZones: null, steps: new Map([ [ 'NAME', @@ -201,7 +201,7 @@ describe('NewPoolStore', () => { flavor: undefined, isMonthlyBilling: false, name: { hasError: false, isTouched: false, value: '' }, - selectedAvailabilityZone: null, + selectedAvailabilityZones: null, steps: new Map([ [ 'NAME', @@ -265,7 +265,7 @@ describe('NewPoolStore', () => { isTouched: false, value: '', }, - selectedAvailabilityZone: null, + selectedAvailabilityZones: null, steps: new Map([ [ 'NAME', diff --git a/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/store.ts b/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/store.ts index cc60a50bbe7b..da6105716f91 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/store.ts +++ b/packages/manager/apps/pci-kubernetes/src/pages/detail/nodepools/new/store.ts @@ -22,7 +22,7 @@ export type TFormStore = { hasError: boolean; }; flavor?: TComputedKubeFlavor; - selectedAvailabilityZone: string | null; + selectedAvailabilityZones: { zone: string; checked: boolean }[] | null; scaling: TScalingState; antiAffinity: boolean; isMonthlyBilling: boolean; @@ -30,7 +30,7 @@ export type TFormStore = { set: { name: (val: string) => void; flavor: (val?: TComputedKubeFlavor) => void; - selectedAvailabilityZone: (selectedZone: string) => void; + selectedAvailabilityZones: (val: { zone: string; checked: boolean }[]) => void; scaling: (val: TScalingState) => void; antiAffinity: (val: boolean) => void; isMonthlyBilling: (val: boolean) => void; @@ -106,12 +106,12 @@ export const useNewPoolStore = create()((set, get) => ({ scaling: initScale, antiAffinity: false, isMonthlyBilling: false, - selectedAvailabilityZone: null, + selectedAvailabilityZones: null, steps: initialSteps(), set: { - selectedAvailabilityZone: (val: string) => { + selectedAvailabilityZones: (val: { zone: string; checked: boolean }[]) => { set({ - selectedAvailabilityZone: val, + selectedAvailabilityZones: val, }); }, name: (val: string) => { @@ -255,7 +255,7 @@ export const useNewPoolStore = create()((set, get) => ({ flavor: undefined, scaling: initScale, antiAffinity: false, - selectedAvailabilityZone: null, + selectedAvailabilityZones: null, isMonthlyBilling: false, steps: initialSteps(), })); diff --git a/packages/manager/apps/pci-kubernetes/src/pages/list/List.page.tsx b/packages/manager/apps/pci-kubernetes/src/pages/list/List.page.tsx index 43526c2f7fd5..4ecdeb4ceccb 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/list/List.page.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/list/List.page.tsx @@ -61,6 +61,7 @@ export default function ListPage() { const [searchField, setSearchField] = useState(''); const filterPopoverRef = useRef(undefined); const featureFlipping3az = use3AZPlanAvailable(); + const [showStorageModal, setShowStorageModal] = useState(true); const { data: allKube, isPending } = useKubes(projectId, pagination, filters); @@ -101,7 +102,9 @@ export default function ListPage() {
- {featureFlipping3az && } + {featureFlipping3az && showStorageModal && ( + setShowStorageModal(false)} /> + )}
{ +const FileStorageAlert = (props: { onRemove?: () => void }) => { const { t } = useTranslation('listing'); return ( - +
diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/hooks/useCreateNodePool.ts b/packages/manager/apps/pci-kubernetes/src/pages/new/hooks/useCreateNodePool.ts new file mode 100644 index 000000000000..483a5651d796 --- /dev/null +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/hooks/useCreateNodePool.ts @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useParam } from '@ovh-ux/manager-pci-common'; +import { convertHourlyPriceToMonthly } from '@ovh-ux/manager-react-components'; + +import { useRegionInformations } from '@/api/hooks/useRegionInformations'; +import { TComputedKubeFlavor } from '@/components/flavor-selector/FlavorSelector.component'; +import { NODE_RANGE, TAGS_BLOB } from '@/constants'; +import { ensureNameIsUnique, generateUniqueName } from '@/helpers'; +import { hasInvalidScalingOrAntiAffinityConfig } from '@/helpers/node-pool'; +import useMergedFlavorById, { getPriceByDesiredScale } from '@/hooks/useMergedFlavorById'; +import { NodePoolPrice, NodePoolState } from '@/types'; + +import { useNodePoolErrors } from './useNodePoolErrors'; + +function generateUniqueNameWithZone( + baseName: string, + zoneName: string | null, + existingNodePools: NodePoolPrice[], + multipleNodes: boolean, +) { + const zoneSuffix = zoneName?.split('-').pop(); + const nameWithSuffix = zoneSuffix && multipleNodes ? `${baseName}-${zoneSuffix}` : baseName; + + ensureNameIsUnique(nameWithSuffix, existingNodePools); + + return generateUniqueName(nameWithSuffix, existingNodePools); +} + +function canSubmitNodePools( + isStepUnlocked: boolean, + nodePoolEnabled: boolean, + nodes: NodePoolPrice[] | null, +): boolean { + if (!isStepUnlocked) return false; + + if (!nodePoolEnabled) return true; + + return Array.isArray(nodes) && nodes.length > 0; +} + +const useCreateNodePools = ({ name, isLocked }: { name?: string; isLocked: boolean }) => { + const [nodes, setNodes] = useState(null); + const [selectedFlavor, setSelectedFlavor] = useState(null); + + const [isMonthlyBilled, setIsMonthlyBilled] = useState(false); + const [nodePoolState, setNodePoolState] = useState({ + antiAffinity: false, + name: '', + isTouched: false, + scaling: { + quantity: { desired: NODE_RANGE.MIN, min: 0, max: NODE_RANGE.MAX }, + isAutoscale: false, + }, + }); + const { projectId } = useParam('projectId'); + const [nodePoolEnabled, setNodePoolEnabled] = useState(true); + const { data: regionInformations } = useRegionInformations(projectId, name ?? null); + + const { error, isValidName, handleCreationError, clearExistsError } = useNodePoolErrors( + nodePoolState, + nodes, + ); + + const price = useMergedFlavorById<{ hour: number; month?: number } | null>( + projectId, + name ?? null, + selectedFlavor?.id ?? null, + { + select: (flavor) => + getPriceByDesiredScale( + flavor.pricingsHourly?.price, + flavor.pricingsMonthly?.price, + nodePoolState.scaling?.quantity.desired, + ), + }, + ); + + const createNodePool = useCallback(() => { + if (!nodes || !nodePoolState.scaling || !selectedFlavor) return; + + const selectedZones = nodePoolState.selectedAvailabilityZones?.filter((zone) => zone.checked); + + const createDataNodePool = ({ zone }: { zone?: string } = {}): NodePoolPrice => ({ + name: generateUniqueNameWithZone( + nodePoolState.name, + zone ?? null, + nodes, + !!(selectedZones?.length && selectedZones?.length >= 2), + ), + antiAffinity: nodePoolState.antiAffinity, + autoscale: nodePoolState.scaling.isAutoscale, + ...(zone && { availabilityZones: [zone] }), + localisation: zone ?? name ?? null, + desiredNodes: nodePoolState.scaling.quantity.desired, + ...(nodePoolState.scaling.isAutoscale && { + minNodes: nodePoolState.scaling.quantity.min, + maxNodes: nodePoolState.scaling.quantity.max, + }), + flavorName: selectedFlavor.name ?? '', + monthlyPrice: isMonthlyBilled + ? (price?.month ?? 0) + : convertHourlyPriceToMonthly(price?.hour ?? 0), + monthlyBilled: isMonthlyBilled, + }); + + try { + const newNodePools = selectedZones?.length + ? selectedZones.filter((zone) => zone.checked).map(createDataNodePool) + : [createDataNodePool()]; + + setNodes([...nodes, ...newNodePools]); + + setNodePoolState((state) => ({ + ...state, + name: '', + isTouched: false, + })); + clearExistsError(); + } catch (err) { + handleCreationError(err); + } + }, [ + nodes, + nodePoolState, + selectedFlavor, + name, + isMonthlyBilled, + price, + clearExistsError, + handleCreationError, + ]); + + const isNodePoolValid = + !nodePoolEnabled || (Boolean(selectedFlavor) && isValidName && !error?.exists); + + const isButtonDisabled = + !isNodePoolValid || + (regionInformations && + hasInvalidScalingOrAntiAffinityConfig(regionInformations?.type, nodePoolState)); + + const isPricingComingSoon = selectedFlavor?.blobs?.tags?.includes(TAGS_BLOB.COMING_SOON); + + const isStepUnlocked = !isLocked; + + const canSubmit = canSubmitNodePools(isStepUnlocked, nodePoolEnabled, nodes); + + useEffect(() => setIsMonthlyBilled(false), [selectedFlavor]); + + useEffect(() => { + if (regionInformations?.availabilityZones.length) { + setNodePoolState((state) => ({ + ...state, + selectedAvailabilityZones: regionInformations?.availabilityZones.map((zone) => ({ + zone, + checked: true, + })), + })); + } + }, [regionInformations?.availabilityZones, setNodePoolState]); + + useEffect(() => { + setNodes(!nodePoolEnabled ? null : []); + if (!nodePoolEnabled) { + setNodePoolState((state) => ({ + ...state, + + name: '', + isTouched: false, + })); + } + }, [nodePoolEnabled, setNodePoolState, setNodes]); + + const onDelete = useCallback( + (nameToDelete: string) => nodes && setNodes(nodes.filter((node) => node.name !== nameToDelete)), + [nodes], + ); + + return { + state: { + nodes, + nodePoolState, + selectedFlavor, + isMonthlyBilled, + nodePoolEnabled, + price, + }, + + actions: { + setNodes, + setNodePoolState, + setSelectedFlavor, + setIsMonthlyBilled, + setNodePoolEnabled, + createNodePool, + onDelete, + }, + + view: { + isValidName, + error: error ? Object.values(error).find((err) => err) : null, + isNodePoolValid, + isButtonDisabled, + isPricingComingSoon, + isStepUnlocked, + canSubmit, + }, + }; +}; + +export default useCreateNodePools; diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/hooks/useNodePoolErrors.ts b/packages/manager/apps/pci-kubernetes/src/pages/new/hooks/useNodePoolErrors.ts new file mode 100644 index 000000000000..b8f57961a6da --- /dev/null +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/hooks/useNodePoolErrors.ts @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; + +import { ensureNameIsUnique } from '@/helpers'; +import { isNodePoolNameValid } from '@/helpers/matchers/matchers'; +import { NodePoolPrice, NodePoolState } from '@/types'; + +function generateUniqueNameWithZone( + baseName: string, + zoneName: string | null, + existingNodePools: NodePoolPrice[], + multipleNodes: boolean, +) { + const zoneSuffix = zoneName?.split('-').pop(); + const nameWithSuffix = zoneSuffix && multipleNodes ? `${baseName}-${zoneSuffix}` : baseName; + ensureNameIsUnique(nameWithSuffix, existingNodePools); +} + +function validateNodePoolNameUniqueness({ + nodePoolName, + selectedAvailabilityZones, + nodes, +}: { + nodePoolName: string; + selectedAvailabilityZones?: { zone: string; checked: boolean }[] | null; + nodes: NodePoolPrice[]; +}) { + if (!selectedAvailabilityZones?.length) { + ensureNameIsUnique(nodePoolName, nodes); + return; + } + + const filterZones = selectedAvailabilityZones + .filter((zone) => zone.checked) + .map(({ zone }) => zone); + + if (!filterZones.length) { + ensureNameIsUnique(nodePoolName, nodes); + return; + } + + filterZones.forEach((zoneName: string) => { + generateUniqueNameWithZone(nodePoolName, zoneName, nodes, filterZones.length > 1); + }); +} + +export function useNodePoolErrors(nodePoolState: NodePoolState, nodes: NodePoolPrice[] | null) { + const [error, setError] = useState<{ valid?: string | null; exists?: string | null } | null>( + null, + ); + + const isValidName = isNodePoolNameValid(nodePoolState.name); + + useEffect(() => { + if (!nodes?.length) { + return setError((prevError) => ({ ...prevError, exists: null })); + } + try { + validateNodePoolNameUniqueness({ + nodePoolName: nodePoolState.name, + selectedAvailabilityZones: nodePoolState.selectedAvailabilityZones, + nodes, + }); + setError((prevError) => ({ ...prevError, exists: null })); + } catch (err) { + setError((prevError) => ({ + ...prevError, + exists: 'kube_add_node_pool_name_already_exist_validation_error', + })); + } + }, [nodePoolState.selectedAvailabilityZones, nodePoolState.name, nodes]); + + useEffect(() => { + setError((prevError) => { + if (!isValidName && nodePoolState.isTouched) { + return { ...prevError, valid: 'kube_add_node_pool_name_input_pattern_validation_error' }; + } + return { ...prevError, valid: null }; + }); + }, [isValidName, nodePoolState.isTouched]); + + const handleCreationError = (err: unknown) => { + if (err instanceof Error && err.message === 'name already exists') { + setError((prevError) => ({ + ...prevError, + exists: 'kube_add_node_pool_name_already_exist_validation_error', + })); + } + }; + + const clearExistsError = () => { + setError((prevError) => ({ + ...prevError, + exists: null, + })); + }; + + return { + error, + isValidName, + handleCreationError, + clearExistsError, + }; +} diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/NodePoolStep.component.spec.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/NodePoolStep.component.spec.tsx index 6b286ccaaf87..bbbf49620240 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/NodePoolStep.component.spec.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/NodePoolStep.component.spec.tsx @@ -1,4 +1,4 @@ -import { QueryObserverSuccessResult } from '@tanstack/react-query'; +import { QueryObserver, QueryObserverSuccessResult, UseQueryResult } from '@tanstack/react-query'; import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -49,9 +49,9 @@ describe('NodePoolStep Component', () => { vi.mocked(useRegionInformations).mockReturnValue({ data: { type: DeploymentMode.MONO_ZONE, - availabilityZones: ['zone-a', 'zone-b'], + availabilityZones: [''], }, - } as QueryObserverSuccessResult); + } as QueryObserverSuccessResult); }); it('should render without error', () => { @@ -67,6 +67,18 @@ describe('NodePoolStep Component', () => { toTest.forEach((test) => expect(screen.getByText(test)).toBeInTheDocument()); }); + it('should render singular button', async () => { + vi.mocked(useRegionInformations).mockReturnValue({ + data: { + type: DeploymentMode.MULTI_ZONES, + availabilityZones: ['zone-1', 'zone-2'], + }, + } as QueryObserverSuccessResult); + render(, { wrapper }); + + expect(screen.getByText('node-pool:kube_common_add_node_pool_plural')).toBeInTheDocument(); + }); + it('should render zones', () => { render(, { wrapper }); }); diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/NodePoolStep.component.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/NodePoolStep.component.tsx index 1bbab8c8b806..55564e72afd1 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/NodePoolStep.component.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/NodePoolStep.component.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -12,20 +12,13 @@ import { import { OsdsButton, OsdsText } from '@ovhcloud/ods-components/react'; import { useParam as useSafeParams } from '@ovh-ux/manager-pci-common'; -import { Datagrid, convertHourlyPriceToMonthly } from '@ovh-ux/manager-react-components'; +import { Datagrid } from '@ovh-ux/manager-react-components'; -import { NodePoolPrice } from '@/api/data/kubernetes'; -import { useRegionInformations } from '@/api/hooks/useRegionInformations'; import BillingStep from '@/components/create/BillingStep.component'; -import { TComputedKubeFlavor } from '@/components/flavor-selector/FlavorSelector.component'; -import { NODE_RANGE, TAGS_BLOB } from '@/constants'; -import { generateUniqueName, isMultiDeploymentZones } from '@/helpers'; -import { isNodePoolNameValid } from '@/helpers/matchers/matchers'; -import { hasInvalidScalingOrAntiAffinityConfig } from '@/helpers/node-pool'; import use3AZPlanAvailable from '@/hooks/use3azPlanAvaible'; -import useMergedFlavorById, { getPriceByDesiredScale } from '@/hooks/useMergedFlavorById'; -import { NodePoolState, TScalingState } from '@/types'; +import { TScalingState } from '@/types'; +import useCreateNodePools from '../hooks/useCreateNodePool'; import { useClusterCreationStepper } from '../hooks/useCusterCreationStepper'; import DeploymentZone from './node-pool/DeploymentZone.component'; import NodePoolAntiAffinity from './node-pool/NodePoolAntiAffinity.component'; @@ -36,231 +29,134 @@ import NodePoolType from './node-pool/NodePoolType.component'; import { getDatagridColumns } from './node-pool/getDataGridColumns'; const NodePoolStep = ({ stepper }: { stepper: ReturnType }) => { - const { t } = useTranslation([ - 'stepper', - 'node-pool', - 'add', - 'kube-nodes', - 'autoscaling', - 'flavor-billing', - 'billing-anti-affinity', - ]); + const { t } = useTranslation(['stepper', 'node-pool']); - const [nodePoolState, setNodePoolState] = useState({ - antiAffinity: false, - name: '', - isTouched: false, - scaling: { - quantity: { desired: NODE_RANGE.MIN, min: 0, max: NODE_RANGE.MAX }, - isAutoscale: false, - }, + const { state, actions, view } = useCreateNodePools({ + isLocked: stepper.node.step.isLocked, + name: stepper.form.region?.name, }); - const isValidName = isNodePoolNameValid(nodePoolState.name); - - const hasError = nodePoolState.isTouched && !isValidName; - const [isMonthlyBilled, setIsMonthlyBilled] = useState(false); - const [selectedFlavor, setSelectedFlavor] = useState(null); - const featureFlipping3az = use3AZPlanAvailable(); - const [nodePoolEnabled, setNodePoolEnabled] = useState(true); - const [nodes, setNodes] = useState(null); - const onDelete = useCallback( - (nameToDelete: string) => nodes && setNodes(nodes.filter((node) => node.name !== nameToDelete)), - [nodes], - ); - const columns = useMemo(() => getDatagridColumns({ onDelete, t }), [onDelete, t]); - - const isNodePoolValid = !nodePoolEnabled || (Boolean(selectedFlavor) && isValidName); - - const { projectId } = useSafeParams('projectId'); - const price = useMergedFlavorById<{ hour: number; month?: number } | null>( - projectId, - stepper.form.region?.name ?? null, - selectedFlavor?.id ?? null, - { - select: (flavor) => - getPriceByDesiredScale( - flavor.pricingsHourly?.price, - flavor.pricingsMonthly?.price, - nodePoolState.scaling?.quantity.desired, - ), - }, + const columns = useMemo( + () => getDatagridColumns({ onDelete: actions.onDelete, t }), + [actions.onDelete, t], ); - const { data: regionInformations } = useRegionInformations( - projectId, - stepper.form.region?.name ?? null, - ); - - const isButtonDisabled = - !isNodePoolValid || - (regionInformations && - hasInvalidScalingOrAntiAffinityConfig(regionInformations, nodePoolState)); - - const isPricingComingSoon = selectedFlavor?.blobs?.tags?.includes(TAGS_BLOB.COMING_SOON); - - const isStepUnlocked = !stepper.node.step.isLocked; - - const canSubmit = - (isStepUnlocked && !nodePoolEnabled) || - (isStepUnlocked && nodePoolEnabled && Array.isArray(nodes) && nodes.length > 0); - - useEffect(() => setIsMonthlyBilled(false), [selectedFlavor]); - - useEffect(() => { - setNodes(!nodePoolEnabled ? null : []); - if (!nodePoolEnabled) { - setNodePoolState((state) => ({ - ...state, - - name: '', - isTouched: false, - })); - } - }, [nodePoolEnabled]); - - const setNewNodePool = useCallback(() => { - if (nodes && nodePoolState.scaling && selectedFlavor) { - const newNodePool: NodePoolPrice = { - name: generateUniqueName(nodePoolState.name, nodes), - antiAffinity: nodePoolState.antiAffinity, - autoscale: nodePoolState.scaling.isAutoscale, - ...(regionInformations?.type && - isMultiDeploymentZones(regionInformations.type) && - nodePoolState.selectedAvailabilityZone && { - availabilityZones: [nodePoolState.selectedAvailabilityZone], - }), - localisation: nodePoolState.selectedAvailabilityZone ?? stepper.form.region?.name ?? null, - desiredNodes: nodePoolState.scaling.quantity.desired, - ...(nodePoolState.scaling.isAutoscale && { - minNodes: nodePoolState.scaling.quantity.min, - maxNodes: nodePoolState.scaling.quantity.max, - }), - flavorName: selectedFlavor.name ?? '', + const { projectId } = useSafeParams('projectId'); - monthlyPrice: isMonthlyBilled - ? (price?.month ?? 0) - : convertHourlyPriceToMonthly(price?.hour ?? 0), - monthlyBilled: isMonthlyBilled, - }; - setNodePoolState((state) => ({ - ...state, - name: '', - isTouched: false, - })); - setNodes([...nodes, newNodePool]); - } - }, [ - nodePoolState, - nodes, - stepper.form.region?.name, - selectedFlavor, - isMonthlyBilled, - setNodePoolState, - setNodes, - ]); + const numberOfZoneSelected = state.nodePoolState.selectedAvailabilityZones?.filter( + ({ checked }) => checked, + ).length; return ( <> - {((!stepper.node.step.isLocked && nodePoolEnabled) || !nodePoolEnabled) && ( + {((!stepper.node.step.isLocked && state.nodePoolEnabled) || !state.nodePoolEnabled) && ( )}
-
+
{stepper.form.region?.name && ( )}
- {featureFlipping3az && - regionInformations?.type && - isMultiDeploymentZones(regionInformations.type) && ( -
- - setNodePoolState((state) => ({ - ...state, - selectedAvailabilityZone: zone, - })) - } - availabilityZones={regionInformations?.availabilityZones} - selectedAvailabilityZone={nodePoolState.selectedAvailabilityZone ?? ''} - /> -
- )} -
+ {featureFlipping3az && state.nodePoolState.selectedAvailabilityZones && ( +
+ + actions.setNodePoolState((prevState) => ({ + ...prevState, + selectedAvailabilityZones: zones, + })) + } + availabilityZones={state.nodePoolState.selectedAvailabilityZones} + /> +
+ )} +
- setNodePoolState((state) => ({ ...state, scaling })) + actions.setNodePoolState((prevState) => ({ ...prevState, scaling })) } - antiAffinity={nodePoolState.antiAffinity} + initialScaling={state.nodePoolState.scaling?.quantity} + antiAffinity={state.nodePoolState.antiAffinity} + isAutoscale={state.nodePoolState.scaling?.isAutoscale} + selectedAvailabilityZones={state.nodePoolState.selectedAvailabilityZones} />
- setNodePoolState((state) => ({ ...state, antiAffinity })) + actions.setNodePoolState((prevState) => ({ ...prevState, antiAffinity })) } />
e.checked).length ?? + null + } + price={state.price?.hour ?? null} + monthlyPrice={state.price?.month} monthlyBilling={{ - isComingSoon: isPricingComingSoon ?? false, - isChecked: isMonthlyBilled, - check: setIsMonthlyBilled, + isComingSoon: view.isPricingComingSoon ?? false, + isChecked: state.isMonthlyBilled, + check: actions.setIsMonthlyBilled, }} - warn={(nodePoolState.scaling?.isAutoscale && isMonthlyBilled) ?? false} + warn={(state.nodePoolState.scaling?.isAutoscale && state.isMonthlyBilled) ?? false} />
-
+
- setNodePoolState((state) => ({ ...state, isTouched })) + actions.setNodePoolState((prevState) => ({ ...prevState, isTouched })) + } + error={view.error} + onNameChange={(name: string) => + actions.setNodePoolState((prevState) => ({ ...prevState, name })) } - hasError={hasError} - onNameChange={(name: string) => setNodePoolState((state) => ({ ...state, name }))} - name={nodePoolState.name} + name={state.nodePoolState.name} />
- {!stepper.node.step.isLocked && nodePoolEnabled && ( + {!stepper.node.step.isLocked && state.nodePoolEnabled && ( - {t('node-pool:kube_common_add_node_pool')} + {numberOfZoneSelected && numberOfZoneSelected > 1 + ? t('node-pool:kube_common_add_node_pool_plural', { count: numberOfZoneSelected }) + : t('node-pool:kube_common_add_node_pool')} )} - {nodes && ( + {state.nodes && ( <> )} - {canSubmit && ( + {view.canSubmit && ( { - stepper.node.submit(nodes); + stepper.node.submit(state.nodes); }} className="mt-4 w-fit" size={ODS_BUTTON_SIZE.md} color={ODS_TEXT_COLOR_INTENT.primary} > - {t('common_stepper_next_button_label')} + {t('stepper:common_stepper_next_button_label')} )}
diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/PlanStep.component.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/PlanStep.component.tsx index f2c739f3557d..c323ba361be2 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/PlanStep.component.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/PlanStep.component.tsx @@ -1,11 +1,11 @@ import { FormEvent, useCallback, useMemo, useState } from 'react'; -import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import { Badge, Button, + Divider, Icon, Message, MessageBody, @@ -103,12 +103,7 @@ const PlanTile = ({ /> )}
- +
@@ -222,9 +217,7 @@ PlanTile.Header = function PlanTileHeader({
{t(title)}
diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/DeploymentZone.component.spec.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/DeploymentZone.component.spec.tsx index 3f7799d23b4c..c152f6296714 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/DeploymentZone.component.spec.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/DeploymentZone.component.spec.tsx @@ -1,54 +1,70 @@ -import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { vi } from 'vitest'; import DeploymentZone from './DeploymentZone.component'; +vi.mock('@/components/pciCard/PciCard.component', () => ({ + PciCard: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + describe('DeploymentZone component', () => { - const availabilityZones = ['zone1', 'zone2', 'zone3']; - const selectedAvailabilityZone = 'zone1'; - const setNodePoolState = vi.fn(); + const baseZones = [ + { zone: 'GRA1', checked: false }, + { zone: 'BHS5', checked: true }, + ]; - it('renders correctly', () => { - const { getByText } = render( + const setup = (props?: Partial>) => { + const onSelect = vi.fn(); + render( , ); + return { onSelect }; + }; - expect(getByText('kube_common_node_pool_deploy_title')).toBeInTheDocument(); - expect(getByText('kube_common_node_pool_deploy_description')).toBeInTheDocument(); - availabilityZones.forEach((zone) => { - expect(getByText(zone)).toBeInTheDocument(); - }); + it.each([ + { multiple: true, desc: 'renders checkboxes for multiple selection' }, + { multiple: false, desc: 'renders radios for single selection' }, + ])('should render correctly when $desc', ({ multiple }) => { + setup({ multiple }); + const inputs = screen.queryAllByRole('radio', { hidden: true }).length + ? screen.getAllByRole('radio', { hidden: true }) + : screen.getAllByRole('checkbox', { hidden: true }); + + expect(inputs.length).toBe(2); }); - it('calls setNodePoolState when a zone is clicked', () => { - const { getAllByRole } = render( - , - ); + it.each([ + { multiple: true, expectedKey: 'checkbox' }, + { multiple: false, expectedKey: 'radio' }, + ])('calls onSelect correctly when changing a $expectedKey', async ({ multiple }) => { + const { onSelect } = setup({ multiple }); - const buttons = getAllByRole('button'); - fireEvent.click(buttons[0]); + const label = screen.getByText('GRA1'); + await userEvent.click(label); - expect(setNodePoolState).toHaveBeenCalledWith('zone1'); + expect(onSelect).toHaveBeenCalledWith([ + expect.objectContaining({ zone: 'GRA1' }), + expect.objectContaining({ zone: 'BHS5' }), + ]); }); - it('highlights the selected zone', () => { - const { getAllByRole } = render( - , - ); + it('shows a validation message when multiple=true and no zones are selected', () => { + setup({ + multiple: true, + availabilityZones: [ + { zone: 'GRA1', checked: false }, + { zone: 'BHS5', checked: false }, + ], + }); - const buttons = getAllByRole('button'); - expect(buttons[0]).toHaveClass('selectedTileClass'); + expect(screen.getByText('kube_common_node_pool_select_zone')).toBeInTheDocument(); }); }); diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/DeploymentZone.component.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/DeploymentZone.component.tsx index beeb87d78e36..7962ee1d2c46 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/DeploymentZone.component.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/DeploymentZone.component.tsx @@ -1,68 +1,148 @@ import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; -import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; -import { ODS_TEXT_COLOR_INTENT, ODS_TEXT_LEVEL, ODS_TEXT_SIZE } from '@ovhcloud/ods-components'; -import { OsdsText, OsdsTile } from '@ovhcloud/ods-components/react'; +import { + Checkbox, + CheckboxControl, + CheckboxGroup, + CheckboxGroupProp, + CheckboxLabel, + Message, + MessageBody, + MessageIcon, + Radio, + RadioControl, + RadioGroup, + RadioGroupProp, + RadioLabel, + RadioValueChangeDetail, + TEXT_PRESET, + Text, +} from '@ovhcloud/ods-react'; -import { selectedTileClass, tileClass } from '../UpdatePolicySelector.component'; +import { PciCard } from '@/components/pciCard/PciCard.component'; +import { TSelectedAvailabilityZones } from '@/types'; + +type GetGroupPropsParams = { + multiple: boolean; + availabilityZones: TSelectedAvailabilityZones; + isInvalid: boolean; + handleCheckboxChange: (checkedValues: string[]) => void; + handleRadioChange: (checkedValues: RadioValueChangeDetail) => void; +}; type DeploymentZoneProps = { - onSelect: (zone: string) => void; - selectedAvailabilityZone: string; - availabilityZones: string[]; + onSelect: (zone: TSelectedAvailabilityZones) => void; + availabilityZones: TSelectedAvailabilityZones; + multiple: boolean; }; -const DeploymentZone = ({ - onSelect, - selectedAvailabilityZone, +function getGroupProps({ + multiple, availabilityZones, -}: DeploymentZoneProps) => { + isInvalid, + handleCheckboxChange, + handleRadioChange, +}: GetGroupPropsParams): CheckboxGroupProp | RadioGroupProp { + if (multiple) { + return { + invalid: isInvalid, + defaultValue: availabilityZones.filter(({ checked }) => checked).map(({ zone }) => zone), + onValueChange: handleCheckboxChange, + className: clsx('gap-4', isInvalid ? 'mt-4' : 'mt-6'), + } satisfies CheckboxGroupProp; + } else { + return { + defaultValue: availabilityZones.find(({ checked }) => checked)?.zone, + onValueChange: handleRadioChange, + className: clsx('gap-4', 'mt-6'), + } satisfies RadioGroupProp; + } +} + +const DeploymentZone = ({ onSelect, availabilityZones, multiple }: DeploymentZoneProps) => { const { t } = useTranslation('node-pool'); + const handleCheckboxChange = (checkedValues: string[]) => { + const newStates = availabilityZones.map(({ zone }) => ({ + zone, + checked: checkedValues.includes(zone), + })); + + onSelect(newStates); + }; + + const handleRadioChange = (checkedValues: RadioValueChangeDetail) => { + const newStates = availabilityZones.map(({ zone }) => ({ + zone, + checked: zone === checkedValues.value, + })); + + onSelect(newStates); + }; + + const isInvalid = availabilityZones.every(({ checked }) => !checked); + + const groupProps = getGroupProps({ + multiple, + availabilityZones, + isInvalid, + handleCheckboxChange, + handleRadioChange, + }); + + const Component = multiple ? Checkbox : Radio; + const Control = multiple ? CheckboxControl : RadioControl; + const Group = (props: CheckboxGroupProp | RadioGroupProp) => + multiple ? ( + + ) : ( + + ); + const Label = multiple ? CheckboxLabel : RadioLabel; + return ( -
- +
+ {t('kube_common_node_pool_deploy_title')} - - - {t('kube_common_node_pool_deploy_description')} - -
- {availabilityZones?.map((zone) => ( - onSelect(zone)} - inline - > - - {zone} - - - ))} -
+
+ {t('kube_common_node_pool_deploy_description')} + {multiple && ( + + + {t('kube_common_node_pool_deploy_description_explanation_multiple_zone_repartition')} + {': '} + + {t('kube_common_node_pool_deploy_description_explanation_multiple_zone')} + + )} + {multiple && isInvalid && ( + + + {t('kube_common_node_pool_select_zone')} + + )} + +
+ {availabilityZones?.map(({ zone, checked }) => ( + + +
+ + +
+
+
+ ))} +
+
); }; diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolAntiAffinity.component.spec.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolAntiAffinity.component.spec.tsx index 2b3e92c64b69..c61482aa53d8 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolAntiAffinity.component.spec.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolAntiAffinity.component.spec.tsx @@ -16,7 +16,8 @@ describe('NodePoolAntiAffinity Component', () => { test('disables the toggle when isEnabled is false', () => { render( {}} />); - const toggle = screen.getByTestId('toggle-anti-affinity'); + const toggle = screen.getByRole('checkbox'); + expect(toggle).toBeDisabled(); }); diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolAntiAffinity.component.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolAntiAffinity.component.tsx index 210b6dc3f362..6d8a0e2e44f1 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolAntiAffinity.component.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolAntiAffinity.component.tsx @@ -1,8 +1,13 @@ import { useTranslation } from 'react-i18next'; -import { ODS_THEME_COLOR_INTENT, ODS_THEME_TYPOGRAPHY_SIZE } from '@ovhcloud/ods-common-theming'; -import { ODS_TEXT_COLOR_INTENT, ODS_TEXT_LEVEL, ODS_TEXT_SIZE } from '@ovhcloud/ods-components'; -import { OsdsFormField, OsdsText, OsdsToggle } from '@ovhcloud/ods-components/react'; +import { + FormField, + TEXT_PRESET, + Text, + Toggle, + ToggleControl, + ToggleLabel, +} from '@ovhcloud/ods-react'; import { ANTI_AFFINITY_MAX_NODES } from '@/constants'; @@ -15,43 +20,31 @@ const NodePoolAntiAffinity = ({ isChecked, onChange, isEnabled }: NodePoolAntiAf const { t } = useTranslation('billing-anti-affinity'); return ( -
- +
+ {t('kubernetes_node_pool_anti_affinity')} - - + + {t('kubernetes_node_pool_anti_affinity_description', { maxNodes: ANTI_AFFINITY_MAX_NODES, })} - - - +
+ isEnabled && onChange(!isChecked)} + disabled={!isEnabled} + color="primary" + checked={isChecked} + onChange={() => isEnabled && onChange(!isChecked)} > - - {t(`kubernetes_node_pool_anti_affinity_${isChecked}`)} - - - + + + + {t(`kubernetes_node_pool_anti_affinity_${isChecked}`)} + + + +
); }; diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolName.component.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolName.component.tsx index d8179f920dbd..f1c6eddc8ef8 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolName.component.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolName.component.tsx @@ -5,13 +5,13 @@ import { ODS_INPUT_TYPE, ODS_TEXT_LEVEL, ODS_TEXT_SIZE } from '@ovhcloud/ods-com import { OsdsFormField, OsdsInput, OsdsText } from '@ovhcloud/ods-components/react'; type NodeNameProps = { - hasError: boolean; + error: string | null; onTouched: (val: boolean) => void; name: string; onNameChange: (val: string) => void; }; -const NodePoolName = ({ hasError, onTouched, name, onNameChange }: NodeNameProps) => { +const NodePoolName = ({ error, onTouched, name, onNameChange }: NodeNameProps) => { const { t } = useTranslation('add'); return ( @@ -25,15 +25,11 @@ const NodePoolName = ({ hasError, onTouched, name, onNameChange }: NodeNameProps > {t('kubernetes_add_nodepool_name_placeholder')}
- + {t('kubernetes_add_name')} @@ -42,13 +38,13 @@ const NodePoolName = ({ hasError, onTouched, name, onNameChange }: NodeNameProps { onNameChange(e.detail.value ?? ''); }} onOdsInputBlur={() => onTouched(true)} - error={hasError} + error={!!error} />
diff --git a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolSize.component.tsx b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolSize.component.tsx index 1cf100f20505..3700a1877991 100644 --- a/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolSize.component.tsx +++ b/packages/manager/apps/pci-kubernetes/src/pages/new/steps/node-pool/NodePoolSize.component.tsx @@ -1,39 +1,51 @@ +import { useMemo } from 'react'; + import { useTranslation } from 'react-i18next'; -import { ODS_TEXT_COLOR_INTENT, ODS_TEXT_LEVEL, ODS_TEXT_SIZE } from '@ovhcloud/ods-components'; -import { OsdsText } from '@ovhcloud/ods-components/react'; +import { TEXT_PRESET, Text } from '@ovhcloud/ods-react'; import { Autoscaling } from '@/components/Autoscaling.component'; -import { TScalingState } from '@/types'; +import { TScalingState, TSelectedAvailabilityZones } from '@/types'; export interface NodeSizeStepProps { isMonthlyBilled: boolean; antiAffinity: boolean; + isAutoscale: boolean; + initialScaling: TScalingState['quantity']; onScaleChange: (scaling: TScalingState) => void; + selectedAvailabilityZones?: TSelectedAvailabilityZones | null; } export default function NodePoolSize({ onScaleChange, + isAutoscale, + initialScaling, isMonthlyBilled, antiAffinity, + selectedAvailabilityZones, }: Readonly) { const { t } = useTranslation('node-pool'); + + const totalNodes = useMemo(() => { + const zonesChecked = selectedAvailabilityZones?.filter((e) => e.checked).length ?? 0; + const desiredNodes = initialScaling?.desired; + if (!zonesChecked || !desiredNodes) return null; + const total = desiredNodes * zonesChecked; + return total === desiredNodes ? null : total; + }, [selectedAvailabilityZones, initialScaling]); + return (
- + {t('kube_common_node_pool_size_title')} - +
); diff --git a/packages/manager/apps/pci-kubernetes/src/types/index.ts b/packages/manager/apps/pci-kubernetes/src/types/index.ts index 0c6bb95e23be..9cca26e1aee9 100644 --- a/packages/manager/apps/pci-kubernetes/src/types/index.ts +++ b/packages/manager/apps/pci-kubernetes/src/types/index.ts @@ -202,12 +202,17 @@ export type TScalingState = { isAutoscale: boolean; }; +export type TSelectedAvailabilityZones = { + zone: string; + checked: boolean; +}[]; + export type NodePoolState = { name: string; isTouched: boolean; scaling: TScalingState; antiAffinity: boolean; - selectedAvailabilityZone?: string; + selectedAvailabilityZones?: TSelectedAvailabilityZones | null; }; export type TCreateNodePoolParam = { diff --git a/packages/manager/apps/pci-private-network/package.json b/packages/manager/apps/pci-private-network/package.json index 73b9917d61a5..12c52f02cc6e 100644 --- a/packages/manager/apps/pci-private-network/package.json +++ b/packages/manager/apps/pci-private-network/package.json @@ -16,7 +16,7 @@ "@hookform/resolvers": "^3.3.4", "@ovh-ux/manager-config": "^8.6.7", "@ovh-ux/manager-core-api": "^0.19.0", - "@ovh-ux/manager-pci-common": "^0.17.0", + "@ovh-ux/manager-pci-common": "^0.20.1", "@ovh-ux/manager-react-components": "^1.46.0", "@ovh-ux/manager-react-core-application": "^0.12.10", "@ovh-ux/manager-react-shell-client": "^0.11.1", diff --git a/packages/manager/apps/public-cloud/src/links.json b/packages/manager/apps/public-cloud/src/links.json index d37df52a0a15..2bf6ad45629f 100644 --- a/packages/manager/apps/public-cloud/src/links.json +++ b/packages/manager/apps/public-cloud/src/links.json @@ -440,6 +440,16 @@ "path": "/pci/projects/{project}/ai/endpoints" } }, + { + "public": { + "application": "public-cloud", + "path": "/create-quantum-qpu" + }, + "redirect": { + "application": "public-cloud", + "path": "/pci/projects/{project}/ai-ml/quantum/qpu" + } + }, { "public": { "application": "public-cloud", diff --git a/packages/manager/modules/manager-components/src/region/service.js b/packages/manager/modules/manager-components/src/region/service.js index f6d35e742fd6..14b8ef7ba36f 100644 --- a/packages/manager/modules/manager-components/src/region/service.js +++ b/packages/manager/modules/manager-components/src/region/service.js @@ -72,6 +72,10 @@ export default class ovhManagerRegionService { const translatedMacroRegion = this.$translate.instant( `manager_components_region_${this.constructor.getMacroRegion(region)}`, ); + if (region === 'EU-SOUTH-LZ-MIL-A') { + return `${translatedMacroRegion}-LZ`; + } + return translatedMacroRegion || region; } diff --git a/yarn.lock b/yarn.lock index b0f4cb73a477..7b7871c502ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7486,10 +7486,19 @@ date-fns "^3.6.0" lodash.isequal "^4.5.0" -"@ovh-ux/manager-pci-common@^0.20.0": - version "0.20.0" - resolved "https://registry.yarnpkg.com/@ovh-ux/manager-pci-common/-/manager-pci-common-0.20.0.tgz#879e5313674e3324d285628449d489d54895c237" - integrity sha512-bPdjV2CHxkc+ZDd06zH3Ss3eCnmhztkMa+wwThXOok5Lb0X3TRANnaQW4KHhofWTxfoLbYPO40KnqK4ErqLczg== +"@ovh-ux/manager-pci-common@^0.20.1": + version "0.20.1" + resolved "https://registry.yarnpkg.com/@ovh-ux/manager-pci-common/-/manager-pci-common-0.20.1.tgz#4fc6d8709c71106ad844cc4f88d5bf40d42ad528" + integrity sha512-RedqU+PjVVAkkvW0BVCEOCrGhzKxuolXjV6UW8JfsEbJ+Cx18YyEt5SRouQ88RYl/4A7bctscMgs6djtacancQ== + dependencies: + clsx "2.1.1" + date-fns "^3.6.0" + lodash.isequal "^4.5.0" + +"@ovh-ux/manager-pci-common@^0.20.1": + version "0.20.1" + resolved "https://registry.npmjs.org/@ovh-ux/manager-pci-common/-/manager-pci-common-0.20.1.tgz#4fc6d8709c71106ad844cc4f88d5bf40d42ad528" + integrity sha512-RedqU+PjVVAkkvW0BVCEOCrGhzKxuolXjV6UW8JfsEbJ+Cx18YyEt5SRouQ88RYl/4A7bctscMgs6djtacancQ== dependencies: clsx "2.1.1" date-fns "^3.6.0"