diff --git a/.eslintignore b/.eslintignore index 9ed6ad6e5d6c..55d3639e01d9 100644 --- a/.eslintignore +++ b/.eslintignore @@ -73,3 +73,4 @@ packages/manager/apps/communication packages/manager/apps/pci-project packages/manager/apps/hub packages/manager/apps/pci-file-storage +packages/manager/apps/network-vrack diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 20330d49e749..92fd65d8dc47 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -42,6 +42,7 @@ /packages/manager/apps/metrics @ovh/dev-manager-infrastructure-baremetalstorage /packages/manager/apps/nasha @ovh/dev-manager-infrastructure-baremetalstorage /packages/manager/apps/netapp @ovh/dev-manager-infrastructure-baremetalstorage +/packages/manager/apps/network-vrack @ovh/dev-manager-infrastructure-network /packages/manager/apps/nutanix @ovh/dev-manager-enterprise-nutanixBackup /packages/manager/apps/observability @ovh/dev-manager-enablers-all /packages/manager/apps/okms @ovh/dev-manager-enterprise-kms diff --git a/.prettierignore b/.prettierignore index 04ce4e6dad60..4708621fb211 100644 --- a/.prettierignore +++ b/.prettierignore @@ -35,3 +35,4 @@ packages/manager/apps/zimbra packages/manager/apps/pci-project packages/manager/apps/hub packages/manager/apps/pci-file-storage +packages/manager/apps/network-vrack diff --git a/.sonarcloud.properties b/.sonarcloud.properties index da74ff29c3d2..0cec53372187 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -7,7 +7,7 @@ sonar.projectName=manager sonar.sources=. sonar.sourceEncoding=UTF-8 sonar.ws.timeout=60 -sonar.projectVersion=tennessine-samurai-10 +sonar.projectVersion=sulfur-mule-1 sonar.exclusions=node_modules/**, **/node_modules/**, **/dist/**, **/semantic/**, **/coverage/**, **/static/**, **/mock/**, **/mockServiceWorker.js sonar.coverage.exclusions=**/*.spec.js diff --git a/.stylelintignore b/.stylelintignore index b3a589edf8b9..b8b98166a193 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -17,3 +17,4 @@ packages/manager/apps/communication packages/manager/apps/zimbra packages/manager/apps/hub packages/manager/apps/pci-file-storage +packages/manager/apps/network-vrack diff --git a/packages/components/ng-at-internet/CHANGELOG.md b/packages/components/ng-at-internet/CHANGELOG.md index e1558c17180a..f77380c896cc 100644 --- a/packages/components/ng-at-internet/CHANGELOG.md +++ b/packages/components/ng-at-internet/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [6.0.46](https://github.com/ovh/manager/compare/@ovh-ux/ng-at-internet@6.0.45...@ovh-ux/ng-at-internet@6.0.46) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/ng-at-internet + + + + + ## [6.0.45](https://github.com/ovh/manager/compare/@ovh-ux/ng-at-internet@6.0.44...@ovh-ux/ng-at-internet@6.0.45) (2026-02-05) **Note:** Version bump only for package @ovh-ux/ng-at-internet diff --git a/packages/components/ng-at-internet/package.json b/packages/components/ng-at-internet/package.json index 4ee3e4642589..7fcf19ea7f67 100644 --- a/packages/components/ng-at-internet/package.json +++ b/packages/components/ng-at-internet/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/ng-at-internet", - "version": "6.0.45", + "version": "6.0.46", "private": true, "description": "ATInternet tracking library wrapper for AngularJS", "keywords": [ @@ -35,7 +35,7 @@ "prepare": "rollup -c --environment BUILD:production" }, "dependencies": { - "@ovh-ux/ovh-at-internet": "^0.29.4" + "@ovh-ux/ovh-at-internet": "^0.30.0" }, "devDependencies": { "@ovh-ux/component-rollup-config": "^13.2.0", diff --git a/packages/components/ng-shell-tracking/CHANGELOG.md b/packages/components/ng-shell-tracking/CHANGELOG.md index 99ce824ed4a4..2165cf46081b 100644 --- a/packages/components/ng-shell-tracking/CHANGELOG.md +++ b/packages/components/ng-shell-tracking/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.7.40](https://github.com/ovh/manager/compare/@ovh-ux/ng-shell-tracking@0.7.39...@ovh-ux/ng-shell-tracking@0.7.40) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/ng-shell-tracking + + + + + ## [0.7.39](https://github.com/ovh/manager/compare/@ovh-ux/ng-shell-tracking@0.7.38...@ovh-ux/ng-shell-tracking@0.7.39) (2026-02-05) **Note:** Version bump only for package @ovh-ux/ng-shell-tracking diff --git a/packages/components/ng-shell-tracking/package.json b/packages/components/ng-shell-tracking/package.json index 9cb082c4d6d1..eb36089c6022 100644 --- a/packages/components/ng-shell-tracking/package.json +++ b/packages/components/ng-shell-tracking/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/ng-shell-tracking", - "version": "0.7.39", + "version": "0.7.40", "private": true, "description": "ATInternet tracking library wrapper for AngularJS", "keywords": [ @@ -36,7 +36,7 @@ "prepare": "rollup -c --environment BUILD:production" }, "dependencies": { - "@ovh-ux/ovh-at-internet": "^0.29.4" + "@ovh-ux/ovh-at-internet": "^0.30.0" }, "devDependencies": { "@ovh-ux/component-rollup-config": "^13.2.0", diff --git a/packages/components/ovh-at-internet/CHANGELOG.md b/packages/components/ovh-at-internet/CHANGELOG.md index 6265aab404aa..3c28ce32374d 100644 --- a/packages/components/ovh-at-internet/CHANGELOG.md +++ b/packages/components/ovh-at-internet/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.30.0](https://github.com/ovh/manager/compare/@ovh-ux/ovh-at-internet@0.29.4...@ovh-ux/ovh-at-internet@0.30.0) (2026-03-04) + + +### Features + +* fix ovh at internet behavior ([d81f245](https://github.com/ovh/manager/commit/d81f245b6186d67a200ef073de9745334bd4fbd0)), closes [#MANAGER-20904](https://github.com/ovh/manager/issues/MANAGER-20904) + + + + + ## [0.29.4](https://github.com/ovh/manager/compare/@ovh-ux/ovh-at-internet@0.29.3...@ovh-ux/ovh-at-internet@0.29.4) (2026-02-05) **Note:** Version bump only for package @ovh-ux/ovh-at-internet diff --git a/packages/components/ovh-at-internet/package.json b/packages/components/ovh-at-internet/package.json index 3df8a69da172..ec0e52c92aa6 100644 --- a/packages/components/ovh-at-internet/package.json +++ b/packages/components/ovh-at-internet/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/ovh-at-internet", - "version": "0.29.4", + "version": "0.30.0", "description": "ATInternet tracking library for OVHcloud.", "keywords": [ "at-internet", diff --git a/packages/components/ovh-at-internet/src/ovh-at-internet.ts b/packages/components/ovh-at-internet/src/ovh-at-internet.ts index d6e7587212c2..6bd26a05d6e6 100644 --- a/packages/components/ovh-at-internet/src/ovh-at-internet.ts +++ b/packages/components/ovh-at-internet/src/ovh-at-internet.ts @@ -43,6 +43,16 @@ function isTrackingDebug() { return window.localStorage?.getItem('MANAGER_TRACKING_DEBUG'); } +function deleteCookieOnAllDomains(cookieName: string) { + const parts = window.location.hostname?.split('.'); + const expired = 'Expires=Thu, 01 Jan 1970 00:00:01 GMT'; + ['', ...parts.map((_, i) => ` Domain=.${parts.slice(i + 1).join('.')};`)] + .filter((d) => d !== ' Domain=.;') + .forEach((domainPart) => { + document.cookie = `${cookieName}=; Path=/;${domainPart} ${expired};`; + }); +} + export default class OvhAtInternet extends OvhAtInternetConfig { /** * Reference to ATInternet Tag object from their JS library. @@ -229,6 +239,7 @@ export default class OvhAtInternet extends OvhAtInternetConfig { window.pa.privacy.include.event('*', 'beforeConsent'); window.pa.privacy.exclude.storageKey('pa_uid', ['beforeConsent']); window.pa.privacy.setMode('beforeConsent'); + deleteCookieOnAllDomains('pa_privacy'); } } @@ -256,8 +267,9 @@ export default class OvhAtInternet extends OvhAtInternetConfig { .finally(() => { this.setEnabled(consent); if (!consent && this.shouldUsePianoAnalytics()) { - document.cookie = `_pctx=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`; - document.cookie = `_pcid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + deleteCookieOnAllDomains('_pctx'); + deleteCookieOnAllDomains('_pcid'); + deleteCookieOnAllDomains('pa_privacy'); } }); } diff --git a/packages/components/ovh-shell/CHANGELOG.md b/packages/components/ovh-shell/CHANGELOG.md index 2d7bd8017160..47a4cc48442d 100644 --- a/packages/components/ovh-shell/CHANGELOG.md +++ b/packages/components/ovh-shell/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [4.10.7](https://github.com/ovh/manager/compare/@ovh-ux/shell@4.10.6...@ovh-ux/shell@4.10.7) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/shell + + + + + ## [4.10.6](https://github.com/ovh/manager/compare/@ovh-ux/shell@4.10.5...@ovh-ux/shell@4.10.6) (2026-02-05) **Note:** Version bump only for package @ovh-ux/shell diff --git a/packages/components/ovh-shell/package.json b/packages/components/ovh-shell/package.json index 335c1ce2256f..d0bcafef2960 100644 --- a/packages/components/ovh-shell/package.json +++ b/packages/components/ovh-shell/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/shell", - "version": "4.10.6", + "version": "4.10.7", "description": "Communication and interaction between applications", "repository": { "type": "git", @@ -30,7 +30,7 @@ "@ovh-ux/manager-config": "^8.9.0", "@ovh-ux/manager-core-api": "^0.21.2", "@ovh-ux/manager-core-sso": "^0.7.2", - "@ovh-ux/ovh-at-internet": "^0.29.4", + "@ovh-ux/ovh-at-internet": "^0.30.0", "@ovh-ux/request-tagger": "^0.6.0", "@ovh-ux/url-builder": "^2.4.3", "dompurify": "^3.2.4", diff --git a/packages/manager-tools/manager-pm/CHANGELOG.md b/packages/manager-tools/manager-pm/CHANGELOG.md index b26312bee089..9971585eb042 100644 --- a/packages/manager-tools/manager-pm/CHANGELOG.md +++ b/packages/manager-tools/manager-pm/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.53.0](https://github.com/ovh/manager/compare/@ovh-ux/manager-pm@0.52.1...@ovh-ux/manager-pm@0.53.0) (2026-03-11) + + +### Features + +* **vrack:** init react app ([43389da](https://github.com/ovh/manager/commit/43389da91100cc53699e7e2d6b567ed22bf64198)), closes [#MANAGER-16231](https://github.com/ovh/manager/issues/MANAGER-16231) + + + + + ## [0.52.1](https://github.com/ovh/manager/compare/@ovh-ux/manager-pm@0.52.0...@ovh-ux/manager-pm@0.52.1) (2026-02-24) **Note:** Version bump only for package @ovh-ux/manager-pm diff --git a/packages/manager-tools/manager-pm/package.json b/packages/manager-tools/manager-pm/package.json index abdd06933f39..797cb683c88b 100644 --- a/packages/manager-tools/manager-pm/package.json +++ b/packages/manager-tools/manager-pm/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-pm", - "version": "0.52.1", + "version": "0.53.0", "private": true, "description": "", "license": "BSD-3-Clause", diff --git a/packages/manager-tools/manager-pm/src/playbook/catalog/pnpm-catalog.json b/packages/manager-tools/manager-pm/src/playbook/catalog/pnpm-catalog.json index 6d34e1197075..4599e6cabcb2 100644 --- a/packages/manager-tools/manager-pm/src/playbook/catalog/pnpm-catalog.json +++ b/packages/manager-tools/manager-pm/src/playbook/catalog/pnpm-catalog.json @@ -29,6 +29,7 @@ "packages/manager/apps/nutanix", "packages/manager/apps/okms", "packages/manager/apps/nasha", + "packages/manager/apps/network-vrack", "packages/manager/apps/pci-ai-endpoints", "packages/manager/apps/pci-ai-tools", "packages/manager/apps/pci-billing", diff --git a/packages/manager-ui-kit/CHANGELOG.md b/packages/manager-ui-kit/CHANGELOG.md index cae93ed4c5cd..d812e8be5ce8 100644 --- a/packages/manager-ui-kit/CHANGELOG.md +++ b/packages/manager-ui-kit/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.2.4](https://github.com/ovh/manager/compare/@ovh-ux/muk@1.2.3...@ovh-ux/muk@1.2.4) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/muk + + + + + ## [1.2.3](https://github.com/ovh/manager/compare/@ovh-ux/muk@1.2.2...@ovh-ux/muk@1.2.3) (2026-02-06) **Note:** Version bump only for package @ovh-ux/muk diff --git a/packages/manager-ui-kit/package.json b/packages/manager-ui-kit/package.json index d0446f6fcdca..d022a975399b 100644 --- a/packages/manager-ui-kit/package.json +++ b/packages/manager-ui-kit/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/muk", - "version": "1.2.3", + "version": "1.2.4", "description": "MUK:Manager UI Kit", "homepage": "https://github.com/ovh/manager/blob/master/packages/manager-ui-kit/README.md", "bugs": { @@ -60,7 +60,7 @@ "@ovh-ux/manager-config": "^8.9.0", "@ovh-ux/manager-core-api": "^0.21.2", "@ovh-ux/manager-core-utils": "^0.5.0", - "@ovh-ux/manager-react-shell-client": "^1.2.3", + "@ovh-ux/manager-react-shell-client": "^1.2.4", "@ovh-ux/manager-static-analysis-kit": "^0.15.0", "@ovh-ux/manager-tailwind-config": "^0.6.2", "@ovh-ux/manager-tests-setup": "^0.8.0", diff --git a/packages/manager-wiki/CHANGELOG.md b/packages/manager-wiki/CHANGELOG.md index 19de9445789a..90200c7f51b0 100644 --- a/packages/manager-wiki/CHANGELOG.md +++ b/packages/manager-wiki/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.26.8](https://github.com/ovh/manager/compare/@ovh-ux/manager-wiki@0.26.7...@ovh-ux/manager-wiki@0.26.8) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-wiki + + + + + ## [0.26.7](https://github.com/ovh/manager/compare/@ovh-ux/manager-wiki@0.26.6...@ovh-ux/manager-wiki@0.26.7) (2026-02-06) **Note:** Version bump only for package @ovh-ux/manager-wiki diff --git a/packages/manager-wiki/package.json b/packages/manager-wiki/package.json index 9fa7e9b652ba..aaee49b36970 100644 --- a/packages/manager-wiki/package.json +++ b/packages/manager-wiki/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-wiki", - "version": "0.26.7", + "version": "0.26.8", "private": true, "description": "Manager Wiki", "license": "BSD-3-Clause", @@ -13,7 +13,7 @@ }, "dependencies": { "@ovh-ux/manager-core-api": "^0.21.2", - "@ovh-ux/muk": "^1.2.3", + "@ovh-ux/muk": "^1.2.4", "@ovhcloud/ods-react": "19.5.0", "@ovhcloud/ods-themes": "19.5.0", "clsx": "^2.1.1", @@ -32,7 +32,7 @@ "@ovh-ux/manager-core-api": "^0.12.0", "@ovh-ux/ovh-at-internet": "^0.21.6", "@ovh-ux/request-tagger": "^0.6.0", - "@ovh-ux/shell": "^4.10.6", + "@ovh-ux/shell": "^4.10.7", "@ovh-ux/url-builder": "^2.4.3", "@storybook/addon-docs": "^8.6.7", "@storybook/addon-essentials": "^8.6.7", diff --git a/packages/manager/apps/account-creation/CHANGELOG.md b/packages/manager/apps/account-creation/CHANGELOG.md index 10325583ee5a..830426452d3a 100644 --- a/packages/manager/apps/account-creation/CHANGELOG.md +++ b/packages/manager/apps/account-creation/CHANGELOG.md @@ -3,6 +3,43 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.13.2](https://github.com/ovh/manager/compare/@ovh-ux/manager-account-creation-app@0.13.1...@ovh-ux/manager-account-creation-app@0.13.2) (2026-03-09) + + +### Bug Fixes + +* **account-creation:** add conditional gender and birthday ([0a7f82a](https://github.com/ovh/manager/commit/0a7f82a0018df029db4b9aeac2bd0c80dffaa3f1)), closes [#MANAGER-20917](https://github.com/ovh/manager/issues/MANAGER-20917) + + + + + +## [0.13.1](https://github.com/ovh/manager/compare/@ovh-ux/manager-account-creation-app@0.13.0...@ovh-ux/manager-account-creation-app@0.13.1) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-account-creation-app + + + + + +# [0.13.0](https://github.com/ovh/manager/compare/@ovh-ux/manager-account-creation-app@0.12.2...@ovh-ux/manager-account-creation-app@0.13.0) (2026-03-03) + + +### Bug Fixes + +* **account-creation:** add conditional NIN ([6e97d0a](https://github.com/ovh/manager/commit/6e97d0a257d60a9502abd5c149ec01a0d7e0dc70)), closes [#MANAGER-21054](https://github.com/ovh/manager/issues/MANAGER-21054) +* **account-creation:** remove siret mandatory hotfix ([d144407](https://github.com/ovh/manager/commit/d144407a6c786fa2d958c88676541797a0e2da5a)), closes [#MANAGER-20933](https://github.com/ovh/manager/issues/MANAGER-20933) +* **i18n:** add missing translations [CDS 1193] ([7af273e](https://github.com/ovh/manager/commit/7af273ebb84029ffcd5c4dafc8cca92fe72cf412)) + + +### Features + +* **account-creation:** add corporation type ([30d9741](https://github.com/ovh/manager/commit/30d97412caaf2545aacc7e0b7f473f39ecdeaf23)), closes [#MANAGER-20913](https://github.com/ovh/manager/issues/MANAGER-20913) + + + + + ## [0.12.2](https://github.com/ovh/manager/compare/@ovh-ux/manager-account-creation-app@0.12.1...@ovh-ux/manager-account-creation-app@0.12.2) (2026-02-25) diff --git a/packages/manager/apps/account-creation/package.json b/packages/manager/apps/account-creation/package.json index 146daa300d13..456dcca91435 100644 --- a/packages/manager/apps/account-creation/package.json +++ b/packages/manager/apps/account-creation/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-account-creation-app", - "version": "0.12.2", + "version": "0.13.2", "private": true, "description": "Application used to finalize an account creation", "repository": { @@ -24,12 +24,12 @@ "@ovh-ux/manager-config": "^8.9.0", "@ovh-ux/manager-core-api": "^0.21.2", "@ovh-ux/manager-core-utils": "^0.5.0", - "@ovh-ux/manager-gcj-module": "^0.8.3", + "@ovh-ux/manager-gcj-module": "^0.8.4", "@ovh-ux/manager-react-components": "2.43.2", - "@ovh-ux/manager-react-core-application": "^0.15.4", + "@ovh-ux/manager-react-core-application": "^0.15.5", "@ovh-ux/manager-react-shell-client": "^0.11.2", "@ovh-ux/request-tagger": "^0.6.0", - "@ovh-ux/shell": "^4.10.6", + "@ovh-ux/shell": "^4.10.7", "@ovhcloud/ods-components": "18.6.4", "@ovhcloud/ods-react": "^19.3.0", "@ovhcloud/ods-themes": "19.3.0", diff --git a/packages/manager/apps/account-creation/public/translations/account-details/Messages_de_DE.json b/packages/manager/apps/account-creation/public/translations/account-details/Messages_de_DE.json index 8d3bc03d662e..f301c9f7422b 100644 --- a/packages/manager/apps/account-creation/public/translations/account-details/Messages_de_DE.json +++ b/packages/manager/apps/account-creation/public/translations/account-details/Messages_de_DE.json @@ -50,5 +50,20 @@ "account_details_field_corporation_firstname": "Vorname des gesetzlichen Vertreters", "account_details_field_corporation_lastname": "Name des gesetzlichen Vertreters", "account_details_section_address_kyc_info": "Achten Sie darauf, alle Informationen einzugeben, und vergewissern Sie sich, dass Ihre persönlichen Daten mit dem Namen und der Adresse in Ihren offiziellen Dokumenten übereinstimmen, um eine Bestellung aufzugeben. ", - "account_details_field_italian_sdi": "Empfänger-Code für die elektronische Rechnungsstellung (SDI)" + "account_details_field_italian_sdi": "Empfänger-Code für die elektronische Rechnungsstellung (SDI)", + "account_details_field_corporationType": "Unternehmensart", + "account_details_field_corporationType_sapa": "SAPA", + "account_details_field_corporationType_sas": "SAS", + "account_details_field_corporationType_sc": "SC", + "account_details_field_corporationType_scarl": "SCARL", + "account_details_field_corporationType_snc": "SNC", + "account_details_field_corporationType_spa": "SPA", + "account_details_field_corporationType_srl": "SRL", + "account_details_field_corporationType_ss": "SS", + "account_details_field_corporationType_personnelle": "Personal", + "account_details_field_nationalIdentificationNumber": "Nationale Identifikationsnummer", + "account_details_field_sex": "Geschlecht", + "account_details_field_birthDay": "Geburtsdatum", + "account_details_sex_option_female": "Weiblich", + "account_details_sex_option_male": "Männlich" } diff --git a/packages/manager/apps/account-creation/public/translations/account-details/Messages_en_GB.json b/packages/manager/apps/account-creation/public/translations/account-details/Messages_en_GB.json index 89a7dafd86d5..d7c58431f13b 100644 --- a/packages/manager/apps/account-creation/public/translations/account-details/Messages_en_GB.json +++ b/packages/manager/apps/account-creation/public/translations/account-details/Messages_en_GB.json @@ -50,5 +50,20 @@ "account_details_field_corporation_firstname": "Last name (legal representative):", "account_details_field_corporation_lastname": "First name (legal representative):", "account_details_section_address_kyc_info": "Please remember to fill in every field and confirm that your personal details match the name and address in your official documents before placing your order. ", - "account_details_field_italian_sdi": "Recipient code for electronic billing (SDI)" + "account_details_field_italian_sdi": "Recipient code for electronic invoicing (SDI)", + "account_details_field_corporationType": "Type of company", + "account_details_field_corporationType_sapa": "SAPA", + "account_details_field_corporationType_sas": "SAS", + "account_details_field_corporationType_sc": "SC", + "account_details_field_corporationType_scarl": "SCARL", + "account_details_field_corporationType_snc": "SNC", + "account_details_field_corporationType_spa": "SPA", + "account_details_field_corporationType_srl": "SRL", + "account_details_field_corporationType_ss": "SS", + "account_details_field_corporationType_personnelle": "Personal", + "account_details_field_nationalIdentificationNumber": "National identification number", + "account_details_field_sex": "Sex", + "account_details_field_birthDay": "Date of birth", + "account_details_sex_option_female": "Female", + "account_details_sex_option_male": "Male" } diff --git a/packages/manager/apps/account-creation/public/translations/account-details/Messages_es_ES.json b/packages/manager/apps/account-creation/public/translations/account-details/Messages_es_ES.json index c22fad5003a2..a9dc961a0eca 100644 --- a/packages/manager/apps/account-creation/public/translations/account-details/Messages_es_ES.json +++ b/packages/manager/apps/account-creation/public/translations/account-details/Messages_es_ES.json @@ -50,5 +50,20 @@ "account_details_field_corporation_firstname": "Nombre del representante legal", "account_details_field_corporation_lastname": "Apellido del representante legal", "account_details_section_address_kyc_info": "Para poder realizar su pedido, deberá incluir toda la información solicitada y asegurarse de que sus datos personales coinciden con el nombre y la dirección que aparecen en sus documentos oficiales. ", - "account_details_field_italian_sdi": "Código del destinatario para la factura electrónica (SDI)" + "account_details_field_italian_sdi": "Código del destinatario para la facturación electrónica (SDI)", + "account_details_field_corporationType": "Tipo de empresa", + "account_details_field_corporationType_sapa": "SAPA", + "account_details_field_corporationType_sas": "SAS", + "account_details_field_corporationType_sc": "SC", + "account_details_field_corporationType_scarl": "SCARL", + "account_details_field_corporationType_snc": "SNC", + "account_details_field_corporationType_spa": "SPA", + "account_details_field_corporationType_srl": "SRL", + "account_details_field_corporationType_ss": "SS", + "account_details_field_corporationType_personnelle": "Personal", + "account_details_field_nationalIdentificationNumber": "Número de identificación nacional", + "account_details_field_sex": "Género", + "account_details_field_birthDay": "Fecha de nacimiento", + "account_details_sex_option_female": "Femenino", + "account_details_sex_option_male": "Masculino" } diff --git a/packages/manager/apps/account-creation/public/translations/account-details/Messages_fr_CA.json b/packages/manager/apps/account-creation/public/translations/account-details/Messages_fr_CA.json index 8298d45821bc..88c01bbb34a8 100644 --- a/packages/manager/apps/account-creation/public/translations/account-details/Messages_fr_CA.json +++ b/packages/manager/apps/account-creation/public/translations/account-details/Messages_fr_CA.json @@ -46,5 +46,20 @@ "account_details_field_sms_consent": "J'accepte librement de recevoir des SMS relatifs aux nouveautés et offres commerciales d'OVHcloud", "account_details_button_validate": "Valider", "account_details_error_message": "Veuillez vérifier les champs marqués en rouge.", - "account_details_success_message": "Vos informations ont été enregistrées avec succès." + "account_details_success_message": "Vos informations ont été enregistrées avec succès.", + "account_details_field_corporationType": "Type d'entreprise", + "account_details_field_corporationType_sapa": "SAPA", + "account_details_field_corporationType_sas": "SAS", + "account_details_field_corporationType_sc": "SC", + "account_details_field_corporationType_scarl": "SCARL", + "account_details_field_corporationType_snc": "SNC", + "account_details_field_corporationType_spa": "SPA", + "account_details_field_corporationType_srl": "SRL", + "account_details_field_corporationType_ss": "SS", + "account_details_field_corporationType_personnelle": "Personnelle", + "account_details_field_nationalIdentificationNumber": "Numéro d'identification national", + "account_details_field_sex": "Genre", + "account_details_field_birthDay": "Date de naissance", + "account_details_sex_option_female": "Femme", + "account_details_sex_option_male": "Homme" } diff --git a/packages/manager/apps/account-creation/public/translations/account-details/Messages_fr_FR.json b/packages/manager/apps/account-creation/public/translations/account-details/Messages_fr_FR.json index 8298d45821bc..88c01bbb34a8 100644 --- a/packages/manager/apps/account-creation/public/translations/account-details/Messages_fr_FR.json +++ b/packages/manager/apps/account-creation/public/translations/account-details/Messages_fr_FR.json @@ -46,5 +46,20 @@ "account_details_field_sms_consent": "J'accepte librement de recevoir des SMS relatifs aux nouveautés et offres commerciales d'OVHcloud", "account_details_button_validate": "Valider", "account_details_error_message": "Veuillez vérifier les champs marqués en rouge.", - "account_details_success_message": "Vos informations ont été enregistrées avec succès." + "account_details_success_message": "Vos informations ont été enregistrées avec succès.", + "account_details_field_corporationType": "Type d'entreprise", + "account_details_field_corporationType_sapa": "SAPA", + "account_details_field_corporationType_sas": "SAS", + "account_details_field_corporationType_sc": "SC", + "account_details_field_corporationType_scarl": "SCARL", + "account_details_field_corporationType_snc": "SNC", + "account_details_field_corporationType_spa": "SPA", + "account_details_field_corporationType_srl": "SRL", + "account_details_field_corporationType_ss": "SS", + "account_details_field_corporationType_personnelle": "Personnelle", + "account_details_field_nationalIdentificationNumber": "Numéro d'identification national", + "account_details_field_sex": "Genre", + "account_details_field_birthDay": "Date de naissance", + "account_details_sex_option_female": "Femme", + "account_details_sex_option_male": "Homme" } diff --git a/packages/manager/apps/account-creation/public/translations/account-details/Messages_it_IT.json b/packages/manager/apps/account-creation/public/translations/account-details/Messages_it_IT.json index a27a94c4425f..5e37f8cf4717 100644 --- a/packages/manager/apps/account-creation/public/translations/account-details/Messages_it_IT.json +++ b/packages/manager/apps/account-creation/public/translations/account-details/Messages_it_IT.json @@ -50,5 +50,20 @@ "account_details_field_corporation_firstname": "Nome del rappresentante legale", "account_details_field_corporation_lastname": "Cognome del rappresentante legale", "account_details_section_address_kyc_info": "Per effettuare un ordine, ricordati di inserire tutte le informazioni richieste e assicurati che i tuoi dati personali coincidano con il nome e l’indirizzo che appaiono nei documenti ufficiali. ", - "account_details_field_italian_sdi": "Codice destinatario per fatturazione elettronica (SDI)" + "account_details_field_italian_sdi": "Codice del destinatario per la fatturazione elettronica (SDI)", + "account_details_field_corporationType": "Tipo di impresa", + "account_details_field_corporationType_sapa": "SAPA", + "account_details_field_corporationType_sas": "SAS", + "account_details_field_corporationType_sc": "SC", + "account_details_field_corporationType_scarl": "SCARL", + "account_details_field_corporationType_snc": "SNC", + "account_details_field_corporationType_spa": "SPA", + "account_details_field_corporationType_srl": "SRL", + "account_details_field_corporationType_ss": "SS", + "account_details_field_corporationType_personnelle": "Personale", + "account_details_field_nationalIdentificationNumber": "Numero di identificazione nazionale", + "account_details_field_sex": "Genere", + "account_details_field_birthDay": "Data di nascita", + "account_details_sex_option_female": "F", + "account_details_sex_option_male": "M" } diff --git a/packages/manager/apps/account-creation/public/translations/account-details/Messages_pl_PL.json b/packages/manager/apps/account-creation/public/translations/account-details/Messages_pl_PL.json index 6f7a316679d3..e1d0fe8a0180 100644 --- a/packages/manager/apps/account-creation/public/translations/account-details/Messages_pl_PL.json +++ b/packages/manager/apps/account-creation/public/translations/account-details/Messages_pl_PL.json @@ -50,5 +50,20 @@ "account_details_field_corporation_firstname": "Imię prawnego przedstawiciela", "account_details_field_corporation_lastname": "Nazwisko prawnego przedstawiciela", "account_details_section_address_kyc_info": "Przed złożeniem zamówienia pamiętaj o wprowadzeniu wszystkich informacji i upewnij się, że Twoje imię i nazwisko odpowiada danym osobowym podanym w Twoich oficjalnych dokumentach. ", - "account_details_field_italian_sdi": "Kod nabywcy na fakturze elektronicznej (SDI)" + "account_details_field_italian_sdi": "Kod odbiorcy do fakturowania elektronicznego (SDI)", + "account_details_field_corporationType": "Rodzaj przedsiębiorstwa", + "account_details_field_corporationType_sapa": "SAPA", + "account_details_field_corporationType_sas": "SAS", + "account_details_field_corporationType_sc": "SC", + "account_details_field_corporationType_scarl": "SCARL", + "account_details_field_corporationType_snc": "SNC", + "account_details_field_corporationType_spa": "SPA", + "account_details_field_corporationType_srl": "SRL", + "account_details_field_corporationType_ss": "SS", + "account_details_field_corporationType_personnelle": "Osobowa", + "account_details_field_nationalIdentificationNumber": "Numer identyfikacji narodowej", + "account_details_field_sex": "Płeć", + "account_details_field_birthDay": "Data urodzenia", + "account_details_sex_option_female": "Kobieta", + "account_details_sex_option_male": "Mężczyzna" } diff --git a/packages/manager/apps/account-creation/public/translations/account-details/Messages_pt_PT.json b/packages/manager/apps/account-creation/public/translations/account-details/Messages_pt_PT.json index a82ebfd8da92..aaeca45359b7 100644 --- a/packages/manager/apps/account-creation/public/translations/account-details/Messages_pt_PT.json +++ b/packages/manager/apps/account-creation/public/translations/account-details/Messages_pt_PT.json @@ -50,5 +50,20 @@ "account_details_field_corporation_firstname": "Nome próprio do representante legal", "account_details_field_corporation_lastname": "Nome do representante legal", "account_details_section_address_kyc_info": "Não se esqueça de introduzir todas as informações e certifique-se de que os seus dados pessoais correspondem ao nome e endereço que constam nos seus documentos oficiais, para que possa efetuar uma encomenda. ", - "account_details_field_italian_sdi": "Código do destinatário para a faturação eletrónica (SDI)" + "account_details_field_italian_sdi": "Código do destinatário para a faturação eletrónica (SDI)", + "account_details_field_corporationType": "Tipo de empresa", + "account_details_field_corporationType_sapa": "SAPA", + "account_details_field_corporationType_sas": "SAS", + "account_details_field_corporationType_sc": "SC", + "account_details_field_corporationType_scarl": "SCARL", + "account_details_field_corporationType_snc": "SNC", + "account_details_field_corporationType_spa": "SPA", + "account_details_field_corporationType_srl": "SRL", + "account_details_field_corporationType_ss": "SS", + "account_details_field_corporationType_personnelle": "Pessoal", + "account_details_field_nationalIdentificationNumber": "Número de identificação nacional", + "account_details_field_sex": "Género", + "account_details_field_birthDay": "Data de nascimento", + "account_details_sex_option_female": "Feminino", + "account_details_sex_option_male": "Masculino" } diff --git a/packages/manager/apps/account-creation/src/hooks/zod/useZod.ts b/packages/manager/apps/account-creation/src/hooks/zod/useZod.ts index 6d8378b9f955..2b24ad90ac38 100644 --- a/packages/manager/apps/account-creation/src/hooks/zod/useZod.ts +++ b/packages/manager/apps/account-creation/src/hooks/zod/useZod.ts @@ -1,10 +1,24 @@ import { z } from 'zod'; import { Rule } from '@/types/rule'; +type OptionalRuleZodSchema = z.ZodOptional< + z.ZodString | z.ZodEffects +>; + +type OptionalUnionWithEmpty = z.ZodUnion< + [OptionalRuleZodSchema, z.ZodLiteral<''>] +>; + export type RuleZodSchema = | z.ZodString | z.ZodEffects - | z.ZodOptional>; + | OptionalRuleZodSchema + | OptionalUnionWithEmpty + | z.ZodEffects< + OptionalUnionWithEmpty, + string | undefined, + string | undefined + >; export const toZodField = (field: Rule): RuleZodSchema => { let zodSchema: RuleZodSchema = z @@ -35,7 +49,9 @@ export const toZodField = (field: Rule): RuleZodSchema => { } if (!field.mandatory) { - return zodSchema.optional(); + return z + .union([zodSchema.optional(), z.literal('')]) + .transform((val) => (val === '' ? undefined : val)); } return zodSchema; diff --git a/packages/manager/apps/account-creation/src/pages/accountDetails/accountDetails.page.tsx b/packages/manager/apps/account-creation/src/pages/accountDetails/accountDetails.page.tsx index 845a91837cb7..3f16c93f6dac 100644 --- a/packages/manager/apps/account-creation/src/pages/accountDetails/accountDetails.page.tsx +++ b/packages/manager/apps/account-creation/src/pages/accountDetails/accountDetails.page.tsx @@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { Controller, SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { useSearchParams } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; -import { z, ZodOptional } from 'zod'; +import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { BaseLayout, @@ -23,6 +23,7 @@ import { OdsSkeleton, OdsText, OdsMessage, + OdsDatepicker, } from '@ovhcloud/ods-components/react'; import { PHONE_NUMBER_COUNTRY_ISO_CODE, @@ -38,7 +39,7 @@ import { ODS_PHONE_NUMBER_COUNTRY_ISO_CODE, ODS_TEXT_PRESET, } from '@ovhcloud/ods-components'; -import { Country, User } from '@ovh-ux/manager-config'; +import { User } from '@ovh-ux/manager-config'; import { NAMESPACES } from '@ovh-ux/manager-common-translations'; import { ButtonType, @@ -53,8 +54,6 @@ import { useMe } from '@/data/hooks/useMe'; import { Rule, RuleField } from '@/types/rule'; import { getZodSchemaFromRule, - RuleZodSchema, - toZodField, useZodTranslatedError, } from '@/hooks/zod/useZod'; import { putMe } from '@/data/api/me'; @@ -73,8 +72,6 @@ import { useTrackBackButtonClick, } from '@/hooks/tracking/useTracking'; import { - CNIN_NON_MANDATORY_RULE, - CNIN_RULE, COUNTRIES_VAT_LABEL, TRACKING_GOAL_TYPE, } from './accountDetails.constants'; @@ -88,7 +85,6 @@ import { } from '@/components/formSkeleton'; import ExitGuard from '@/components/exitGuard/ExitGuard.component'; import InvalidationRedirectGuard from '@/components/invalidationRedirectGuard/InvalidationRedirectGuard.component'; -import { isCNINMandatory, isCNINVisible } from '@/helpers/xander/xanderHelper'; type AccountDetailsFormProps = { rules: Record; @@ -118,9 +114,6 @@ function AccountDetailsForm({ const pageTracking = usePageTracking(); const { trackClick, trackPage } = useTrackingContext(); const { trackError } = useTrackError('final-step'); - const [cninSchemaOverride, setCninSchemaOverride] = useState< - RuleZodSchema | undefined - >(undefined); const { legalForm, @@ -141,19 +134,11 @@ function AccountDetailsForm({ const zodSchema = useMemo(() => { const baseSchema = getZodSchemaFromRule(rules); - // TODO: Remove after mandatory check is implemented in Xander for FR - if (cninSchemaOverride) { - return baseSchema.extend({ - confirmSend: z.literal(true), - smsConsent: z.boolean().optional(), - companyNationalIdentificationNumber: cninSchemaOverride, - }); - } return baseSchema.extend({ confirmSend: z.literal(true), smsConsent: z.boolean().optional(), }); - }, [rules, cninSchemaOverride]); + }, [rules]); function renderTranslatedZodError(message: string | undefined, rule: Rule) { if (!message) return undefined; @@ -249,29 +234,6 @@ function AccountDetailsForm({ if (!phone && country !== phoneCountry) { setValue('phoneCountry', country); } - - // TODO: Remove after mandatory check is implemented in Xander for FR - if ( - legalForm === 'corporation' && // Add only for corporation legal form - isCNINMandatory({ - ovhSubsidiary, - country: (country as Country) || undefined, - defaultMandatory: - rules?.companyNationalIdentificationNumber?.mandatory, - }) - ) { - setCninSchemaOverride(toZodField(CNIN_RULE)); - } else if ( - (legalForm === 'association' || legalForm === 'administration') && - isCNINVisible({ - ovhSubsidiary, - country: (country as Country) || undefined, - }) - ) { - setCninSchemaOverride(toZodField(CNIN_NON_MANDATORY_RULE)); - } else { - setCninSchemaOverride(undefined); - } } }, [country]); @@ -423,6 +385,160 @@ function AccountDetailsForm({ ); }} /> + {/* Display national identification number if legal form is individual and national identification number rule is present */} + {isIndividualLegalForm(legalForm) && + rules?.nationalIdentificationNumber && ( + <> + ( + + + + {errors.nationalIdentificationNumber && + rules?.nationalIdentificationNumber && ( + + {renderTranslatedZodError( + errors.nationalIdentificationNumber.message, + rules?.nationalIdentificationNumber, + )} + + )} + + )} + /> + + )} + {rules?.sex?.mandatory && ( + ( + + +
+ {rules?.sex.in?.map((sex: string) => ( +
+ + + {t(`account_details_sex_option_${sex}`)} + +
+ ))} +
+
+ )} + /> + )} + {rules?.birthDay?.mandatory && ( + { + const birthDayRegex = rules?.birthDay?.regularExpression + ? new RegExp(rules.birthDay.regularExpression) + : null; + return ( + + + { + const date = e.detail?.value as Date | undefined; + if ( + date instanceof Date && + !Number.isNaN(date.getTime()) + ) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart( + 2, + '0', + ); + const d = String(date.getDate()).padStart(2, '0'); + field.onChange(`${y}-${m}-${d}`); + } + }} + onOdsBlur={field.onBlur} + /> + {errors.birthDay && rules?.birthDay && ( + + {renderTranslatedZodError( + errors.birthDay.message, + rules?.birthDay, + )} + + )} + + ); + }} + /> + )} {(rules?.organisation || @@ -471,8 +587,50 @@ function AccountDetailsForm({ )} /> - {(rules?.companyNationalIdentificationNumber || - !!cninSchemaOverride) && ( + {rules?.corporationType && ( + ( + + + + + {!isLoading && ( + <> + + {rules?.corporationType.in?.map((type) => ( + + ))} + + + )} + + )} + /> + )} + {rules?.companyNationalIdentificationNumber && ( {t('account_details_field_siret')} - {(rules?.companyNationalIdentificationNumber - ?.mandatory || - !(cninSchemaOverride instanceof ZodOptional)) && // TODO: Remove next line after mandatory check is implemented in Xander for FR - ' *'} + {rules?.companyNationalIdentificationNumber + ?.mandatory && ' *'} {errors.companyNationalIdentificationNumber && - (rules?.companyNationalIdentificationNumber || - !!cninSchemaOverride) && ( + rules?.companyNationalIdentificationNumber && ( )} @@ -548,6 +700,58 @@ function AccountDetailsForm({ )} /> )} + {rules?.nationalIdentificationNumber && !shouldDisplaySIREN && ( + <> + {/* Display NIN for organisations with NIN rule */} + ( + + + + {errors.nationalIdentificationNumber && + rules?.nationalIdentificationNumber && ( + + {renderTranslatedZodError( + errors.nationalIdentificationNumber.message, + rules?.nationalIdentificationNumber, + )} + + )} + + )} + /> + + )} {rules?.italianSDI && ( : ; + return isLoading ? ( + Loading... + ) : ( + Loading routes ...}> + + + ); } function App() { diff --git a/packages/manager/apps/dedicated-servers/src/components/cells/renewCell.tsx b/packages/manager/apps/dedicated-servers/src/components/cells/renewCell.tsx index 5313d3dcc6b0..fdf62ecb0d7e 100644 --- a/packages/manager/apps/dedicated-servers/src/components/cells/renewCell.tsx +++ b/packages/manager/apps/dedicated-servers/src/components/cells/renewCell.tsx @@ -9,9 +9,7 @@ export const RenewCell = (server: DedicatedServer) => { const { t } = useTranslation('dedicated-servers'); return ( - {(billingInfo) => ( -
{t(getRenewWording(billingInfo))}
- )} + {(billingInfo) =>
{t(getRenewWording(billingInfo))}
}
); }; diff --git a/packages/manager/apps/dedicated-servers/src/components/dataGridColumns.tsx b/packages/manager/apps/dedicated-servers/src/components/dataGridColumns.tsx index 3633755c0275..66f0db7bb91d 100644 --- a/packages/manager/apps/dedicated-servers/src/components/dataGridColumns.tsx +++ b/packages/manager/apps/dedicated-servers/src/components/dataGridColumns.tsx @@ -152,6 +152,20 @@ export function useColumns(): DatagridColumn[] {
{t(server.datacenter)}
), }, + { + id: 'availabilityZone', + accessorKey: 'availabilityZone', + isSearchable: false, + isFilterable: true, + isSortable: true, + enableHiding: true, + type: FilterTypeCategories.String, + header: t('server_display_availability_zone'), + label: t('server_display_availability_zone'), + cell: ({ row: { original: server } }) => ( +
{t(server.availabilityZone)}
+ ), + }, { id: 'state', accessorKey: 'state', @@ -161,6 +175,7 @@ export function useColumns(): DatagridColumn[] { isSortable: true, type: FilterTypeCategories.Boolean, header: t('server_display_state'), + label: t('server_display_state'), cell: ({ row: { original: server } }) => ( [] { enableHiding: true, type: FilterTypeCategories.Boolean, header: t('server_display_monitoring'), + label: t('server_display_monitoring'), cell: ({ row: { original: server } }) => MonitoringStatusChip(server), }, { @@ -189,6 +205,7 @@ export function useColumns(): DatagridColumn[] { enableHiding: true, type: FilterTypeCategories.String, header: t('server_display_vrack'), + label: t('server_display_vrack'), cell: ({ row: { original: server } }) => DSVrack(server), }, { @@ -200,6 +217,7 @@ export function useColumns(): DatagridColumn[] { enableHiding: true, type: FilterTypeCategories.String, header: t('server_display_renew'), + label: t('server_display_renew'), cell: ({ row: { original: server } }) => RenewCell(server), }, { @@ -211,6 +229,7 @@ export function useColumns(): DatagridColumn[] { enableHiding: true, type: FilterTypeCategories.String, header: t('server_display_expiration'), + label: t('server_display_expiration'), cell: ({ row: { original: server } }) => ExpirationCell(server), }, { @@ -222,6 +241,7 @@ export function useColumns(): DatagridColumn[] { enableHiding: true, type: FilterTypeCategories.String, header: t('server_display_engagement'), + label: t('server_display_engagement'), cell: ({ row: { original: server } }) => EngagementCell(server), }, { @@ -233,6 +253,7 @@ export function useColumns(): DatagridColumn[] { isSortable: false, type: FilterTypeCategories.String, header: t('server_display_price'), + label: t('server_display_price'), cell: ({ row: { original: server } }) => PriceCell(server), }, { @@ -256,6 +277,7 @@ export function useColumns(): DatagridColumn[] { header: '', isSortable: false, cell: ({ row: { original: server } }) => ActionCell(server), + size: 45, }, ]; } diff --git a/packages/manager/apps/dedicated-servers/src/components/errorComponent.tsx b/packages/manager/apps/dedicated-servers/src/components/errorComponent.tsx index df21b7e1eb94..470a3da25813 100644 --- a/packages/manager/apps/dedicated-servers/src/components/errorComponent.tsx +++ b/packages/manager/apps/dedicated-servers/src/components/errorComponent.tsx @@ -11,7 +11,7 @@ export type ErrorBannerProps = { export type ErrorProps = { error: ApiError } | { error: ErrorBannerProps }; export const ErrorComponent = ({ error }: ErrorProps) => { - if ("response" in error) { + if ('response' in error) { const { status, headers } = error.response; return ( @@ -20,7 +20,7 @@ export const ErrorComponent = ({ error }: ErrorProps) => { status, data: error, headers: Object.fromEntries( - Object.entries(headers ?? {}).map(([k, v]) => [k, String(v)]) + Object.entries(headers ?? {}).map(([k, v]) => [k, String(v)]), ), }} /> @@ -28,4 +28,4 @@ export const ErrorComponent = ({ error }: ErrorProps) => { } return ; -}; \ No newline at end of file +}; diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/index.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/index.tsx new file mode 100644 index 000000000000..6b2d94db4ea6 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/index.tsx @@ -0,0 +1,3 @@ +import ManageViewButton from './manageViewButton'; + +export { ManageViewButton }; diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/manageView.constants.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/manageView.constants.tsx new file mode 100644 index 000000000000..5b06821a48ca --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/manageView.constants.tsx @@ -0,0 +1,29 @@ +export const PREFERENCES_KEY = 'DATAGRID_VIEWS_CONFIG_DEDICATED_SERVERS'; +export const STANDARD_VIEW_ID = 'standard-view'; +export const MAX_VIEWS_NUMBER = 5; +export const DEFAULT_COLUMN_VISIBILITY: Record = { + serverId: false, + 'iam.displayName': true, + ip: true, + reverse: false, + commercialRange: true, + os: false, + region: true, + availabilityZone: true, + rack: false, + datacenter: false, + state: true, + monitoring: false, + vrack: false, + renew: false, + expiration: false, + engagement: false, + price: false, + tags: true, + actions: true, +}; + +export const ACCORDION_VALUES = { + order: 'order', + groupBy: 'groupby', +}; diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewButton.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewButton.tsx new file mode 100644 index 000000000000..febb1de26a3c --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewButton.tsx @@ -0,0 +1,165 @@ +import React, { useContext, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + BUTTON_VARIANT, + BUTTON_SIZE, + ICON_NAME, + Icon, + Popover, + PopoverTrigger, + PopoverContent, + Divider, + POPOVER_POSITION, + TEXT_PRESET, + Text, + RadioGroup, + Radio, + RadioControl, + RadioLabel, + Badge, + BADGE_COLOR, + BADGE_SIZE, + Tooltip, + TooltipTrigger, + TooltipContent, +} from '@ovhcloud/ods-react'; +import { MAX_VIEWS_NUMBER, STANDARD_VIEW_ID } from './manageView.constants'; +import { ViewType } from './types'; +import ManageViewDrawer from './manageViewDrawer'; +import { ViewContext } from './viewContext'; +import ManageViewDelete from './manageViewDelete'; + +export const ManageViewButton = () => { + const { t } = useTranslation('manage-view'); + const { views, currentView, setCurrentView } = useContext(ViewContext); + const [isOpoverOpen, setIsPopoverOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [viewToEdit, selectViewToEdit] = useState(null); + + const popoverChange = ({ open }: { open: boolean }) => { + setIsPopoverOpen(open); + }; + + const closeDrawer = () => { + setIsDrawerOpen(false); + }; + + const changeView = ({ value: viewId }: { value: string }) => { + setCurrentView(views.find(({ id }) => id === viewId)); + }; + + return ( +
+ + + + + +
+ + {t('current_view')} + + + + { + setIsPopoverOpen(false); + }} + /> + + + + + {t('views')} + + + + {views.map((view) => ( + + + + + {view.name} + {view.default && ( + + {t('default')} + + )} + + + ))} + + + +
+
+
+ +
+ ); +}; + +export default ManageViewButton; diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewCard.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewCard.tsx new file mode 100644 index 000000000000..5297afd8b76e --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewCard.tsx @@ -0,0 +1,87 @@ +import React, { useContext } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { TEXT_PRESET, Text } from '@ovh-ux/muk'; +import styleModule from './style.module.scss'; +import { ColumnsConfig, ViewContext } from './viewContext'; + +import { + Checkbox, + CheckboxLabel, + CheckboxControl, + ICON_NAME, + Icon, + Card, + CARD_COLOR, +} from '@ovhcloud/ods-react'; + +import { DedicatedServer } from '@/data/types/server.type'; + +export function SortableColumnCard({ + column, +}: { + column: ColumnsConfig; +}) { + const { groupBy, setColumnVisibility } = useContext(ViewContext); + const isGrouped = column.id === groupBy; + + const { + setNodeRef, + attributes, + listeners, + transform, + transition, + isDragging, + } = useSortable({ id: column.id, disabled: isGrouped }); + + return ( +
+ { + if (column.enableHiding) { + setColumnVisibility((prev) => ({ + ...prev, + [column.id]: !column.visible, + })); + } + }} + > + + + + + {column.label} + + + {!isGrouped && ( + e.stopPropagation()} + className={styleModule.dragHandle} + > + + + )} + +
+ ); +} diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewConfig.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewConfig.tsx new file mode 100644 index 000000000000..693daa2d29e5 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewConfig.tsx @@ -0,0 +1,86 @@ +import React, { useContext, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, + TEXT_PRESET, + Text, +} from '@ovhcloud/ods-react'; + +import { DndContext } from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + arrayMove, +} from '@dnd-kit/sortable'; +import { ViewContext } from './viewContext'; +import { SortableColumnCard } from './manageViewCard'; +import { ACCORDION_VALUES } from './manageView.constants'; +import ManageViewGroupBy from './manageViewGroupBy'; + +export type ManageViewConfigProps = { + drawerVisibility: boolean; +}; + +export const ManageViewConfig = ({ + drawerVisibility, +}: ManageViewConfigProps) => { + const [accordionValue, setAccordionValue] = useState([ + ACCORDION_VALUES.order, + ]); + const { columnsConfig, setOrderedColumns, groupBy, setGroupBy } = useContext( + ViewContext, + ); + const { t } = useTranslation('manage-view'); + + if (!drawerVisibility && accordionValue.includes(ACCORDION_VALUES.groupBy)) { + setAccordionValue([ACCORDION_VALUES.order]); + } + + const columnsToDisplay = columnsConfig.filter( + (column) => column.id !== 'actions', + ); + + return ( + setAccordionValue(value)} + value={accordionValue} + > + + + + {t('select_columns_visibility')} + + + + { + if (!over || active.id === over.id || active.id === groupBy) + return; + setOrderedColumns((prev) => { + const oldIndex = prev.findIndex((c) => c.id === active.id); + const newIndex = prev.findIndex((c) => c.id === over.id); + return arrayMove(prev, oldIndex, newIndex); + }); + }} + > + c.id)} + strategy={verticalListSortingStrategy} + > + {columnsToDisplay.map((column) => ( + + ))} + + + + + + + ); +}; + +export default ManageViewConfig; diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDelete.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDelete.tsx new file mode 100644 index 000000000000..325851e92bda --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDelete.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { OdsButton, OdsModal, OdsText } from '@ovhcloud/ods-components/react'; +import { ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { createPortal } from 'react-dom'; +import { + Button, + BUTTON_COLOR, + BUTTON_VARIANT, + Icon, + ICON_NAME, +} from '@ovhcloud/ods-react'; +import { ViewType } from './types'; +import { useDeleteViewPreference } from '@/hooks/manage-views/useDeleteViewPreference'; + +export type ManageViewDeleteProps = { + views: ViewType[]; + view: ViewType; + disabled: boolean; + onOpenModal?: () => void; +}; + +export default function ExportCsv({ + views, + view, + disabled, + onOpenModal, +}: ManageViewDeleteProps) { + const { t } = useTranslation('manage-view'); + const { mutate: deleteView } = useDeleteViewPreference({}); + const [isOpen, setOpen] = useState(false); + const [currentView, setCurrentView] = useState(null); + + useEffect(() => { + setCurrentView(view); + }, [isOpen, view, views]); + + const openModalHandler = () => { + setOpen(true); + if (onOpenModal) onOpenModal(); + }; + const cancelDeleteView = () => setOpen(false); + + const confirmDeleteView = () => { + if (currentView) { + deleteView({ + view: currentView, + }); + setOpen(false); + } + }; + + return ( + <> + + {view && + createPortal( + + + {t('modal_delete_title', { name: currentView?.name })} + + {t('modal_delete_description')} + + + , + document.body, + )} + + ); +} diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDrawer.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDrawer.tsx new file mode 100644 index 000000000000..1f09aaf6f285 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDrawer.tsx @@ -0,0 +1,219 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { + Button, + BUTTON_VARIANT, + Icon, + Checkbox, + CheckboxControl, + CheckboxLabel, + Text, + BUTTON_SIZE, +} from '@ovhcloud/ods-react'; +import { ODS_ICON_NAME } from '@ovhcloud/ods-components'; +import { + DEFAULT_COLUMN_VISIBILITY, + PREFERENCES_KEY, +} from './manageView.constants'; +import { Categories, ViewType } from './types'; +import { useSaveViewsPreference } from '@/hooks/manage-views/useSaveViewPreference'; +import ManageViewDrawerTitle from './manageViewDrawerTitle'; +import ManageViewConfig from './manageViewConfig'; +import { ViewContext } from './viewContext'; + +export type ManageViewDrawerProps = { + views: ViewType[]; + view?: ViewType; + isOpen: boolean; + handleDismiss: () => void; + handleConfirm: () => void; + handleCancel: () => void; +}; +export const ManageViewDrawer = ({ + views, + view, + isOpen, + handleConfirm, + handleCancel, +}: ManageViewDrawerProps) => { + const { + setColumnVisibility, + currentView, + setColumnsOrder, + groupBy: contextGroupBy, + setGroupBy: setContextGroupBy, + } = useContext(ViewContext); + const { t } = useTranslation('manage-view'); + const { t: tCommon } = useTranslation(NAMESPACES.ACTIONS); + const [editingView, setEditingView] = useState(null); + const { mutate: saveViews } = useSaveViewsPreference({ + key: PREFERENCES_KEY, + }); + + const [initialGroupBy, setInitialGroupBy] = useState( + contextGroupBy, + ); + + useEffect(() => { + let name = view?.name; + let id = view?.id; + + // increment both name and id numbers for new views to avoid duplicates, based on existing views + if (!name || !id) { + const nameRegex = new RegExp( + `${t('new_view')}\\s?(?:\\((?\\d)+\\))?`, + ); + const { maxViewNumber, maxIdNumber } = views.reduce( + (acc, v) => { + // Check new view name pattern + if (!name) { + const nameMatch = v.name.match(nameRegex); + if (nameMatch && Number(nameMatch?.groups?.number)) { + const viewNumber = Number(nameMatch.groups.number); + acc.maxViewNumber = Math.max(acc.maxViewNumber, viewNumber); + } else if (nameMatch) { + acc.maxViewNumber = Math.max(acc.maxViewNumber, 0); + } + } + + // Check view id pattern + if (!id) { + const idMatch = v.id.match(/^view-(\d+)$/); + if (idMatch) { + const idNumber = Number(idMatch[1]); + acc.maxIdNumber = Math.max(acc.maxIdNumber, idNumber); + } + } + + return acc; + }, + { maxViewNumber: -1, maxIdNumber: -1 }, + ); + + if (!name) { + name = + maxViewNumber === -1 + ? t('new_view') + : `${t('new_view')} (${maxViewNumber + 1})`; + } + + if (!id) { + id = + maxIdNumber === -1 + ? `view-${views.length}` + : `view-${maxIdNumber + 1}`; + } + } + + setEditingView({ + name, + id, + default: view?.default, + }); + + if (isOpen) { + setInitialGroupBy(contextGroupBy); + } + }, [isOpen, view, views]); + + const handleNameChange = (value: string) => { + setEditingView({ + ...editingView, + name: value, + }); + }; + + const handelSetDefault = () => { + setEditingView({ + ...editingView, + default: !editingView.default, + }); + }; + + const saveViewChanges = () => { + saveViews({ + view: { + ...editingView, + groupBy: contextGroupBy, + }, + }); + handleConfirm(); + }; + + const cancelChanges = () => { + setColumnVisibility( + currentView?.columnVisibility || DEFAULT_COLUMN_VISIBILITY, + ); + setColumnsOrder(currentView.columnOrder); + setContextGroupBy(initialGroupBy); + handleCancel(); + }; + + return ( +
+ +
+ ); +}; + +export default ManageViewDrawer; diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDrawerTitle.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDrawerTitle.tsx new file mode 100644 index 000000000000..10d97fef7e35 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewDrawerTitle.tsx @@ -0,0 +1,88 @@ +import { + Button, + BUTTON_VARIANT, + Icon, + ICON_NAME, + TEXT_PRESET, + Text, + INPUT_TYPE, + Input, +} from '@ovhcloud/ods-react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type ManageViewDrawerTitleProps = { + value: string; + onChange: (value: string) => void; +}; + +export const ManageViewDrawerTitle = ({ + value, + onChange, +}: ManageViewDrawerTitleProps) => { + const { t } = useTranslation('dedicated-servers'); + const [isEditMode, setEditMode] = useState(false); + const [inputValue, setInputValue] = useState(''); + + const handleChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + return ( +
+ {isEditMode && ( + <> + +
+ + +
+ + )} + {!isEditMode && ( + <> + + {value} + + + + )} +
+ ); +}; + +export default ManageViewDrawerTitle; diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewGroupBy.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewGroupBy.tsx new file mode 100644 index 000000000000..90db2d60444d --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/manageViewGroupBy.tsx @@ -0,0 +1,74 @@ +import { + AccordionContent, + AccordionItem, + AccordionTrigger, + TEXT_PRESET, + Text, +} from '@ovhcloud/ods-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { OdsRadio, OdsText } from '@ovhcloud/ods-components/react'; +import { Categories } from './types'; + +const CATEGORY_OPTIONS: { value: Categories; labelKey: string }[] = [ + { value: 'commercialRange', labelKey: 'server_category_model' }, + { value: 'rack', labelKey: 'server_category_rack' }, + { value: 'region', labelKey: 'server_category_region' }, + { value: 'datacenter', labelKey: 'server_category_datacenter' }, +]; + +interface ManageViewGroupByProps { + groupBy?: Categories; + setGroupBy: (groupBy?: Categories) => void; +} + +export const ManageViewGroupBy = ({ + groupBy, + setGroupBy, +}: ManageViewGroupByProps) => { + const { t } = useTranslation('manage-view'); + + const handleToggle = (value: Categories) => { + const newValue = groupBy === value ? undefined : value; + setGroupBy(newValue); + }; + + return ( + + + {t('group_rows_category')} + + +
+ {CATEGORY_OPTIONS.map((option) => ( +
handleToggle(option.value)} + > + + + +
+ ))} +
+
+
+ ); +}; + +export default ManageViewGroupBy; diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/style.module.scss b/packages/manager/apps/dedicated-servers/src/components/manageView/style.module.scss new file mode 100644 index 000000000000..fe53cd519b30 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/style.module.scss @@ -0,0 +1,57 @@ +.column-list { + display: flex; + flex-flow: column; + row-gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + touch-action: none; // empêche le scroll de bloquer le drag + + &__item { + width: 100%; + + &__card { + width: 100%; + padding: 0.5rem 1rem; + margin: 0.3rem 0; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + &.disabled { + background-color: var(--ods-color-background-disabled-default); + cursor: not-allowed; + } + &.selected { + background-color: var(--ods-color-primary-025); + } + } + } +} + +.column-list__item__wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.dragHandle { + cursor: grab; + user-select: none; +} + +.dragHandle:active { + cursor: grabbing; +} + +div[data-scope='accordion'] { + &[data-part='root'] { + display: flex; + flex-direction: column; + max-height: 100%; + } + &[data-part='item'] { + overflow: hidden; + min-height: 70px; + } +} diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/types.ts b/packages/manager/apps/dedicated-servers/src/components/manageView/types.ts new file mode 100644 index 000000000000..8a09a5ceaae3 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/types.ts @@ -0,0 +1,20 @@ +import { VisibilityState } from '@tanstack/react-table'; +import { DedicatedServer } from '@/data/types/server.type'; + +export type ViewType = { + name: string; + id: string; + default?: boolean; + columnVisibility?: VisibilityState; + columnOrder?: string[]; + groupBy?: Categories; +}; + +export type Categories = 'commercialRange' | 'rack' | 'region' | 'datacenter'; + +export interface GroupRow { + id: string; + displayName: string; + subRows: DedicatedServer[]; + [key: string]: any; +} diff --git a/packages/manager/apps/dedicated-servers/src/components/manageView/viewContext.tsx b/packages/manager/apps/dedicated-servers/src/components/manageView/viewContext.tsx new file mode 100644 index 000000000000..a909fe7c4bec --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/components/manageView/viewContext.tsx @@ -0,0 +1,293 @@ +import React, { + useMemo, + useState, + PropsWithChildren, + createContext, + useEffect, + useCallback, + useRef, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { TFunction } from 'i18next'; +import { DatagridColumn, useDataApi, UseDataApiResult } from '@ovh-ux/muk'; +import { VisibilityState, SortingState } from '@tanstack/react-table'; +import { useColumns } from '../dataGridColumns'; +import { DedicatedServer } from '@/data/types/server.type'; +import { Categories, GroupRow, ViewType } from './types'; +import { useGetViewsPreferences } from '@/hooks/manage-views/useGetViewPreferences'; +import { + DEFAULT_COLUMN_VISIBILITY, + PREFERENCES_KEY, + STANDARD_VIEW_ID, +} from './manageView.constants'; + +export type ColumnsConfig = DatagridColumn & { + visible?: boolean; + order?: number; +}; + +export type ViewContextType = { + views: ViewType[]; + currentView?: ViewType; + setCurrentView: React.Dispatch>; + columnsConfig: ColumnsConfig[]; + columnVisibility: VisibilityState; + setColumnVisibility: React.Dispatch>; + setOrderedColumns: React.Dispatch< + React.SetStateAction[]> + >; + setColumnsOrder: (order?: string[]) => void; + setViews: React.Dispatch>; + groupBy: Categories; + setGroupBy: (category: Categories) => void; + gridData: (DedicatedServer | GroupRow)[]; + isGroupingActive: boolean; + dataApi: UseDataApiResult; +}; + +export const ViewContext = createContext>({ + views: [], + currentView: null, + setCurrentView: () => {}, + columnsConfig: [], + columnVisibility: {}, + setViews: () => {}, + setOrderedColumns: () => {}, + setColumnsOrder: () => {}, + setColumnVisibility: () => {}, + groupBy: undefined, + setGroupBy: () => {}, + gridData: [], + isGroupingActive: false, + dataApi: {} as UseDataApiResult, +}); + +/** + * Utility to extract a group key based on a category property. + */ +const getCategoryGroupKey = ( + server: DedicatedServer, + category: keyof DedicatedServer, + t: TFunction<'manage-view', undefined>, +): string => { + return String(server[category] ?? t('server_category_other')); +}; + +/** + * Utility to extract the sorting state based on the current grouping strategy. + */ +const getGroupingSorting = (groupBy: Categories): SortingState => { + if (groupBy) { + return [{ id: groupBy, desc: false }]; + } + + return []; +}; + +export const ViewContextProvider = ({ children }: PropsWithChildren) => { + const { t } = useTranslation('manage-view'); + const [views, setViews] = useState([]); + const [currentView, setCurrentView] = useState(null); + const [groupBy, setGroupBy] = useState(undefined); + + const [userColumnVisibility, setUserColumnVisibility] = useState< + VisibilityState + >(DEFAULT_COLUMN_VISIBILITY); + const initialColumns = useColumns(); + const memoizedInitialColumns = useMemo(() => initialColumns, []); + const [orderedColumns, setOrderedColumns] = useState(memoizedInitialColumns); + + // Fetch saved views preferences + const { preferences, error, isLoading } = useGetViewsPreferences({ + key: PREFERENCES_KEY, + enabled: true, + }); + + const dataApi = useDataApi({ + version: 'v6', + iceberg: true, + enabled: true, + route: `/dedicated/server`, + cacheKey: ['dedicated-servers', `/dedicated/server`], + }); + + const { flattenData, sorting } = dataApi; + + const groupingSorting = useMemo(() => getGroupingSorting(groupBy), [groupBy]); + + const setSortingRef = useRef(sorting?.setSorting); + useEffect(() => { + setSortingRef.current = sorting?.setSorting; + }, [sorting?.setSorting]); + + // 1. Trigger API Call on GroupBy Change + useEffect(() => { + setSortingRef.current?.(groupingSorting); + }, [groupingSorting]); + + // 2. Data Grouping Logic + const gridDataInfo = useMemo(() => { + if (!flattenData || groupBy === undefined) { + return { + isGroupingActive: false, + gridData: flattenData || [], + }; + } + + const groupsMap = new Map(); + + flattenData.forEach((server) => { + let groupKey: string; + + if (groupBy) { + groupKey = getCategoryGroupKey(server, groupBy, t); + } else { + return; + } + + if (!groupsMap.has(groupKey)) { + groupsMap.set(groupKey, []); + } + + groupsMap.get(groupKey)!.push(server); + }); + + const groupRows: GroupRow[] = Array.from(groupsMap.entries()).map( + ([key, child]) => ({ + id: `group-${key}`, + displayName: key, + subRows: child.map((server) => ({ ...server, id: server.name })), + }), + ); + + return { + isGroupingActive: true, + gridData: groupRows, + }; + }, [groupBy, flattenData, t]); + + const { isGroupingActive, gridData } = gridDataInfo; + + const setColumnsOrder = useCallback( + (order?: string[]) => { + if (order) { + const currentOrderedColumns = memoizedInitialColumns + .map((column) => { + let columnOrderIndex = order.findIndex((id) => id === column.id); + if (columnOrderIndex === -1) { + columnOrderIndex = memoizedInitialColumns.findIndex( + (c) => c.id === column.id, + ); + } + return { + ...column, + order: columnOrderIndex, + }; + }) + .sort((a, b) => a.order - b.order); + + setOrderedColumns(currentOrderedColumns); + } else { + setOrderedColumns(memoizedInitialColumns); + } + }, + [memoizedInitialColumns], + ); + + // When preferences are loaded, set views and current view + useEffect(() => { + const viewList: ViewType[] = + preferences && !error && !isLoading ? [...preferences] : []; + + const foundDefaultView = viewList.find((view) => view.default); + + viewList.unshift({ + name: t('standard_view'), + id: STANDARD_VIEW_ID, + default: !foundDefaultView, + }); + + setViews(viewList); + if (!isLoading && !currentView) { + setCurrentView(foundDefaultView || viewList[0]); + + if (foundDefaultView?.columnOrder) { + setColumnsOrder(foundDefaultView?.columnOrder); + } + + if (foundDefaultView?.columnVisibility) { + setUserColumnVisibility(foundDefaultView.columnVisibility); + } + + setGroupBy(foundDefaultView?.groupBy); + } + }, [preferences, isLoading, error]); + + // When current view changes, update column visibility, order state and group by state + useEffect(() => { + setUserColumnVisibility({ + ...DEFAULT_COLUMN_VISIBILITY, + ...currentView?.columnVisibility, + }); + setColumnsOrder(currentView?.columnOrder); + + setGroupBy(currentView?.groupBy); + }, [currentView]); + + const columnVisibility = useMemo(() => { + if (!groupBy) return userColumnVisibility; + return { + ...userColumnVisibility, + [groupBy]: true, + }; + }, [userColumnVisibility, groupBy]); + + const viewContext = useMemo(() => { + let columns = orderedColumns.map((column) => { + const isGrouped = column.id === groupBy; + return { + ...column, + visible: !!columnVisibility[column.id], + enableHiding: isGrouped ? false : column.enableHiding, + }; + }); + + if (groupBy) { + columns = [...columns].sort((a, b) => { + if (a.id === groupBy) return -1; + if (b.id === groupBy) return 1; + return 0; + }); + } + + return { + views, + currentView, + setCurrentView, + columnsConfig: columns, + columnVisibility, + setColumnVisibility: setUserColumnVisibility, + setOrderedColumns, + setColumnsOrder, + setViews, + groupBy, + setGroupBy, + gridData, + isGroupingActive, + dataApi, + }; + }, [ + views, + currentView, + columnVisibility, + orderedColumns, + groupBy, + gridData, + isGroupingActive, + dataApi, + ]); + + return ( + {children} + ); +}; diff --git a/packages/manager/apps/dedicated-servers/src/components/orderMenu.tsx b/packages/manager/apps/dedicated-servers/src/components/orderMenu.tsx index 6c757ddecfba..57739da4bd7c 100644 --- a/packages/manager/apps/dedicated-servers/src/components/orderMenu.tsx +++ b/packages/manager/apps/dedicated-servers/src/components/orderMenu.tsx @@ -12,6 +12,7 @@ import useLinkUtils, { UrlLinks } from '@/hooks/useLinkUtils'; import { orderLinks } from '@/data/constants/orderLinks'; import ExportCsv from './exportCsv'; import { ExportCsvDataType } from './exportCsv/types'; +import { ManageViewButton } from './manageView'; export const OrderMenu: React.FC<{ exportCsvData: ExportCsvDataType }> = ({ exportCsvData, @@ -33,8 +34,8 @@ export const OrderMenu: React.FC<{ exportCsvData: ExportCsvDataType }> = ({ }; return ( -
-
+
+
= ({ label={tCommon('order')} /> +
initialColumns, []); + const [expanded, setExpanded] = useState({}); + + const { + columnVisibility, + setColumnVisibility, + columnsConfig, + groupBy, + gridData, + isGroupingActive, + dataApi, + } = useContext(ViewContext); + + const { + isError, + error, + totalCount, + hasNextPage, + fetchNextPage, + isLoading, + search, + sorting, + filters, + } = dataApi; + + const { error: errorListing, data: dedicatedServers } = useDedicatedServer(); + + const [prevGroupBy, setPrevGroupBy] = useState(groupBy); + if (groupBy !== prevGroupBy) { + setPrevGroupBy(groupBy); + setExpanded({}); + } + + const effectiveExpanded: ExpandedState = isGroupingActive + ? Object.fromEntries( + gridData + .filter((r) => 'subRows' in r) + .map((r) => [ + r.id, + typeof expanded === 'object' + ? expanded[r.id as string] ?? true + : true, + ]), + ) + : expanded; + + const handleSetExpanded: typeof setExpanded = (updater) => { + const newValue = + typeof updater === 'function' ? updater(effectiveExpanded) : updater; + // react-table removes keys to collapse rows. We need to explicitly set + // them to false so that ?? true doesn't re-open them on next render. + if (typeof newValue === 'object' && typeof effectiveExpanded === 'object') { + Object.keys(effectiveExpanded).forEach((key) => { + if (!(key in newValue)) { + (newValue as Record)[key] = false; + } + }); + } + setExpanded(newValue); + }; + + // Column Configuration Logic + // Adjust column rendering based on whether we are looking at a Group Header or a Data Row + const effectiveColumns = useMemo(() => { + // If grouping is NOT active, return standard config from context + if (!isGroupingActive) { + return columnsConfig; + } + + const visibleColumns = columnsConfig.filter((config) => config.visible); + const firstVisibleColumnId = visibleColumns[0]?.id; + const penultimateVisibleColumnId = + visibleColumns[visibleColumns.length - 2]?.id; + + return columnsConfig.map((config) => { + const isGrouped = config.id === groupBy; + + // Case A: The First Visible Column + // We hijack this column to display the Group Title when looking at a header row + if (config.id === firstVisibleColumnId) { + return { + ...config, + isSearchable: false, // Disable search on group headers + cell: (props: any) => { + const rowOriginal = props.row.original; + + if (props.row.depth === 0) { + return ( +
+ + {rowOriginal.displayName} + +
+ ); + } + + if (config.cell && typeof config.cell === 'function') { + return ( +
+ {config.cell(props)} +
+ ); + } + return null; + }, + }; + } + + // Case B: All Other Columns + // We want them to be empty on the Group Header row to avoid visual clutter or errors + return { + ...config, + isSearchable: false, + cell: (props: any) => { + // If it is a group header, render nothing in this column + if (props.row.depth === 0) { + if (config.id === penultimateVisibleColumnId) { + const rowOriginal = props.row.original; + const count = rowOriginal.subRows.length; + + return ( +
+ + {t('server_count_items')}{' '} + {count} + +
+ ); + } + return ( +
+ ); + } + + // Otherwise, render the standard cell content + if (config.cell && typeof config.cell === 'function') { + return ( +
+ {config.cell(props)} +
+ ); + } + return null; + }, + }; + }); + }, [columnsConfig, isGroupingActive, groupBy, t]); + + const fnFilter = { ...filters }; + fnFilter.add = (value) => { + const curentFilter = { ...value }; + if (curentFilter.key === 'os') { + const tps = templateList.filter((template) => + template.description + .toLowerCase() + .includes((curentFilter.value as string).toLowerCase()), + ); + if (!tps.length) return; + curentFilter.displayValue = curentFilter.value as string; + if (curentFilter.comparator === FilterComparator.Includes) { + if (tps.length === 1) { + curentFilter.value = tps[0]?.templateName; + } else { + curentFilter.comparator = FilterComparator.IsIn; + curentFilter.value = tps.map((os) => os.templateName); + } + } else { + curentFilter.value = tps[0]?.templateName; + } + } + filters.add(curentFilter); + }; + + const topbar = useMemo( + () => ( + + ), + [columns, totalCount], + ); + + return ( + <> + +
+ {(isError || errorListing) && ( + + )} + {!isError && !errorListing && ( + + [] + } + autoScroll={false} + data={gridData} + totalCount={isGroupingActive ? undefined : totalCount || 0} + hasNextPage={hasNextPage && !isLoading} + onFetchNextPage={fetchNextPage} + sorting={ + !isGroupingActive + ? sorting + : { + ...sorting, + manualSorting: false, + sorting: [], + setSorting: undefined, + } + } + isLoading={isLoading} + filters={fnFilter} + columnVisibility={{ + columnVisibility, + setColumnVisibility, + }} + search={search} + topbar={topbar} + resourceType="dedicatedServer" + // Expansion configuration for Grouping + expandable={{ + expanded: effectiveExpanded, + setExpanded: handleSetExpanded, + // Only allow expansion if grouping is active and it's a top-level row (depth 0) + getRowCanExpand: (row) => isGroupingActive && row.depth === 0, + }} + /> + + )} +
+ + ); +} diff --git a/packages/manager/apps/dedicated-servers/src/data/api/manager-preferences.ts b/packages/manager/apps/dedicated-servers/src/data/api/manager-preferences.ts new file mode 100644 index 000000000000..ae9431a281d9 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/data/api/manager-preferences.ts @@ -0,0 +1,46 @@ +import { apiClient, ApiResponse } from '@ovh-ux/manager-core-api'; +import { ViewType } from '@/components/manageView/types'; + +export type PostManagerPreferencesParams = { + key: string; + value: string; +}; + +export type GetManagerPreferencesResponse = { + key: string; + value: string; +}; + +export const getManagerPreferencesQueryKey = (key?: string) => + key + ? ['/me/preferences/manager', `/me/preferences/manager/${key}`] + : ['/me/preferences/manager']; + +export const getManagerPreferences = (key?: string): Promise => + apiClient.v6 + .get( + `/me/preferences/manager${key ? `/${encodeURIComponent(key)}` : ''}`, + ) + .then(({ data }) => { + return data?.value ? JSON.parse(data.value) : []; + }); + +export const postManagerPreferences = ( + params: PostManagerPreferencesParams, +): Promise> => + apiClient.v6.post(`/me/preferences/manager`, { + key: params.key, + value: params.value, + }); + +export type PutManagerPreferencesParams = { + key: string; + value: string; +}; + +export const putManagerPreferences = ( + params: PutManagerPreferencesParams, +): Promise> => + apiClient.v6.put(`/me/preferences/manager/${params.key}`, { + value: params.value, + }); diff --git a/packages/manager/apps/dedicated-servers/src/data/types/cluster.type.ts b/packages/manager/apps/dedicated-servers/src/data/types/cluster.type.ts index 43e9898bf4da..d11212e2488c 100644 --- a/packages/manager/apps/dedicated-servers/src/data/types/cluster.type.ts +++ b/packages/manager/apps/dedicated-servers/src/data/types/cluster.type.ts @@ -1,4 +1,4 @@ -import { IamObject } from "@ovh-ux/muk"; +import { IamObject } from '@ovh-ux/muk'; type Status = 'pending' | 'in-progress' | 'done' | 'failed'; diff --git a/packages/manager/apps/dedicated-servers/src/data/types/server.type.ts b/packages/manager/apps/dedicated-servers/src/data/types/server.type.ts index 5917ae982001..e96d5c7dfca0 100644 --- a/packages/manager/apps/dedicated-servers/src/data/types/server.type.ts +++ b/packages/manager/apps/dedicated-servers/src/data/types/server.type.ts @@ -1,4 +1,4 @@ -import { IamObject } from "@ovh-ux/muk"; +import { IamObject } from '@ovh-ux/muk'; type Datacenter = | 'bhs1' @@ -66,7 +66,7 @@ interface Tags { [key: string]: string; } -export interface DedicatedServer extends Record{ +export interface DedicatedServer extends Record { availabilityZone: string; bootId?: number | null; bootScript?: string | null; diff --git a/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useDeleteViewPreference.tsx b/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useDeleteViewPreference.tsx new file mode 100644 index 000000000000..478f1809d8c0 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useDeleteViewPreference.tsx @@ -0,0 +1,62 @@ +import { useContext } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { useNotifications } from '@ovh-ux/manager-react-components'; +import { + getManagerPreferencesQueryKey, + postManagerPreferences, +} from '@/data/api/manager-preferences'; +import { ViewContext } from '@/components/manageView/viewContext'; +import { + PREFERENCES_KEY, + STANDARD_VIEW_ID, +} from '@/components/manageView/manageView.constants'; +import { ViewType } from '@/components/manageView/types'; + +export type DeleteViewPreferenceMutationParams = { + view: ViewType; +}; + +export type UseDeleteViewPreferenceParams = { + onError?: (apiError: ApiError) => void; + onSuccess?: (variables: DeleteViewPreferenceMutationParams) => void; + onSettled?: () => void; +}; + +export const useDeleteViewPreference = ({ + onError, + onSuccess, + onSettled, +}: UseDeleteViewPreferenceParams) => { + const queryClient = useQueryClient(); + const { views, setCurrentView } = useContext(ViewContext); + const { clearNotifications } = useNotifications(); + + return useMutation({ + mutationFn: ({ view }: DeleteViewPreferenceMutationParams) => { + const updatedViews = [ + ...views.filter( + (_view) => _view.id !== view.id && _view.id !== STANDARD_VIEW_ID, + ), + ]; + + return postManagerPreferences({ + key: PREFERENCES_KEY, + value: JSON.stringify(updatedViews), + }); + }, + onSuccess: async (_, variables) => { + clearNotifications(); + setCurrentView(null); + await queryClient.invalidateQueries({ + queryKey: getManagerPreferencesQueryKey(), + }); + onSuccess?.(variables); + }, + onError: (error: ApiError) => { + clearNotifications(); + onError?.(error); + }, + onSettled, + }); +}; diff --git a/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useGetViewPreferences.tsx b/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useGetViewPreferences.tsx new file mode 100644 index 000000000000..ecfc5c51b9ac --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useGetViewPreferences.tsx @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { + getManagerPreferences, + getManagerPreferencesQueryKey, +} from '@/data/api/manager-preferences'; +import { ViewType } from '@/components/manageView/types'; + +export type UseGetViewsPreferencesParams = { + key?: string; + enabled?: boolean; +}; + +export const useGetViewsPreferences = ({ + key, + enabled = true, +}: UseGetViewsPreferencesParams) => { + const { data: preferences, isLoading, isError, error } = useQuery< + ViewType[], + ApiError + >({ + queryKey: getManagerPreferencesQueryKey(key), + queryFn: () => getManagerPreferences(key), + enabled, + retry: false, + }); + + return { + preferences, + isLoading, + isError, + error, + }; +}; diff --git a/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useSaveViewPreference.tsx b/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useSaveViewPreference.tsx new file mode 100644 index 000000000000..b77762dd4ad0 --- /dev/null +++ b/packages/manager/apps/dedicated-servers/src/hooks/manage-views/useSaveViewPreference.tsx @@ -0,0 +1,80 @@ +import { useContext } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { useNotifications } from '@ovh-ux/manager-react-components'; +import { + getManagerPreferencesQueryKey, + postManagerPreferences, +} from '@/data/api/manager-preferences'; +import { ViewContext } from '@/components/manageView/viewContext'; +import { STANDARD_VIEW_ID } from '@/components/manageView/manageView.constants'; +import { ViewType } from '@/components/manageView/types'; + +export type SaveViewsPreferenceMutationParams = { + view: ViewType; +}; + +export type UseSaveViewsPreferenceParams = { + key: string; + onError?: (apiError: ApiError) => void; + onSuccess?: (variables: SaveViewsPreferenceMutationParams) => void; + onSettled?: () => void; +}; + +export const useSaveViewsPreference = ({ + key, + onError, + onSuccess, + onSettled, +}: UseSaveViewsPreferenceParams) => { + const queryClient = useQueryClient(); + const { views, columnVisibility, columnsConfig, setCurrentView } = useContext( + ViewContext, + ); + const { clearNotifications } = useNotifications(); + + return useMutation({ + mutationFn: ({ view }: SaveViewsPreferenceMutationParams) => { + const currentDefault = views.find((_view) => _view.default); + if (view.default && currentDefault) currentDefault.default = false; + + const updatedView = { + ...view, + columnVisibility, + columnOrder: columnsConfig.map((column) => column.id), + }; + + const updatedViews = [ + ...views.filter( + (_view) => + _view.id !== updatedView.id && + _view.id !== currentDefault?.id && + _view.id !== STANDARD_VIEW_ID, + ), + ...(![STANDARD_VIEW_ID, updatedView.id].includes(currentDefault?.id) + ? [currentDefault] + : []), + updatedView, + ]; + + setCurrentView(updatedView); + + return postManagerPreferences({ + key, + value: JSON.stringify(updatedViews), + }); + }, + onSuccess: async (_, variables) => { + clearNotifications(); + await queryClient.invalidateQueries({ + queryKey: getManagerPreferencesQueryKey(), + }); + onSuccess?.(variables); + }, + onError: (error: ApiError) => { + clearNotifications(); + onError?.(error); + }, + onSettled, + }); +}; diff --git a/packages/manager/apps/dedicated-servers/src/hooks/useGetFormatedExportCsvData.tsx b/packages/manager/apps/dedicated-servers/src/hooks/useGetFormatedExportCsvData.tsx index f584cf65273d..1e95ac47e800 100644 --- a/packages/manager/apps/dedicated-servers/src/hooks/useGetFormatedExportCsvData.tsx +++ b/packages/manager/apps/dedicated-servers/src/hooks/useGetFormatedExportCsvData.tsx @@ -44,6 +44,7 @@ const csvRowsMapper: { region: ({ region }) => region, rack: ({ rack }) => rack, datacenter: ({ datacenter }) => datacenter, + availabilityZone: ({ availabilityZone }) => availabilityZone, state: ({ state }) => textByProductStatus[state], monitoring: ({ monitoring, noIntervention }) => monitoringStatusWording(monitoring, noIntervention), @@ -82,8 +83,8 @@ export default ({ totalCount, columns }: ExportCsvDataType) => { columns .filter(({ id }) => !USELESS_COLUMNS.includes(id)) .reduce( - ([prevHeadings, prevIds], { label, id }) => [ - [...prevHeadings, label], + ([prevHeadings, prevIds], { label, header, id }) => [ + [...prevHeadings, label || header], [...prevIds, id], ], [[], []], diff --git a/packages/manager/apps/dedicated-servers/src/index.tsx b/packages/manager/apps/dedicated-servers/src/index.tsx index c6fa5cd487ec..1798dcd57541 100644 --- a/packages/manager/apps/dedicated-servers/src/index.tsx +++ b/packages/manager/apps/dedicated-servers/src/index.tsx @@ -34,7 +34,7 @@ const init = async (appName: string) => { context, reloadOnLocaleChange: true, defaultNS: appName, - ns: ['dedicated-servers', 'cluster', 'onboarding'], + ns: ['dedicated-servers', 'cluster', 'onboarding', 'manage-view'], }); const region = context.environment.getRegion(); diff --git a/packages/manager/apps/dedicated-servers/src/pages/listing/index.tsx b/packages/manager/apps/dedicated-servers/src/pages/listing/index.tsx index d06a68d8d541..e8f71195682d 100644 --- a/packages/manager/apps/dedicated-servers/src/pages/listing/index.tsx +++ b/packages/manager/apps/dedicated-servers/src/pages/listing/index.tsx @@ -12,11 +12,7 @@ import { useRouteSynchro, ShellContext, } from '@ovh-ux/manager-react-shell-client'; -import { - ChangelogMenu, - BaseLayout, - GuideMenu, -} from '@ovh-ux/muk'; +import { ChangelogMenu, BaseLayout, GuideMenu } from '@ovh-ux/muk'; import { OdsTabs, OdsTab } from '@ovhcloud/ods-components/react'; import { useTranslation } from 'react-i18next'; import { CHANGELOG_LINKS } from '@/data/constants/changelogLinks'; @@ -72,7 +68,7 @@ export default function Layout() { header={{ title: t('title'), changelogButton: , - guideMenu: + guideMenu: , }} tabs={
diff --git a/packages/manager/apps/dedicated-servers/src/pages/listing/server/index.scss b/packages/manager/apps/dedicated-servers/src/pages/listing/server/index.scss index 50a83d26ea1f..02d01dc06009 100644 --- a/packages/manager/apps/dedicated-servers/src/pages/listing/server/index.scss +++ b/packages/manager/apps/dedicated-servers/src/pages/listing/server/index.scss @@ -9,3 +9,9 @@ display: flex; justify-content: center; } + +#container > #right-side { + button:has([class*='icon--columns']) { + display: none; + } +} diff --git a/packages/manager/apps/dedicated-servers/src/pages/listing/server/index.tsx b/packages/manager/apps/dedicated-servers/src/pages/listing/server/index.tsx index 005f37d32165..b1ec3a4537a8 100644 --- a/packages/manager/apps/dedicated-servers/src/pages/listing/server/index.tsx +++ b/packages/manager/apps/dedicated-servers/src/pages/listing/server/index.tsx @@ -1,117 +1,12 @@ -import React, { useState } from 'react'; +import React from 'react'; import './index.scss'; -import { Datagrid, useDataApi, RedirectionGuard } from '@ovh-ux/muk'; -import { ApiError, FilterComparator } from '@ovh-ux/manager-core-api'; -import { VisibilityState } from '@tanstack/react-table'; -import { FilterWithLabel } from '@ovh-ux/muk/dist/types/src/components/filters/Filter.props'; -import OrderMenu from '@/components/orderMenu'; -import { useColumns } from '@/components/dataGridColumns'; -import { useDedicatedServer } from '@/hooks/useDedicatedServer'; -import { urls } from '@/routes/routes.constant'; -import { ErrorComponent } from '@/components/errorComponent'; -import { DedicatedServer } from '@/data/types/server.type'; -import { useGetTemplateInfos } from '@/hooks/useGetTemplateInfo'; +import { ViewContextProvider } from '@/components/manageView/viewContext'; +import ServerDatagrid from '@/components/serverDatagrid'; export default function ServerListing() { - const { templateList } = useGetTemplateInfos(); - const columns = useColumns(); - const [columnVisibility, setColumnVisibility] = useState({ - serverId: false, - displayName: true, - ip: true, - reverse: false, - commercialRange: true, - os: false, - region: true, - rack: false, - datacenter: false, - state: true, - monitoring: false, - vrack: false, - renew: false, - expiration: false, - engagement: false, - price: false, - tags: true, - actions: true, - }); - - const { - flattenData, - isError, - error, - totalCount, - hasNextPage, - fetchNextPage, - isLoading, - search, - sorting, - filters, - } = useDataApi({ - version: 'v6', - iceberg: true, - enabled: true, - route: `/dedicated/server`, - cacheKey: ['dedicated-servers', `/dedicated/server`], - }); - const { error: errorListing, data: dedicatedServer } = useDedicatedServer(); - - const fnFilter = { ...filters }; - fnFilter.add = (value: FilterWithLabel) => { - const curentFilter = { ...value }; - if (curentFilter.key === 'os') { - const tps = templateList.filter((template) => - template.description - .toLowerCase() - .includes((curentFilter.value as string).toLowerCase()), - ); - if (!tps.length) return; - curentFilter.displayValue = curentFilter.value as string; - if (curentFilter.comparator === FilterComparator.Includes) { - if (tps.length === 1) { - curentFilter.value = tps[0]?.templateName; - } else { - curentFilter.comparator = FilterComparator.IsIn; - curentFilter.value = tps.map((os) => os.templateName); - } - } else { - curentFilter.value = tps[0]?.templateName; - } - } - filters.add(curentFilter); - }; - return ( - <> - {(isError || errorListing) && ( - - )} - {!isError && !errorListing && ( - - {flattenData && ( -
- } - resourceType="dedicatedServer" - /> -
- )} -
- )} - + + + ); } diff --git a/packages/manager/apps/dedicated-servers/src/routes/routes.tsx b/packages/manager/apps/dedicated-servers/src/routes/routes.tsx index c9f39b645873..ac0d7fb78b5a 100644 --- a/packages/manager/apps/dedicated-servers/src/routes/routes.tsx +++ b/packages/manager/apps/dedicated-servers/src/routes/routes.tsx @@ -1,63 +1,44 @@ -import React from 'react'; -import { RouteObject } from 'react-router-dom'; +import React, { lazy } from 'react'; +import { Route } from 'react-router-dom'; +import { urls } from './routes.constant'; +import { ErrorBoundary } from '@ovh-ux/muk'; import { PageType } from '@ovh-ux/manager-react-shell-client'; -import NotFound from '@/pages/404'; -import { urls } from '@/routes/routes.constant'; -const lazyRouteConfig = (importFn: CallableFunction): Partial => { - return { - lazy: async () => { - const { default: moduleDefault, ...moduleExports } = await importFn(); - return { - Component: moduleDefault, - ...moduleExports, - }; - }, - }; -}; +const LayoutPage = lazy(() => import('@/pages/listing')); +const ServerListing = lazy(() => import('@/pages/listing/server')); +const ClusterListing = lazy(() => import('@/pages/listing/cluster')); +const OnboardingPage = lazy(() => import('@/pages/onboarding')); -export const routes: any = [ - { - path: urls.root, - ...lazyRouteConfig(() => import('@/pages/listing/index')), - children: [ - { - id: 'server', - path: urls.server, - ...lazyRouteConfig(() => import('@/pages/listing/server')), - handle: { - tracking: { - pageName: 'all-servers', - pageType: PageType.listing, - }, - }, - }, - { - id: 'cluster', - path: urls.cluster, - ...lazyRouteConfig(() => import('@/pages/listing/cluster')), - handle: { - tracking: { - pageName: 'cluster', - pageType: PageType.listing, - }, - }, - }, - ], - }, - { - id: 'onboarding', - path: urls.onboarding, - ...lazyRouteConfig(() => import('@/pages/onboarding')), - handle: { - tracking: { - pageName: 'onboarding', - pageType: PageType.onboarding, - }, - }, - }, - { - path: '*', - element: , - }, -]; +export default ( + <> + } + > + + + + } + /> + +); diff --git a/packages/manager/apps/dedicated-servers/src/test-utils/TestApp.tsx b/packages/manager/apps/dedicated-servers/src/test-utils/TestApp.tsx index de7fbfd6f8d4..e3786ad19e36 100644 --- a/packages/manager/apps/dedicated-servers/src/test-utils/TestApp.tsx +++ b/packages/manager/apps/dedicated-servers/src/test-utils/TestApp.tsx @@ -1,10 +1,14 @@ import React from 'react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; -import { createMemoryRouter, RouterProvider } from 'react-router-dom'; -import { routes } from '../routes/routes'; +import { + createMemoryRouter, + createRoutesFromElements, + RouterProvider, +} from 'react-router-dom'; +import routes from '../routes/routes'; export function TestApp({ initialRoute = '/' }) { - const router = createMemoryRouter(routes, { + const router = createMemoryRouter(createRoutesFromElements(routes), { initialEntries: [initialRoute], initialIndex: 0, }); diff --git a/packages/manager/apps/dedicated-servers/src/test-utils/setupTests.tsx b/packages/manager/apps/dedicated-servers/src/test-utils/setupTests.tsx index 2701298e818c..d677cf6a7bac 100644 --- a/packages/manager/apps/dedicated-servers/src/test-utils/setupTests.tsx +++ b/packages/manager/apps/dedicated-servers/src/test-utils/setupTests.tsx @@ -1,8 +1,10 @@ +/* eslint-disable class-methods-use-this */ import { vi } from 'vitest'; import React from 'react'; import '@testing-library/jest-dom'; import { Navigate, NavLinkProps, Path, To } from 'react-router-dom'; import { ShellContextType } from '@ovh-ux/manager-react-shell-client'; +import { ErrorBoundary } from '@ovh-ux/muk'; const shellMock = ({ ux: { @@ -261,6 +263,9 @@ vi.mock('@ovh-ux/muk', async () => { {children &&
{children}
} ), + ErrorBoundary: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), useDataApi: vi.fn(() => ({ flattenData: [], hasNextPage: false, @@ -269,6 +274,10 @@ vi.mock('@ovh-ux/muk', async () => { filters: {}, sorting: {}, })), + TEXT_PRESET: { + heading4: 'heading4', + }, + Text: ({ children }: any) => {children}, }; }); @@ -288,3 +297,44 @@ vi.mock('@ovh-ux/manager-core-api', async () => { }, }; }); + +// ResizeObserver mock +class ResizeObserverMock { + observe = () => {}; + + unobserve() {} + + disconnect() {} +} + +global.ResizeObserver = ResizeObserverMock as any; + +// matchMedia mock +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, // deprecated but required + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); + +// getBoundingClientRect mock +HTMLElement.prototype.getBoundingClientRect = function() { + return { + width: 100, + height: 50, + top: 0, + left: 0, + bottom: 50, + right: 100, + x: 0, + y: 0, + toJSON: () => {}, + }; +}; diff --git a/packages/manager/apps/dedicated-servers/tailwind.config.mjs b/packages/manager/apps/dedicated-servers/tailwind.config.mjs index 9c8eddbbab15..a6a253e47893 100644 --- a/packages/manager/apps/dedicated-servers/tailwind.config.mjs +++ b/packages/manager/apps/dedicated-servers/tailwind.config.mjs @@ -9,7 +9,7 @@ export default { './src/**/*.{js,jsx,ts,tsx}', path.join( path.dirname(require.resolve('@ovh-ux/muk')), - '**/*.{js,jsx,ts,tsx}', + '**/*.{js,jsx,ts,tsx,cjs}', ), ], theme: { diff --git a/packages/manager/apps/dedicated-servers/tsconfig.json b/packages/manager/apps/dedicated-servers/tsconfig.json index e2104f471575..d954594c183f 100644 --- a/packages/manager/apps/dedicated-servers/tsconfig.json +++ b/packages/manager/apps/dedicated-servers/tsconfig.json @@ -5,7 +5,7 @@ "target": "es2020", "types": ["vite/client", "node"], "module": "ES2020", - "moduleResolution": "node", + "moduleResolution": "bundler", "removeComments": true, "outDir": "dist", "esModuleInterop": true, diff --git a/packages/manager/apps/dedicated-servers/vitest.config.js b/packages/manager/apps/dedicated-servers/vitest.config.js index 7d705125814a..cdbb076fe095 100644 --- a/packages/manager/apps/dedicated-servers/vitest.config.js +++ b/packages/manager/apps/dedicated-servers/vitest.config.js @@ -15,6 +15,10 @@ export default mergeConfig( coverage: { exclude: [...defaultExcludedFiles], }, + css: false, + deps: { + inline: ['@ovhcloud/ods-react'], + }, }, resolve: { dedupe: [...defaultDedupedDependencies], diff --git a/packages/manager/apps/dedicated/CHANGELOG.md b/packages/manager/apps/dedicated/CHANGELOG.md index b18283f14fe2..b31f9103c2e6 100644 --- a/packages/manager/apps/dedicated/CHANGELOG.md +++ b/packages/manager/apps/dedicated/CHANGELOG.md @@ -3,6 +3,51 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [20.75.3](https://github.com/ovh/manager/compare/@ovh-ux/manager-dedicated@20.75.2...@ovh-ux/manager-dedicated@20.75.3) (2026-03-11) + +**Note:** Version bump only for package @ovh-ux/manager-dedicated + + + + + +## [20.75.2](https://github.com/ovh/manager/compare/@ovh-ux/manager-dedicated@20.75.1...@ovh-ux/manager-dedicated@20.75.2) (2026-03-10) + +**Note:** Version bump only for package @ovh-ux/manager-dedicated + + + + + +## [20.75.1](https://github.com/ovh/manager/compare/@ovh-ux/manager-dedicated@20.75.0...@ovh-ux/manager-dedicated@20.75.1) (2026-03-09) + +**Note:** Version bump only for package @ovh-ux/manager-dedicated + + + + + +# [20.75.0](https://github.com/ovh/manager/compare/@ovh-ux/manager-dedicated@20.74.15...@ovh-ux/manager-dedicated@20.75.0) (2026-03-05) + + +### Features + +* **network-interfaces:** reworking the display of aggregated network interfaces ([c451aa6](https://github.com/ovh/manager/commit/c451aa66cca353fda4887a066245bd5f45a613c6)), closes [#MANAGER-17094](https://github.com/ovh/manager/issues/MANAGER-17094) +* **ola-nics:** adding aggregation/desaggregation buttons to nics lists ([c9b13a2](https://github.com/ovh/manager/commit/c9b13a2c3d987477c34af214f68bcf0249efc850)), closes [#MANAGER-17106](https://github.com/ovh/manager/issues/MANAGER-17106) +* **ola-nics:** include the OLA information into the bandwidth display ([5c68904](https://github.com/ovh/manager/commit/5c689043c2722c9bba794bb28c833654a69533d1)), closes [#MANAGER-17071](https://github.com/ovh/manager/issues/MANAGER-17071) + + + + + +## [20.74.15](https://github.com/ovh/manager/compare/@ovh-ux/manager-dedicated@20.74.14...@ovh-ux/manager-dedicated@20.74.15) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-dedicated + + + + + ## [20.74.14](https://github.com/ovh/manager/compare/@ovh-ux/manager-dedicated@20.74.13...@ovh-ux/manager-dedicated@20.74.14) (2026-03-03) diff --git a/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/clusters/nodes/interfaces/interfaces.html b/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/clusters/nodes/interfaces/interfaces.html index 10a9f1c7ef09..0d675723e98b 100644 --- a/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/clusters/nodes/interfaces/interfaces.html +++ b/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/clusters/nodes/interfaces/interfaces.html @@ -1,19 +1,7 @@
-
- - -
- -
+
@@ -33,6 +22,7 @@ data-server="$ctrl.server" data-server-name="$ctrl.serverName" data-interfaces="$ctrl.interfaces" + data-ola="$ctrl.ola" data-specifications="$ctrl.specifications" data-urls="$ctrl.urls" data-failover-ips="$ctrl.failoverIps" diff --git a/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/clusters/nodes/interfaces/interfaces.routing.js b/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/clusters/nodes/interfaces/interfaces.routing.js index 747867bcbdd1..7064a46a04af 100644 --- a/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/clusters/nodes/interfaces/interfaces.routing.js +++ b/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/clusters/nodes/interfaces/interfaces.routing.js @@ -87,7 +87,7 @@ export default /* @ngInject */ ($stateProvider) => { urls: /* @ngInject */ (constants, user) => constants.urls[user.ovhSubsidiary], breadcrumb: /* @ngInject */ ($translate) => - $translate.instant('dedicated_server_interfaces'), + $translate.instant('dedicated_server_interfaces_breadcrumb'), goToInterfaces: ($state, Alerter, serverName) => ( message = false, type = 'success', diff --git a/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/servers/interfaces/interfaces.html b/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/servers/interfaces/interfaces.html index cbe493474fdf..66aed1b923da 100644 --- a/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/servers/interfaces/interfaces.html +++ b/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/servers/interfaces/interfaces.html @@ -1,19 +1,7 @@
-
- - -
- -
+
@@ -33,6 +22,7 @@ data-server="$ctrl.server" data-server-name="$ctrl.serverName" data-interfaces="$ctrl.interfaces" + data-ola="$ctrl.ola" data-specifications="$ctrl.specifications" data-urls="$ctrl.urls" data-failover-ips="$ctrl.failoverIps" diff --git a/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/servers/interfaces/interfaces.routing.js b/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/servers/interfaces/interfaces.routing.js index ce5cfcac108e..ad571d7b62d8 100644 --- a/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/servers/interfaces/interfaces.routing.js +++ b/packages/manager/apps/dedicated/client/app/dedicated/dedicated-server/servers/interfaces/interfaces.routing.js @@ -90,7 +90,7 @@ export default /* @ngInject */ ($stateProvider) => { urls: /* @ngInject */ (constants, user) => constants.urls[user.ovhSubsidiary], breadcrumb: /* @ngInject */ ($translate) => - $translate.instant('dedicated_server_interfaces'), + $translate.instant('dedicated_server_interfaces_breadcrumb'), goToInterfaces: ($state, Alerter, serverName) => ( message = false, type = 'success', diff --git a/packages/manager/apps/dedicated/package.json b/packages/manager/apps/dedicated/package.json index 68587106f388..02a6db1a7aac 100644 --- a/packages/manager/apps/dedicated/package.json +++ b/packages/manager/apps/dedicated/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-dedicated", - "version": "20.74.14", + "version": "20.75.3", "private": true, "description": "OVHcloud Dedicated control panel.", "repository": { @@ -26,34 +26,34 @@ "@ovh-ux/manager-banner": "^1.3.7", "@ovh-ux/manager-beta-preference": "^1.0.6", "@ovh-ux/manager-billing": "^0.65.4", - "@ovh-ux/manager-billing-components": "^4.35.5", - "@ovh-ux/manager-bm-server-components": "^2.21.1", + "@ovh-ux/manager-billing-components": "^4.35.6", + "@ovh-ux/manager-bm-server-components": "^2.22.0", "@ovh-ux/manager-catalog-price": "^1.8.1", "@ovh-ux/manager-cda": "^1.10.2", "@ovh-ux/manager-cloud-connect": "^1.27.1", "@ovh-ux/manager-cloud-styles": "^1.9.3", - "@ovh-ux/manager-components": "^1.28.0", + "@ovh-ux/manager-components": "^1.29.0", "@ovh-ux/manager-config": "^8.9.0", "@ovh-ux/manager-core": "^13.4.4", "@ovh-ux/manager-dbaas-logs": "^1.37.5", "@ovh-ux/manager-error-page": "^2.4.7", "@ovh-ux/manager-exchange": "^4.18.3", "@ovh-ux/manager-filters": "^1.2.2", - "@ovh-ux/manager-iplb": "^1.30.2", + "@ovh-ux/manager-iplb": "^1.31.0", "@ovh-ux/manager-log-to-customer": "^2.8.2", "@ovh-ux/manager-metrics": "^1.6.2", "@ovh-ux/manager-models": "^2.7.11", "@ovh-ux/manager-nasha": "^2.20.3", "@ovh-ux/manager-netapp": "^2.17.10", "@ovh-ux/manager-ng-layout-helpers": "^2.13.1", - "@ovh-ux/manager-nutanix": "^2.11.29", + "@ovh-ux/manager-nutanix": "^2.12.0", "@ovh-ux/manager-resource-tagging": "^1.3.2", "@ovh-ux/manager-support": "^2.1.45", "@ovh-ux/manager-trusted-nic": "^1.2.2", "@ovh-ux/manager-veeam-enterprise": "^1.13.3", "@ovh-ux/manager-vps": "^2.50.15", - "@ovh-ux/manager-vrack": "^1.22.0", - "@ovh-ux/ng-at-internet": "^6.0.45", + "@ovh-ux/manager-vrack": "^1.23.0", + "@ovh-ux/ng-at-internet": "^6.0.46", "@ovh-ux/ng-at-internet-ui-router-plugin": "^3.5.6", "@ovh-ux/ng-log-live-tail": "^2.7.2", "@ovh-ux/ng-ovh-api-wrappers": "^5.1.0", @@ -82,13 +82,13 @@ "@ovh-ux/ng-ovh-web-universe-components": "^9.21.3", "@ovh-ux/ng-pagination-front": "^10.3.7", "@ovh-ux/ng-q-allsettled": "^2.1.7", - "@ovh-ux/ng-shell-tracking": "^0.7.39", + "@ovh-ux/ng-shell-tracking": "^0.7.40", "@ovh-ux/ng-tail-logs": "^2.2.6", "@ovh-ux/ng-translate-async-loader": "^2.2.7", "@ovh-ux/ng-ui-router-breadcrumb": "^1.4.3", "@ovh-ux/ng-ui-router-layout": "^4.3.7", "@ovh-ux/request-tagger": "^0.6.0", - "@ovh-ux/shell": "^4.10.6", + "@ovh-ux/shell": "^4.10.7", "@ovh-ux/ui-kit": "^6.10.5", "@uirouter/angularjs": "^1.0.23", "URIjs": "^1.14.0", diff --git a/packages/manager/apps/exchange/CHANGELOG.md b/packages/manager/apps/exchange/CHANGELOG.md index 13ee698575d7..7736e53ef006 100644 --- a/packages/manager/apps/exchange/CHANGELOG.md +++ b/packages/manager/apps/exchange/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.13.49](https://github.com/ovh/manager/compare/@ovh-ux/manager-exchange-app@0.13.48...@ovh-ux/manager-exchange-app@0.13.49) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-exchange-app + + + + + ## [0.13.48](https://github.com/ovh/manager/compare/@ovh-ux/manager-exchange-app@0.13.47...@ovh-ux/manager-exchange-app@0.13.48) (2026-02-11) **Note:** Version bump only for package @ovh-ux/manager-exchange-app diff --git a/packages/manager/apps/exchange/package.json b/packages/manager/apps/exchange/package.json index 82fba888969b..2ba3adb716e5 100644 --- a/packages/manager/apps/exchange/package.json +++ b/packages/manager/apps/exchange/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-exchange-app", - "version": "0.13.48", + "version": "0.13.49", "private": true, "repository": { "type": "git", @@ -23,7 +23,7 @@ "@ovh-ux/manager-log-to-customer": "^2.8.2", "@ovh-ux/manager-ng-apiv2-helper": "^0.2.1", "@ovh-ux/manager-ng-layout-helpers": "^2.13.1", - "@ovh-ux/ng-at-internet": "^6.0.45", + "@ovh-ux/ng-at-internet": "^6.0.46", "@ovh-ux/ng-log-live-tail": "^2.7.2", "@ovh-ux/ng-ovh-api-wrappers": "^5.1.0", "@ovh-ux/ng-ovh-chart": "^1.3.5", diff --git a/packages/manager/apps/freefax/CHANGELOG.md b/packages/manager/apps/freefax/CHANGELOG.md index b15f233e3173..abe7480cc44a 100644 --- a/packages/manager/apps/freefax/CHANGELOG.md +++ b/packages/manager/apps/freefax/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.78](https://github.com/ovh/manager/compare/@ovh-ux/manager-freefax-app@9.0.77...@ovh-ux/manager-freefax-app@9.0.78) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-freefax-app + + + + + ## [9.0.77](https://github.com/ovh/manager/compare/@ovh-ux/manager-freefax-app@9.0.76...@ovh-ux/manager-freefax-app@9.0.77) (2026-02-20) diff --git a/packages/manager/apps/freefax/package.json b/packages/manager/apps/freefax/package.json index 5fa978f86853..4863ab8da9b4 100644 --- a/packages/manager/apps/freefax/package.json +++ b/packages/manager/apps/freefax/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-freefax-app", - "version": "9.0.77", + "version": "9.0.78", "private": true, "description": "Freefax standalone application.", "repository": { @@ -23,7 +23,7 @@ "@ovh-ux/manager-freefax": "^7.9.6", "@ovh-ux/manager-ng-layout-helpers": "^2.13.1", "@ovh-ux/manager-telecom-styles": "^4.8.8", - "@ovh-ux/ng-at-internet": "^6.0.45", + "@ovh-ux/ng-at-internet": "^6.0.46", "@ovh-ux/ng-ovh-api-wrappers": "^5.1.0", "@ovh-ux/ng-ovh-checkbox-table": "^2.1.7", "@ovh-ux/ng-ovh-contracts": "^4.6.6", diff --git a/packages/manager/apps/hpc-backup-agent-iaas/CHANGELOG.md b/packages/manager/apps/hpc-backup-agent-iaas/CHANGELOG.md index 78154e347bac..460de70169f7 100644 --- a/packages/manager/apps/hpc-backup-agent-iaas/CHANGELOG.md +++ b/packages/manager/apps/hpc-backup-agent-iaas/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.1.23](https://github.com/ovh/manager/compare/@ovh-ux/manager-hpc-backup-agent-iaas-app@0.1.22...@ovh-ux/manager-hpc-backup-agent-iaas-app@0.1.23) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-hpc-backup-agent-iaas-app + + + + + ## [0.1.22](https://github.com/ovh/manager/compare/@ovh-ux/manager-hpc-backup-agent-iaas-app@0.1.21...@ovh-ux/manager-hpc-backup-agent-iaas-app@0.1.22) (2026-02-26) **Note:** Version bump only for package @ovh-ux/manager-hpc-backup-agent-iaas-app diff --git a/packages/manager/apps/hpc-backup-agent-iaas/package.json b/packages/manager/apps/hpc-backup-agent-iaas/package.json index 144f505cbc26..a74c563fcf68 100644 --- a/packages/manager/apps/hpc-backup-agent-iaas/package.json +++ b/packages/manager/apps/hpc-backup-agent-iaas/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-hpc-backup-agent-iaas-app", - "version": "0.1.22", + "version": "0.1.23", "private": true, "description": "OVHcloud Backup Agent for IaaS", "repository": { @@ -25,10 +25,10 @@ "@ovh-ux/manager-core-api": "^0.21.2", "@ovh-ux/manager-core-utils": "*", "@ovh-ux/manager-react-components": "2.43.1", - "@ovh-ux/manager-react-core-application": "^0.15.4", + "@ovh-ux/manager-react-core-application": "^0.15.5", "@ovh-ux/manager-react-shell-client": "^0.11.2", "@ovh-ux/request-tagger": "*", - "@ovh-ux/shell": "^4.10.6", + "@ovh-ux/shell": "^4.10.7", "@ovhcloud/ods-components": "~18.6.2", "@ovhcloud/ods-themes": "~18.6.2", "@tanstack/react-query": "5.51.21", diff --git a/packages/manager/apps/hpc-vmware-public-vcf-aas/CHANGELOG.md b/packages/manager/apps/hpc-vmware-public-vcf-aas/CHANGELOG.md index b29faeaf0b40..2ab07d2b8398 100644 --- a/packages/manager/apps/hpc-vmware-public-vcf-aas/CHANGELOG.md +++ b/packages/manager/apps/hpc-vmware-public-vcf-aas/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.31.2](https://github.com/ovh/manager/compare/@ovh-ux/manager-hpc-vmware-public-vcf-aas-app@0.31.1...@ovh-ux/manager-hpc-vmware-public-vcf-aas-app@0.31.2) (2026-03-11) + +**Note:** Version bump only for package @ovh-ux/manager-hpc-vmware-public-vcf-aas-app + + + + + +## [0.31.1](https://github.com/ovh/manager/compare/@ovh-ux/manager-hpc-vmware-public-vcf-aas-app@0.31.0...@ovh-ux/manager-hpc-vmware-public-vcf-aas-app@0.31.1) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-hpc-vmware-public-vcf-aas-app + + + + + # [0.31.0](https://github.com/ovh/manager/compare/@ovh-ux/manager-hpc-vmware-public-vcf-aas-app@0.30.3...@ovh-ux/manager-hpc-vmware-public-vcf-aas-app@0.31.0) (2026-02-24) diff --git a/packages/manager/apps/hpc-vmware-public-vcf-aas/package.json b/packages/manager/apps/hpc-vmware-public-vcf-aas/package.json index 6e84d55dcd57..6a45a26f533f 100644 --- a/packages/manager/apps/hpc-vmware-public-vcf-aas/package.json +++ b/packages/manager/apps/hpc-vmware-public-vcf-aas/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-hpc-vmware-public-vcf-aas-app", - "version": "0.31.0", + "version": "0.31.2", "private": true, "description": "New managed VMware Public VCF aas offers", "repository": { @@ -27,9 +27,9 @@ "@ovh-ux/manager-module-common-api": "^0.6.7", "@ovh-ux/manager-module-order": "^0.14.1", "@ovh-ux/manager-module-vcd-api": "^0.11.3", - "@ovh-ux/manager-network-common": "^0.9.0", + "@ovh-ux/manager-network-common": "^0.10.0", "@ovh-ux/manager-react-components": "^2.43.1", - "@ovh-ux/manager-react-core-application": "^0.15.4", + "@ovh-ux/manager-react-core-application": "^0.15.5", "@ovh-ux/manager-react-shell-client": "^0.11.2", "@ovh-ux/request-tagger": "^0.6.0", "@ovhcloud/ods-components": "^18.6.2", diff --git a/packages/manager/apps/hpc-vmware-vsphere/CHANGELOG.md b/packages/manager/apps/hpc-vmware-vsphere/CHANGELOG.md index 06eeb6ad6882..980446781c7c 100644 --- a/packages/manager/apps/hpc-vmware-vsphere/CHANGELOG.md +++ b/packages/manager/apps/hpc-vmware-vsphere/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.3.20](https://github.com/ovh/manager/compare/@ovh-ux/manager-hpc-vmware-vsphere-app@0.3.19...@ovh-ux/manager-hpc-vmware-vsphere-app@0.3.20) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-hpc-vmware-vsphere-app + + + + + ## [0.3.19](https://github.com/ovh/manager/compare/@ovh-ux/manager-hpc-vmware-vsphere-app@0.3.18...@ovh-ux/manager-hpc-vmware-vsphere-app@0.3.19) (2026-02-16) **Note:** Version bump only for package @ovh-ux/manager-hpc-vmware-vsphere-app diff --git a/packages/manager/apps/hpc-vmware-vsphere/package.json b/packages/manager/apps/hpc-vmware-vsphere/package.json index 4d2b12c38c09..2a5d38d75340 100644 --- a/packages/manager/apps/hpc-vmware-vsphere/package.json +++ b/packages/manager/apps/hpc-vmware-vsphere/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-hpc-vmware-vsphere-app", - "version": "0.3.19", + "version": "0.3.20", "private": true, "description": "Managed VMware vsphere services", "repository": { @@ -27,7 +27,7 @@ "@ovh-ux/manager-core-utils": "^0.5.0", "@ovh-ux/manager-module-order": "^0.14.1", "@ovh-ux/manager-react-components": "2.43.1", - "@ovh-ux/manager-react-core-application": "^0.15.4", + "@ovh-ux/manager-react-core-application": "^0.15.5", "@ovh-ux/manager-react-shell-client": "^0.11.2", "@ovh-ux/request-tagger": "^0.6.0", "@ovhcloud/ods-components": "18.6.2", diff --git a/packages/manager/apps/hub/CHANGELOG.md b/packages/manager/apps/hub/CHANGELOG.md index 130f060eb2c1..ef94a9e58af8 100644 --- a/packages/manager/apps/hub/CHANGELOG.md +++ b/packages/manager/apps/hub/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.29.8](https://github.com/ovh/manager/compare/@ovh-ux/manager-hub-app@0.29.7...@ovh-ux/manager-hub-app@0.29.8) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-hub-app + + + + + ## [0.29.7](https://github.com/ovh/manager/compare/@ovh-ux/manager-hub-app@0.29.6...@ovh-ux/manager-hub-app@0.29.7) (2026-02-16) diff --git a/packages/manager/apps/hub/package.json b/packages/manager/apps/hub/package.json index 452597caed57..eaa2ee427221 100644 --- a/packages/manager/apps/hub/package.json +++ b/packages/manager/apps/hub/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-hub-app", - "version": "0.29.7", + "version": "0.29.8", "private": true, "description": "OVHcloud Dashboard control panel.", "repository": { @@ -24,7 +24,7 @@ "@ovh-ux/manager-core-api": "^0.21.2", "@ovh-ux/manager-models": "^2.7.11", "@ovh-ux/manager-react-components": "^1.48.0", - "@ovh-ux/manager-react-core-application": "^0.15.4", + "@ovh-ux/manager-react-core-application": "^0.15.5", "@ovh-ux/manager-react-shell-client": "^0.11.2", "@ovh-ux/request-tagger": "^0.6.0", "@ovhcloud/ods-common-core": "17.2.1", diff --git a/packages/manager/apps/hycu/CHANGELOG.md b/packages/manager/apps/hycu/CHANGELOG.md index 972454d5113d..28818edb8111 100644 --- a/packages/manager/apps/hycu/CHANGELOG.md +++ b/packages/manager/apps/hycu/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.15.39](https://github.com/ovh/manager/compare/@ovh-ux/manager-hycu-app@0.15.38...@ovh-ux/manager-hycu-app@0.15.39) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-hycu-app + + + + + ## [0.15.38](https://github.com/ovh/manager/compare/@ovh-ux/manager-hycu-app@0.15.37...@ovh-ux/manager-hycu-app@0.15.38) (2026-02-05) **Note:** Version bump only for package @ovh-ux/manager-hycu-app diff --git a/packages/manager/apps/hycu/package.json b/packages/manager/apps/hycu/package.json index 1f61c52ea3a1..41954f37e3c9 100644 --- a/packages/manager/apps/hycu/package.json +++ b/packages/manager/apps/hycu/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-hycu-app", - "version": "0.15.38", + "version": "0.15.39", "private": true, "description": "Backup HYCU for OVHcloud", "repository": { @@ -26,7 +26,7 @@ "@ovh-ux/manager-module-common-api": "^0.6.7", "@ovh-ux/manager-module-order": "^0.14.1", "@ovh-ux/manager-react-components": "1.48.0", - "@ovh-ux/manager-react-core-application": "^0.15.4", + "@ovh-ux/manager-react-core-application": "^0.15.5", "@ovh-ux/manager-react-shell-client": "^0.11.2", "@ovh-ux/manager-tailwind-config": "^0.6.2", "@ovh-ux/request-tagger": "^0.6.0", diff --git a/packages/manager/apps/iam/CHANGELOG.md b/packages/manager/apps/iam/CHANGELOG.md index a3ac9a074f89..cc32f5b5380f 100644 --- a/packages/manager/apps/iam/CHANGELOG.md +++ b/packages/manager/apps/iam/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [3.2.23](https://github.com/ovh/manager/compare/@ovh-ux/manager-iam-app@3.2.22...@ovh-ux/manager-iam-app@3.2.23) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-iam-app + + + + + ## [3.2.22](https://github.com/ovh/manager/compare/@ovh-ux/manager-iam-app@3.2.21...@ovh-ux/manager-iam-app@3.2.22) (2026-03-03) **Note:** Version bump only for package @ovh-ux/manager-iam-app diff --git a/packages/manager/apps/iam/package.json b/packages/manager/apps/iam/package.json index 3179ff293ec4..e10a9f6029cf 100644 --- a/packages/manager/apps/iam/package.json +++ b/packages/manager/apps/iam/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-iam-app", - "version": "3.2.22", + "version": "3.2.23", "private": true, "description": "IAM standalone application.", "repository": { @@ -27,7 +27,7 @@ "@ovh-ux/manager-iam": "^1.27.0", "@ovh-ux/manager-log-to-customer": "^2.8.2", "@ovh-ux/manager-ng-layout-helpers": "^2.13.1", - "@ovh-ux/ng-at-internet": "^6.0.45", + "@ovh-ux/ng-at-internet": "^6.0.46", "@ovh-ux/ng-at-internet-ui-router-plugin": "^3.5.6", "@ovh-ux/ng-log-live-tail": "^2.7.2", "@ovh-ux/ng-ovh-api-wrappers": "^5.1.0", @@ -38,11 +38,11 @@ "@ovh-ux/ng-ovh-swimming-poll": "^5.1.7", "@ovh-ux/ng-ovh-utils": "^14.5.5", "@ovh-ux/ng-pagination-front": "^10.3.7", - "@ovh-ux/ng-shell-tracking": "^0.7.39", + "@ovh-ux/ng-shell-tracking": "^0.7.40", "@ovh-ux/ng-ui-router-breadcrumb": "^1.4.3", "@ovh-ux/ng-ui-router-layout": "^4.3.7", "@ovh-ux/request-tagger": "^0.6.0", - "@ovh-ux/shell": "^4.10.6", + "@ovh-ux/shell": "^4.10.7", "@ovh-ux/ui-kit": "^6.10.5", "@uirouter/angularjs": "^1.0.23", "angular": "^1.7.5", diff --git a/packages/manager/apps/identity-access-management/CHANGELOG.md b/packages/manager/apps/identity-access-management/CHANGELOG.md index c560edd208c6..4fc9146042c5 100644 --- a/packages/manager/apps/identity-access-management/CHANGELOG.md +++ b/packages/manager/apps/identity-access-management/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.8.2](https://github.com/ovh/manager/compare/@ovh-ux/manager-identity-access-management-app@0.8.1...@ovh-ux/manager-identity-access-management-app@0.8.2) (2026-03-09) + + +### Bug Fixes + +* **iam:** add translation for ServiceAccount delete modal ([abe1a6b](https://github.com/ovh/manager/commit/abe1a6bd7ff0278b2b6ef115f9e815a7766b7553)), closes [#MAIAM-81](https://github.com/ovh/manager/issues/MAIAM-81) +* **iam:** fix links for ServiceAccounts tabs ([c9ed269](https://github.com/ovh/manager/commit/c9ed26906004fd9c4b95501345569b5370c91b42)), closes [#MAIAM-81](https://github.com/ovh/manager/issues/MAIAM-81) + + + + + +## [0.8.1](https://github.com/ovh/manager/compare/@ovh-ux/manager-identity-access-management-app@0.8.0...@ovh-ux/manager-identity-access-management-app@0.8.1) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-identity-access-management-app + + + + + # [0.8.0](https://github.com/ovh/manager/compare/@ovh-ux/manager-identity-access-management-app@0.7.4...@ovh-ux/manager-identity-access-management-app@0.8.0) (2026-03-03) diff --git a/packages/manager/apps/identity-access-management/package.json b/packages/manager/apps/identity-access-management/package.json index c32f88393a66..21e2b278f815 100644 --- a/packages/manager/apps/identity-access-management/package.json +++ b/packages/manager/apps/identity-access-management/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-identity-access-management-app", - "version": "0.8.0", + "version": "0.8.2", "private": true, "description": "manage iam policies users tags etc", "repository": { @@ -24,7 +24,7 @@ "@ovh-ux/manager-core-api": "^0.21.2", "@ovh-ux/manager-core-utils": "^0.5.0", "@ovh-ux/manager-react-components": "^2.43.1", - "@ovh-ux/manager-react-core-application": "^0.15.4", + "@ovh-ux/manager-react-core-application": "^0.15.5", "@ovh-ux/manager-react-shell-client": "^0.11.2", "@ovh-ux/request-tagger": "^0.6.0", "@ovhcloud/ods-components": "^18.3.0", diff --git a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_de_DE.json b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_de_DE.json index 83a5ce186ebe..7883e3d5190f 100644 --- a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_de_DE.json +++ b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_de_DE.json @@ -31,5 +31,8 @@ "iam_user_roles_ADMIN": "Administrator", "iam_user_roles_REGULAR": "Eingeschränkter Administrator", "iam_user_roles_UNPRIVILEGED": "Nur Lesen", - "iam_user_roles_NONE": "Keine" + "iam_user_roles_NONE": "Keine", + "iam_service_accounts_modal_title_delete": "Das Dienstkonto {{serviceName}} löschen", + "iam_service_accounts_modal_message_delete": "Sind Sie sicher, dass Sie das Dienstkonto {{serviceName}} löschen möchten?", + "iam_service_accounts_datagrid_column_associated_policies": "Verwandte Richtlinien" } diff --git a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_en_GB.json b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_en_GB.json index 77bef258cdad..a5cb2bdcd4d0 100644 --- a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_en_GB.json +++ b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_en_GB.json @@ -31,5 +31,8 @@ "iam_user_roles_ADMIN": "Administrator", "iam_user_roles_REGULAR": "Restricted administrator", "iam_user_roles_UNPRIVILEGED": "Read-only", - "iam_user_roles_NONE": "None" + "iam_user_roles_NONE": "None", + "iam_service_accounts_modal_title_delete": "Delete the service account {{serviceName}}", + "iam_service_accounts_modal_message_delete": "Are you sure you want to delete the service account {{serviceName}}?", + "iam_service_accounts_datagrid_column_associated_policies": "Associated policies" } diff --git a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_es_ES.json b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_es_ES.json new file mode 100644 index 000000000000..899f724cbaf9 --- /dev/null +++ b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_es_ES.json @@ -0,0 +1,38 @@ +{ + "iam_identities": "Identidades", + "iam_identities_users": "Usuarios locales", + "iam_identities_user_groups": "Grupos de usuarios", + "iam_identities_service_accounts": "Cuenta de servicio", + "iam_identities_sso": "SSO", + "iam_service_accounts_add_account": "Añadir una cuenta de servicio", + "iam_service_accounts_edit_account": "Modificar la cuenta de servicio", + "iam_service_accounts_fill_informations": "Introducir la información", + "iam_service_accounts_service_account_name": "Nombre de la cuenta de servicio", + "iam_service_accounts_service_password": "Contraseña", + "iam_service_accounts_warning_content": "Ya no tendrás acceso al contenido del token. Asegúrate de guardarlo de manera segura.", + "iam_service_accounts_callback_url": "URL de callback", + "iam_service_accounts_policies": "Políticas", + "iam_service_accounts_help_info": "Ayuda", + "iam_service_accounts_optional_step": "Este paso es opcional y puede realizarse más adelante.", + "iam_service_accounts_no_policies_title": "No hay ninguna política disponible en tu cuenta", + "iam_service_accounts_no_policies_description": "Aún no has creado ninguna política en tu cuenta. Para adjuntar una política a esta cuenta de servicio, primero debes crear una.", + "iam_service_accounts_create_policy": "Crear una política", + "iam_service_accounts_search": "Búsqueda", + "iam_service_accounts_service_account_created": "Cuenta de servicio creada", + "iam_service_accounts_your_service_account_created": "Tu cuenta de servicio ha sido creada correctamente.", + "iam_service_accounts_add_success_title": "Cuenta de servicio creada", + "iam_service_accounts_add_success_content": "Su cuenta de servicio se ha creado con éxito. Puede encontrarlo en la tabla de abajo.", + "iam_service_accounts_add_error": "¡Ups! No conseguimos añadir la cuenta de servicio.", + "iam_service_accounts_edit_success": "La cuenta de servicio se ha modificado correctamente.", + "iam_service_accounts_edit_error": "¡Ups! No conseguimos modificar la cuenta de servicio.", + "iam_service_accounts_modal_title_delete": "Eliminar la cuenta de servicio {{serviceName}}", + "iam_service_accounts_modal_message_delete": "¿Estás seguro de que quieres eliminar la cuenta de servicio {{serviceName}} ?", + "iam_service_accounts_delete_success": "La cuenta de servicio se ha eliminado correctamente.", + "iam_service_accounts_delete_error": "¡Ups! No conseguimos eliminar la cuenta de servicio.", + "iam_user_users_role": "Privilegio", + "iam_user_roles_ADMIN": "Administrador", + "iam_user_roles_REGULAR": "Administrador restringido", + "iam_user_roles_UNPRIVILEGED": "Solo lectura", + "iam_user_roles_NONE": "Ninguno", + "iam_service_accounts_datagrid_column_associated_policies": "Políticas asociadas" +} diff --git a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_fr_CA.json b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_fr_CA.json index 74c172af6575..c0a3c32d44ac 100644 --- a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_fr_CA.json +++ b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_fr_CA.json @@ -13,6 +13,7 @@ "iam_service_accounts_callback_url": "URL de callback", "iam_service_accounts_policies": "Politiques", "iam_service_accounts_help_info": "Aide", + "iam_service_accounts_datagrid_column_associated_policies": "Politiques associées", "iam_service_accounts_optional_step": "Cette étape est facultative et peut être effectuée ultérieurement.", "iam_service_accounts_no_policies_title": "Aucune politique disponible dans votre compte", "iam_service_accounts_no_policies_description": "Vous n'avez encore créé aucune politique dans votre compte. Pour attacher une politique à ce compte de service, vous devez d'abord en créer une.", @@ -25,6 +26,8 @@ "iam_service_accounts_add_error": "Oups! Nous n'arrivons pas à ajouter le compte de service.", "iam_service_accounts_edit_success": "Le compte de service a bien été modifié.", "iam_service_accounts_edit_error": "Oups! Nous n'arrivons pas à modifier le compte de service.", + "iam_service_accounts_modal_title_delete": "Supprimer le compte de service {{serviceName}}", + "iam_service_accounts_modal_message_delete": "Êtes-vous sûr de vouloir supprimer le compte de service {{serviceName}} ?", "iam_service_accounts_delete_success": "Le compte de service a bien été supprimé.", "iam_service_accounts_delete_error": "Oups! Nous n'arrivons pas à supprimer le compte de service.", "iam_user_users_role": "Privilège", diff --git a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_fr_FR.json b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_fr_FR.json index 74c172af6575..c0a3c32d44ac 100644 --- a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_fr_FR.json +++ b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_fr_FR.json @@ -13,6 +13,7 @@ "iam_service_accounts_callback_url": "URL de callback", "iam_service_accounts_policies": "Politiques", "iam_service_accounts_help_info": "Aide", + "iam_service_accounts_datagrid_column_associated_policies": "Politiques associées", "iam_service_accounts_optional_step": "Cette étape est facultative et peut être effectuée ultérieurement.", "iam_service_accounts_no_policies_title": "Aucune politique disponible dans votre compte", "iam_service_accounts_no_policies_description": "Vous n'avez encore créé aucune politique dans votre compte. Pour attacher une politique à ce compte de service, vous devez d'abord en créer une.", @@ -25,6 +26,8 @@ "iam_service_accounts_add_error": "Oups! Nous n'arrivons pas à ajouter le compte de service.", "iam_service_accounts_edit_success": "Le compte de service a bien été modifié.", "iam_service_accounts_edit_error": "Oups! Nous n'arrivons pas à modifier le compte de service.", + "iam_service_accounts_modal_title_delete": "Supprimer le compte de service {{serviceName}}", + "iam_service_accounts_modal_message_delete": "Êtes-vous sûr de vouloir supprimer le compte de service {{serviceName}} ?", "iam_service_accounts_delete_success": "Le compte de service a bien été supprimé.", "iam_service_accounts_delete_error": "Oups! Nous n'arrivons pas à supprimer le compte de service.", "iam_user_users_role": "Privilège", diff --git a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_it_IT.json b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_it_IT.json index 6d881fe4b13c..26f34b4f6ce5 100644 --- a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_it_IT.json +++ b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_it_IT.json @@ -31,5 +31,8 @@ "iam_user_roles_ADMIN": "Amministratore", "iam_user_roles_REGULAR": "Amministratore con restrizioni", "iam_user_roles_UNPRIVILEGED": "Sola lettura", - "iam_user_roles_NONE": "Nessuna" + "iam_user_roles_NONE": "Nessuna", + "iam_service_accounts_modal_title_delete": "Elimina l'account di servizio {{serviceName}}", + "iam_service_accounts_modal_message_delete": "Sei sicuro di voler eliminare l'account di servizio {{serviceName}} ?", + "iam_service_accounts_datagrid_column_associated_policies": "Politiche associate" } diff --git a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_pl_PL.json b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_pl_PL.json new file mode 100644 index 000000000000..cda3fd3f5c13 --- /dev/null +++ b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_pl_PL.json @@ -0,0 +1,38 @@ +{ + "iam_identities": "Tożsamości", + "iam_identities_users": "Użytkownicy lokalni", + "iam_identities_user_groups": "Grupy użytkowników", + "iam_identities_service_accounts": "Konto usługi", + "iam_identities_sso": "SSO", + "iam_service_accounts_add_account": "Dodaj konto usługi", + "iam_service_accounts_edit_account": "Edytuj konto usługi", + "iam_service_accounts_fill_informations": "Wprowadź informacje", + "iam_service_accounts_service_account_name": "Nazwa konta usługi", + "iam_service_accounts_service_password": "Hasło", + "iam_service_accounts_warning_content": "Nie będziesz miał już dostępu do treści tokena. Upewnij się, że go bezpiecznie zapiszesz.", + "iam_service_accounts_callback_url": "URL zwrotny", + "iam_service_accounts_policies": "Polityki", + "iam_service_accounts_help_info": "Pomoc", + "iam_service_accounts_optional_step": "Ten krok jest opcjonalny i można go wykonać później.", + "iam_service_accounts_no_policies_title": "Brak polityki dostępnej w Twoim koncie", + "iam_service_accounts_no_policies_description": "Nie utworzyłeś jeszcze żadnej polityki w swoim koncie. Aby przypisać politykę do tego konta usługi, musisz najpierw utworzyć jedną.", + "iam_service_accounts_create_policy": "Utwórz politykę", + "iam_service_accounts_search": "Wyszukiwanie", + "iam_service_accounts_service_account_created": "Konto usługi utworzone", + "iam_service_accounts_your_service_account_created": "Twoje konto usługi zostało pomyślnie utworzone.", + "iam_service_accounts_add_success_title": "Konto usługi utworzone", + "iam_service_accounts_add_success_content": "Twoje konto serwisowe zostało pomyślnie utworzone. Możesz je znaleźć w tabeli poniżej.", + "iam_service_accounts_add_error": "Ups! Nie możemy dodać konta serwisowego.", + "iam_service_accounts_edit_success": "Konto serwisowe zostało pomyślnie zmodyfikowane.", + "iam_service_accounts_edit_error": "Ups! Nie możemy zmodyfikować konta serwisowego.", + "iam_service_accounts_modal_title_delete": "Usuń konto usługi {{serviceName}}", + "iam_service_accounts_modal_message_delete": "Czy na pewno chcesz usunąć konto usługi {{serviceName}} ?", + "iam_service_accounts_delete_success": "Konto serwisowe zostało pomyślnie usunięte.", + "iam_service_accounts_delete_error": "Ups! Nie możemy usunąć konta serwisowego.", + "iam_user_users_role": "Uprawnienie", + "iam_user_roles_ADMIN": "Administrator", + "iam_user_roles_REGULAR": "Administrator z ograniczeniami", + "iam_user_roles_UNPRIVILEGED": "Tylko do odczytu", + "iam_user_roles_NONE": "Brak", + "iam_service_accounts_datagrid_column_associated_policies": "Powiązane polityki" +} diff --git a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_pt_PT.json b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_pt_PT.json index 879c4048b6a4..78d43f7a034d 100644 --- a/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_pt_PT.json +++ b/packages/manager/apps/identity-access-management/public/translations/service-accounts/Messages_pt_PT.json @@ -31,5 +31,8 @@ "iam_user_roles_ADMIN": "Administrador", "iam_user_roles_REGULAR": "Administrador restrito", "iam_user_roles_UNPRIVILEGED": "Apenas leitura", - "iam_user_roles_NONE": "Nenhum/a" + "iam_user_roles_NONE": "Nenhum/a", + "iam_service_accounts_modal_title_delete": "Eliminar a conta de serviço {{serviceName}}", + "iam_service_accounts_modal_message_delete": "Tem a certeza de que deseja eliminar a conta de serviço {{serviceName}} ?", + "iam_service_accounts_datagrid_column_associated_policies": "Políticas associadas" } diff --git a/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/components/ServiceAccountsTabs.component.tsx b/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/components/ServiceAccountsTabs.component.tsx index a90bc1df2d53..e2485035fe57 100644 --- a/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/components/ServiceAccountsTabs.component.tsx +++ b/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/components/ServiceAccountsTabs.component.tsx @@ -13,12 +13,12 @@ export function ServiceAccountsTabs() { return ( - + {t('iam_identities_users')} - + {t('iam_identities_user_groups')} @@ -28,7 +28,7 @@ export function ServiceAccountsTabs() { - + {t('iam_identities_sso')} diff --git a/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/delete/ServiceAccountsDelete.page.tsx b/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/delete/ServiceAccountsDelete.page.tsx index e875c2461d15..ab6e670598ea 100644 --- a/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/delete/ServiceAccountsDelete.page.tsx +++ b/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/delete/ServiceAccountsDelete.page.tsx @@ -10,7 +10,7 @@ import { SERVICE_ACCOUNTS_TRACKING } from '@/tracking.constant'; import { useDeleteIamServiceAccount } from '@/data/hooks/useGetIamServiceAccounts'; export default function ServiceAccountsDelete() { - const { t } = useTranslation(['service-accounts', 'permanent-tokens', NAMESPACES.ACTIONS]); + const { t } = useTranslation(['service-accounts', NAMESPACES.ACTIONS]); const { trackClick, trackPage } = useOvhTracking(); const { addSuccess, addError } = useNotifications(); const navigate = useNavigate(); @@ -56,7 +56,9 @@ export default function ServiceAccountsDelete() { return ( diff --git a/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/listing/useDatagridColumn.tsx b/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/listing/useDatagridColumn.tsx index fca93f096dd9..92c9d415e1fc 100644 --- a/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/listing/useDatagridColumn.tsx +++ b/packages/manager/apps/identity-access-management/src/pages/serviceAccounts/listing/useDatagridColumn.tsx @@ -12,7 +12,7 @@ import { Actions } from './Actions.component'; import { ServiceAccountsPolicyCount } from '@/pages/serviceAccounts/components/ServiceAccountsPolicyCount.component'; export function useDatagridColumn(): DatagridColumn[] { - const { t } = useTranslation('permanent-tokens'); + const { t } = useTranslation(['permanent-tokens', 'service-accounts']); const formatDate = useFormatDate(); return [ @@ -40,7 +40,7 @@ export function useDatagridColumn(): DatagridColumn[] { }, { id: 'policy-count', - label: 'Politiques associées', + label: t('iam_service_accounts_datagrid_column_associated_policies', { ns: 'service-accounts' }), cell: (account: IamServiceAccount) => ( ), diff --git a/packages/manager/apps/iplb/CHANGELOG.md b/packages/manager/apps/iplb/CHANGELOG.md index d86be762849f..5aba6a597ca9 100644 --- a/packages/manager/apps/iplb/CHANGELOG.md +++ b/packages/manager/apps/iplb/CHANGELOG.md @@ -3,6 +3,30 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [3.2.51](https://github.com/ovh/manager/compare/@ovh-ux/manager-iplb-app@3.2.50...@ovh-ux/manager-iplb-app@3.2.51) (2026-03-09) + +**Note:** Version bump only for package @ovh-ux/manager-iplb-app + + + + + +## [3.2.50](https://github.com/ovh/manager/compare/@ovh-ux/manager-iplb-app@3.2.49...@ovh-ux/manager-iplb-app@3.2.50) (2026-03-05) + +**Note:** Version bump only for package @ovh-ux/manager-iplb-app + + + + + +## [3.2.49](https://github.com/ovh/manager/compare/@ovh-ux/manager-iplb-app@3.2.48...@ovh-ux/manager-iplb-app@3.2.49) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-iplb-app + + + + + ## [3.2.48](https://github.com/ovh/manager/compare/@ovh-ux/manager-iplb-app@3.2.47...@ovh-ux/manager-iplb-app@3.2.48) (2026-02-26) **Note:** Version bump only for package @ovh-ux/manager-iplb-app diff --git a/packages/manager/apps/iplb/package.json b/packages/manager/apps/iplb/package.json index 4739e2eef8bb..af1667071954 100644 --- a/packages/manager/apps/iplb/package.json +++ b/packages/manager/apps/iplb/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-iplb-app", - "version": "3.2.48", + "version": "3.2.51", "private": true, "description": "IP Load Balancer standalone application.", "repository": { @@ -17,14 +17,14 @@ "start": "webpack-dev-server" }, "dependencies": { - "@ovh-ux/manager-components": "^1.28.0", + "@ovh-ux/manager-components": "^1.29.0", "@ovh-ux/manager-config": "^8.9.0", "@ovh-ux/manager-core": "^13.4.4", "@ovh-ux/manager-filters": "^1.2.2", - "@ovh-ux/manager-iplb": "^1.30.2", + "@ovh-ux/manager-iplb": "^1.31.0", "@ovh-ux/manager-log-to-customer": "^2.8.2", "@ovh-ux/manager-ng-layout-helpers": "^2.13.1", - "@ovh-ux/ng-at-internet": "^6.0.45", + "@ovh-ux/ng-at-internet": "^6.0.46", "@ovh-ux/ng-log-live-tail": "^2.7.2", "@ovh-ux/ng-ovh-actions-menu": "^5.1.7", "@ovh-ux/ng-ovh-api-wrappers": "^5.1.0", diff --git a/packages/manager/apps/ips/CHANGELOG.md b/packages/manager/apps/ips/CHANGELOG.md index 25c11837d8bd..82c6884f3def 100644 --- a/packages/manager/apps/ips/CHANGELOG.md +++ b/packages/manager/apps/ips/CHANGELOG.md @@ -3,6 +3,49 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.7.0](https://github.com/ovh/manager/compare/@ovh-ux/manager-ips-app@0.6.0...@ovh-ux/manager-ips-app@0.7.0) (2026-03-11) + + +### Bug Fixes + +* **ips:** fix copilot feedbacks ([5bc3db5](https://github.com/ovh/manager/commit/5bc3db54a0d66172dc129cf0023c62f95902358d)), closes [#MANAGER-19522](https://github.com/ovh/manager/issues/MANAGER-19522) +* **ips:** fix order feedbacks ([db45f10](https://github.com/ovh/manager/commit/db45f10db4bd24a64f05bf6729f17408fec8bee7)), closes [#MANAGER-19717](https://github.com/ovh/manager/issues/MANAGER-19717) +* **ips:** updating the description label displayed when selecting a bandwidth in the order page ([c4f49f8](https://github.com/ovh/manager/commit/c4f49f82ee5564bcc09c632f77ad2f8716a5b311)), closes [#MANAGER-21050](https://github.com/ovh/manager/issues/MANAGER-21050) + + +### Features + +* **ips:** add bandwidth order ([fc5cd27](https://github.com/ovh/manager/commit/fc5cd27bcd224ba42f1121a50cb5149b6cbde880)), closes [#MANAGER-19717](https://github.com/ovh/manager/issues/MANAGER-19717) + + + + + +# [0.6.0](https://github.com/ovh/manager/compare/@ovh-ux/manager-ips-app@0.5.2...@ovh-ux/manager-ips-app@0.6.0) (2026-03-09) + + +### Bug Fixes + +* **ips:** removing redundant subnet info from the datagrid ([11bc5af](https://github.com/ovh/manager/commit/11bc5af378b5e1da5ddd0790fce2e6e2cb065982)), closes [#MANAGER-20845](https://github.com/ovh/manager/issues/MANAGER-20845) + + +### Features + +* **ips:** add button to add reverse DNS on ipv6 empty result ([73a1b4e](https://github.com/ovh/manager/commit/73a1b4e7e1b892fcc31dfc3198f6a2317b0bdd2a)), closes [#MANAGER-20559](https://github.com/ovh/manager/issues/MANAGER-20559) +* **ips:** add port range support ([417ce7e](https://github.com/ovh/manager/commit/417ce7e71b154e73903b6e380957004166c79e6c)), closes [#MANAGER-20168](https://github.com/ovh/manager/issues/MANAGER-20168) + + + + + +## [0.5.2](https://github.com/ovh/manager/compare/@ovh-ux/manager-ips-app@0.5.1...@ovh-ux/manager-ips-app@0.5.2) (2026-03-04) + +**Note:** Version bump only for package @ovh-ux/manager-ips-app + + + + + ## [0.5.1](https://github.com/ovh/manager/compare/@ovh-ux/manager-ips-app@0.5.0...@ovh-ux/manager-ips-app@0.5.1) (2026-02-05) **Note:** Version bump only for package @ovh-ux/manager-ips-app diff --git a/packages/manager/apps/ips/package.json b/packages/manager/apps/ips/package.json index 80c8a9b6d21f..41e7b1f60d1b 100644 --- a/packages/manager/apps/ips/package.json +++ b/packages/manager/apps/ips/package.json @@ -1,6 +1,6 @@ { "name": "@ovh-ux/manager-ips-app", - "version": "0.5.1", + "version": "0.7.0", "private": true, "description": "Ips manager app", "repository": { @@ -24,10 +24,10 @@ "@ovh-ux/manager-config": "^8.9.0", "@ovh-ux/manager-core-api": "^0.21.2", "@ovh-ux/manager-core-utils": "^0.5.0", - "@ovh-ux/manager-module-common-api": "^0.6.7", "@ovh-ux/manager-module-order": "^0.14.1", + "@ovh-ux/manager-network-common": "^0.10.0", "@ovh-ux/manager-react-components": "^2.43.1", - "@ovh-ux/manager-react-core-application": "^0.15.4", + "@ovh-ux/manager-react-core-application": "^0.15.5", "@ovh-ux/manager-react-shell-client": "^0.11.2", "@ovh-ux/muk": "^0.10.1", "@ovh-ux/request-tagger": "^0.4.1", diff --git a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_de_DE.json b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_de_DE.json index ff17f0cc8433..381c6393ae44 100644 --- a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_de_DE.json +++ b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_de_DE.json @@ -25,8 +25,8 @@ "modeColumnLabel": "Modus", "protocolColumnLabel": "Protokoll", "sourceColumnLabel": "Quell-IP-Adresse", - "sourcePortColumnLabel": "Quell-Port", - "destinationPortColumnLabel": "Ziel-Port", + "sourcePortColumnLabel": "Hafenquelle (Bereich)", + "destinationPortColumnLabel": "Zielhafen (Bereich)", "tcpOptionsColumnLabel": "TCP-Status", "permit_action": "Erlauben", "deny_action": "Verbieten", @@ -40,5 +40,6 @@ "firewall_not_created_tooltip": "Die Firewall kann erst aktiviert werden, wenn eine erste Regel erstellt wurde.", "destinationPortLowerThanSourcePortError": "Der Zielport darf nicht kleiner als der Quellport sein.", "createRuleErrorMessage": "Die eingegebenen Daten enthalten Fehler. Bitte überprüfen Sie die rot markierten Felder.", - "requiredFieldError": "Dieses Feld muss ausgefüllt werden." + "requiredFieldError": "Dieses Feld muss ausgefüllt werden.", + "port_format_tooltip": "Das Format des Hafens kann entweder als einzelne Zahl (zum Beispiel: \"123\"), oder als Bereich von Häfen (zum Beispiel: \"123-125\")." } diff --git a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_en_GB.json b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_en_GB.json index 0c07e900e2ab..96aebb22205b 100644 --- a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_en_GB.json +++ b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_en_GB.json @@ -25,8 +25,8 @@ "modeColumnLabel": "Mode", "protocolColumnLabel": "Protocol", "sourceColumnLabel": "Source IP", - "sourcePortColumnLabel": "Source port", - "destinationPortColumnLabel": "Destination port", + "sourcePortColumnLabel": "Source port (range)", + "destinationPortColumnLabel": "Destination port (range)", "tcpOptionsColumnLabel": "TCP status", "permit_action": "Authorise", "deny_action": "Refuse", @@ -40,5 +40,6 @@ "firewall_not_created_tooltip": "The firewall cannot be enabled until a first rule has been created.", "destinationPortLowerThanSourcePortError": "The destination port must be greater than or equal to the source port", "createRuleErrorMessage": "There are errors in the data entered, please check the fields in red.", - "requiredFieldError": "Required field" + "requiredFieldError": "Required field", + "port_format_tooltip": "The port format can be provided either as a single number (for example: \"123\"), or as a range of ports (for example: \"123-125\")." } diff --git a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_es_ES.json b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_es_ES.json index 4f709634a952..2f21b187454d 100644 --- a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_es_ES.json +++ b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_es_ES.json @@ -25,8 +25,8 @@ "modeColumnLabel": "Modo", "protocolColumnLabel": "Protocolo", "sourceColumnLabel": "Dirección IP de origen", - "sourcePortColumnLabel": "Puerto de origen", - "destinationPortColumnLabel": "Puerto de destino", + "sourcePortColumnLabel": "Puerto de origen (rango)", + "destinationPortColumnLabel": "Puerto de destino (rango)", "tcpOptionsColumnLabel": "Estado TCP", "permit_action": "Autorizar", "deny_action": "Denegar", @@ -40,5 +40,6 @@ "firewall_not_created_tooltip": "El firewall no puede activarse hasta que se cree una primera regla.", "destinationPortLowerThanSourcePortError": "El puerto de destino no puede ser menor que el puerto de origen", "createRuleErrorMessage": "Hay errores en los datos introducidos. Por favor, compruebe los campos marcados en rojo.", - "requiredFieldError": "Este campo es obligatorio." + "requiredFieldError": "Este campo es obligatorio.", + "port_format_tooltip": "El formato del puerto puede ser proporcionado ya sea en forma de un solo número (por ejemplo: \"123\"), o en forma de un rango de puertos (por ejemplo: \"123-125\")." } diff --git a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_fr_CA.json b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_fr_CA.json index d86d27e63ed1..ca33f21df482 100644 --- a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_fr_CA.json +++ b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_fr_CA.json @@ -25,8 +25,8 @@ "modeColumnLabel": "Mode", "protocolColumnLabel": "Protocole", "sourceColumnLabel": "Adresse IP source", - "sourcePortColumnLabel": "Port source", - "destinationPortColumnLabel": "Port de destination", + "sourcePortColumnLabel": "Port source (plage)", + "destinationPortColumnLabel": "Port de destination (plage)", "tcpOptionsColumnLabel": "État TCP", "permit_action": "Autoriser", "deny_action": "Refuser", @@ -40,5 +40,6 @@ "firewall_not_created_tooltip": "Le firewall ne peut pas être activé tant qu'une 1ère règle n'a pas été créée.", "destinationPortLowerThanSourcePortError": "Le port de destination ne peut pas être inférieur au port source", "createRuleErrorMessage": "Il y a des erreurs dans les données saisies, veuillez vérifier les champs signalés en rouge.", - "requiredFieldError": "Ce champ est requis" + "requiredFieldError": "Ce champ est requis", + "port_format_tooltip": "Le format du port peut être fourni soit sous forme d'un seul chiffre (par exemple : \"123\"), soit sous forme d'une plage de ports (par exemple : \"123-125\")." } diff --git a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_fr_FR.json index d86d27e63ed1..ca33f21df482 100644 --- a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_fr_FR.json +++ b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_fr_FR.json @@ -25,8 +25,8 @@ "modeColumnLabel": "Mode", "protocolColumnLabel": "Protocole", "sourceColumnLabel": "Adresse IP source", - "sourcePortColumnLabel": "Port source", - "destinationPortColumnLabel": "Port de destination", + "sourcePortColumnLabel": "Port source (plage)", + "destinationPortColumnLabel": "Port de destination (plage)", "tcpOptionsColumnLabel": "État TCP", "permit_action": "Autoriser", "deny_action": "Refuser", @@ -40,5 +40,6 @@ "firewall_not_created_tooltip": "Le firewall ne peut pas être activé tant qu'une 1ère règle n'a pas été créée.", "destinationPortLowerThanSourcePortError": "Le port de destination ne peut pas être inférieur au port source", "createRuleErrorMessage": "Il y a des erreurs dans les données saisies, veuillez vérifier les champs signalés en rouge.", - "requiredFieldError": "Ce champ est requis" + "requiredFieldError": "Ce champ est requis", + "port_format_tooltip": "Le format du port peut être fourni soit sous forme d'un seul chiffre (par exemple : \"123\"), soit sous forme d'une plage de ports (par exemple : \"123-125\")." } diff --git a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_it_IT.json b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_it_IT.json index 150a6a80eb29..9388cbab37d3 100644 --- a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_it_IT.json +++ b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_it_IT.json @@ -25,8 +25,8 @@ "modeColumnLabel": "Modo", "protocolColumnLabel": "Protocollo", "sourceColumnLabel": "Indirizzo IP sorgente", - "sourcePortColumnLabel": "Porta sorgente", - "destinationPortColumnLabel": "Porta di destinazione", + "sourcePortColumnLabel": "Porto di partenza (intervallo)", + "destinationPortColumnLabel": "Porto di destinazione (intervallo)", "tcpOptionsColumnLabel": "Stato TCP", "permit_action": "Autorizza", "deny_action": "Rifiuta", @@ -40,5 +40,6 @@ "firewall_not_created_tooltip": "Il firewall non può essere attivato fino a quando non viene creata una prima regola.", "destinationPortLowerThanSourcePortError": "La porta di destinazione non può essere inferiore alla porta sorgente", "createRuleErrorMessage": "Sono presenti errori nei dati inseriti, controlla i campi evidenziati in rosso.", - "requiredFieldError": "Questo campo è obbligatorio" + "requiredFieldError": "Questo campo è obbligatorio", + "port_format_tooltip": "Il formato del porto può essere fornito sia sotto forma di un solo numero (ad esempio: \"123\"), sia sotto forma di un intervallo di porti (ad esempio: \"123-125\")." } diff --git a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_pl_PL.json b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_pl_PL.json index 57a122e502d7..7e8c14b6e2ed 100644 --- a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_pl_PL.json +++ b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_pl_PL.json @@ -25,8 +25,8 @@ "modeColumnLabel": "Tryb", "protocolColumnLabel": "Protokół", "sourceColumnLabel": "Źródłowy adres IP", - "sourcePortColumnLabel": "Port źródłowy", - "destinationPortColumnLabel": "Port docelowy", + "sourcePortColumnLabel": "Port źródłowy (zakres)", + "destinationPortColumnLabel": "Port docelowy (zakres)", "tcpOptionsColumnLabel": "Stan TCP", "permit_action": "Zezwalaj", "deny_action": "Odrzucaj", @@ -40,5 +40,6 @@ "firewall_not_created_tooltip": "Nie można włączyć firewalla, dopóki nie zostanie utworzona pierwsza reguła.", "destinationPortLowerThanSourcePortError": "Docelowy port nie może mieć numeru mniejszego niż port źródłowy", "createRuleErrorMessage": "Wprowadzone dane zawierają błędy. Sprawdź pola zaznaczone na czerwono.", - "requiredFieldError": "Uzupełnienie tego pola jest obowiązkowe" + "requiredFieldError": "Uzupełnienie tego pola jest obowiązkowe", + "port_format_tooltip": "Format portu może być podany jako pojedyncza liczba (na przykład: \"123\"), lub jako zakres portów (na przykład: \"123-125\")." } diff --git a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_pt_PT.json b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_pt_PT.json index b9a98696cdbe..1d1b2c3a98e5 100644 --- a/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_pt_PT.json +++ b/packages/manager/apps/ips/public/translations/edge-network-firewall/Messages_pt_PT.json @@ -25,8 +25,8 @@ "modeColumnLabel": "Modo", "protocolColumnLabel": "Protocolo", "sourceColumnLabel": "Endereço IP de origem", - "sourcePortColumnLabel": "Porta de origem", - "destinationPortColumnLabel": "Porta de destino", + "sourcePortColumnLabel": "Porto de origem (intervalo)", + "destinationPortColumnLabel": "Porto de destino (intervalo)", "tcpOptionsColumnLabel": "Estado TCP", "permit_action": "Autorizar", "deny_action": "Recusar", @@ -40,5 +40,6 @@ "firewall_not_created_tooltip": "A firewall não pode ser ativada enquanto não se criar uma primeira regra.", "destinationPortLowerThanSourcePortError": "A porta de destino não pode ser inferior à porta de origem", "createRuleErrorMessage": "Existem erros nos dados introduzidos, queira verificar os campos assinalados a vermelho.", - "requiredFieldError": "Campo obrigatório" + "requiredFieldError": "Campo obrigatório", + "port_format_tooltip": "O formato do porto pode ser fornecido sob a forma de um único número (por exemplo: \"123\"), ou sob a forma de um intervalo de portos (por exemplo: \"123-125\")." } diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_de_DE.json b/packages/manager/apps/ips/public/translations/ips/Messages_de_DE.json index aa1f60cb9e9f..88fe8e0b3659 100644 --- a/packages/manager/apps/ips/public/translations/ips/Messages_de_DE.json +++ b/packages/manager/apps/ips/public/translations/ips/Messages_de_DE.json @@ -6,5 +6,6 @@ "task_error_message": "Task-Status: {{status}}. Kommentar: {{comment}}", "ip_task_status_cancelled": "abgebrochen", "ip_task_status_customerError": "Fehler", - "ip_task_status_ovhError": "Fehler" + "ip_task_status_ovhError": "Fehler", + "current_bandwidth": "Aktuelle Bandbreite" } diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_en_GB.json b/packages/manager/apps/ips/public/translations/ips/Messages_en_GB.json index c86b7fc44055..1872c23aa0a5 100644 --- a/packages/manager/apps/ips/public/translations/ips/Messages_en_GB.json +++ b/packages/manager/apps/ips/public/translations/ips/Messages_en_GB.json @@ -6,5 +6,6 @@ "task_error_message": "Task status: {{status}}. Comment: {{comment}}", "ip_task_status_cancelled": "cancelled", "ip_task_status_customerError": "error", - "ip_task_status_ovhError": "error" + "ip_task_status_ovhError": "error", + "current_bandwidth": "Current bandwidth" } diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_es_ES.json b/packages/manager/apps/ips/public/translations/ips/Messages_es_ES.json index 1d9d442e3f35..20823bac23e1 100644 --- a/packages/manager/apps/ips/public/translations/ips/Messages_es_ES.json +++ b/packages/manager/apps/ips/public/translations/ips/Messages_es_ES.json @@ -6,5 +6,6 @@ "task_error_message": "Estado de la tarea: {{status}}. Comentario: {{comment}}", "ip_task_status_cancelled": "cancelada", "ip_task_status_customerError": "error", - "ip_task_status_ovhError": "error" + "ip_task_status_ovhError": "error", + "current_bandwidth": "Ancho de banda actual" } diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_fr_CA.json b/packages/manager/apps/ips/public/translations/ips/Messages_fr_CA.json index 2574cfe7a076..a979c69ca29d 100644 --- a/packages/manager/apps/ips/public/translations/ips/Messages_fr_CA.json +++ b/packages/manager/apps/ips/public/translations/ips/Messages_fr_CA.json @@ -3,6 +3,7 @@ "order": "Commander une IP", "byoip": "BYOIP", "free_price": "Gratuit", + "current_bandwidth": "Bande passante actuelle", "task_error_message": "Statut de la tâche: {{status}}. Commentaire: {{comment}}", "ip_task_status_cancelled": "annulée", "ip_task_status_customerError": "erreur", diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/ips/Messages_fr_FR.json index 2574cfe7a076..a979c69ca29d 100644 --- a/packages/manager/apps/ips/public/translations/ips/Messages_fr_FR.json +++ b/packages/manager/apps/ips/public/translations/ips/Messages_fr_FR.json @@ -3,6 +3,7 @@ "order": "Commander une IP", "byoip": "BYOIP", "free_price": "Gratuit", + "current_bandwidth": "Bande passante actuelle", "task_error_message": "Statut de la tâche: {{status}}. Commentaire: {{comment}}", "ip_task_status_cancelled": "annulée", "ip_task_status_customerError": "erreur", diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_it_IT.json b/packages/manager/apps/ips/public/translations/ips/Messages_it_IT.json index a0eac5ba6884..5b0c830aa61e 100644 --- a/packages/manager/apps/ips/public/translations/ips/Messages_it_IT.json +++ b/packages/manager/apps/ips/public/translations/ips/Messages_it_IT.json @@ -6,5 +6,6 @@ "task_error_message": "Stato del task: {{status}}. Commento: {{comment}}", "ip_task_status_cancelled": "annullato", "ip_task_status_customerError": "errore", - "ip_task_status_ovhError": "errore" + "ip_task_status_ovhError": "errore", + "current_bandwidth": "Larghezza di banda attuale" } diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_pl_PL.json b/packages/manager/apps/ips/public/translations/ips/Messages_pl_PL.json index 27ae63e0e803..f1ecead7d8f7 100644 --- a/packages/manager/apps/ips/public/translations/ips/Messages_pl_PL.json +++ b/packages/manager/apps/ips/public/translations/ips/Messages_pl_PL.json @@ -6,5 +6,6 @@ "task_error_message": "Status zadania: {{status}}. Komentarz: {{comment}}", "ip_task_status_cancelled": "anulowane", "ip_task_status_customerError": "błąd", - "ip_task_status_ovhError": "błąd" + "ip_task_status_ovhError": "błąd", + "current_bandwidth": "Aktualna przepustowość" } diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_pt_PT.json b/packages/manager/apps/ips/public/translations/ips/Messages_pt_PT.json index d8887c8f0678..7fd08950341b 100644 --- a/packages/manager/apps/ips/public/translations/ips/Messages_pt_PT.json +++ b/packages/manager/apps/ips/public/translations/ips/Messages_pt_PT.json @@ -6,5 +6,6 @@ "task_error_message": "Estado da tarefa: {{status}}. Comentário: {{comment}}", "ip_task_status_cancelled": "anulada", "ip_task_status_customerError": "erro", - "ip_task_status_ovhError": "erro" + "ip_task_status_ovhError": "erro", + "current_bandwidth": "Largura de banda atual" } diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_de_DE.json b/packages/manager/apps/ips/public/translations/listing/Messages_de_DE.json index 692ed8f6d90d..35878d57e499 100644 --- a/packages/manager/apps/ips/public/translations/listing/Messages_de_DE.json +++ b/packages/manager/apps/ips/public/translations/listing/Messages_de_DE.json @@ -114,5 +114,6 @@ "survey_link": "Gefällt Ihnen das?", "survey_link_tooltip": "Gefällt Ihnen diese Seite? Teilen Sie uns Ihre Anmerkungen mit!", "listingColumnsIpGameFirewallConfigured": "Konfiguriert", - "listingColumnsIpGameFirewallConfiguredTooltip": "Die Game Firewall ist konfiguriert. Klicken Sie hier, um die Konfiguration erneut vorzunehmen." + "listingColumnsIpGameFirewallConfiguredTooltip": "Die Game Firewall ist konfiguriert. Klicken Sie hier, um die Konfiguration erneut vorzunehmen.", + "listingNoResultFoundIpv6": "Keine Ergebnisse (Klicken hier. um einen Reverse-DNS zu konfigurieren, damit Ihre IP gelistet wird)" } diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_en_GB.json b/packages/manager/apps/ips/public/translations/listing/Messages_en_GB.json index 86c307a88a51..ca5bd2b28a5e 100644 --- a/packages/manager/apps/ips/public/translations/listing/Messages_en_GB.json +++ b/packages/manager/apps/ips/public/translations/listing/Messages_en_GB.json @@ -114,5 +114,6 @@ "survey_link": "Like it?", "survey_link_tooltip": "Like this page? Send us your feedback!", "listingColumnsIpGameFirewallConfigured": "Configured", - "listingColumnsIpGameFirewallConfiguredTooltip": "The Game Firewall is configured. Click to reconfigure." + "listingColumnsIpGameFirewallConfiguredTooltip": "The Game Firewall is configured. Click to reconfigure.", + "listingNoResultFoundIpv6": "No results (Click here to configure a reverse DNS so that your IP is listed)" } diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_es_ES.json b/packages/manager/apps/ips/public/translations/listing/Messages_es_ES.json index c20351df8fd4..793b0c864769 100644 --- a/packages/manager/apps/ips/public/translations/listing/Messages_es_ES.json +++ b/packages/manager/apps/ips/public/translations/listing/Messages_es_ES.json @@ -114,5 +114,6 @@ "survey_link": "¿Le gusta?", "survey_link_tooltip": "¿Le gusta esta página? ¡Envíenos sus comentarios!", "listingColumnsIpGameFirewallConfigured": "Configurada", - "listingColumnsIpGameFirewallConfiguredTooltip": "El Game Firewall está configurado correctamente. Haga clic aquí para volver a configurarlo." + "listingColumnsIpGameFirewallConfiguredTooltip": "El Game Firewall está configurado correctamente. Haga clic aquí para volver a configurarlo.", + "listingNoResultFoundIpv6": "Ningún resultado (Hacer clic aquí. para configurar un reverse DNS para que tu IP esté listada)" } diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_fr_CA.json b/packages/manager/apps/ips/public/translations/listing/Messages_fr_CA.json index 72c1b590e05d..68b72ba707b8 100644 --- a/packages/manager/apps/ips/public/translations/listing/Messages_fr_CA.json +++ b/packages/manager/apps/ips/public/translations/listing/Messages_fr_CA.json @@ -114,5 +114,6 @@ "aggregate_in_progress": "Agrégation en cours", "creation_in_progress": "Création en cours", "survey_link": "Vous aimez?", - "survey_link_tooltip": "Vous aimez cette page ? Faites nous part de vos remarques !" + "survey_link_tooltip": "Vous aimez cette page ? Faites nous part de vos remarques !", + "listingNoResultFoundIpv6": "Aucun résultat (Cliquer ici pour configurer un reverse DNS pour que votre IP soit listée)" } diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/listing/Messages_fr_FR.json index 72c1b590e05d..68b72ba707b8 100644 --- a/packages/manager/apps/ips/public/translations/listing/Messages_fr_FR.json +++ b/packages/manager/apps/ips/public/translations/listing/Messages_fr_FR.json @@ -114,5 +114,6 @@ "aggregate_in_progress": "Agrégation en cours", "creation_in_progress": "Création en cours", "survey_link": "Vous aimez?", - "survey_link_tooltip": "Vous aimez cette page ? Faites nous part de vos remarques !" + "survey_link_tooltip": "Vous aimez cette page ? Faites nous part de vos remarques !", + "listingNoResultFoundIpv6": "Aucun résultat (Cliquer ici pour configurer un reverse DNS pour que votre IP soit listée)" } diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_it_IT.json b/packages/manager/apps/ips/public/translations/listing/Messages_it_IT.json index d2cb95e4ac38..4afce3f8f6cd 100644 --- a/packages/manager/apps/ips/public/translations/listing/Messages_it_IT.json +++ b/packages/manager/apps/ips/public/translations/listing/Messages_it_IT.json @@ -114,5 +114,6 @@ "survey_link": "Ti piace?", "survey_link_tooltip": "Ti piace questa pagina? Condividi con noi i tuoi commenti!", "listingColumnsIpGameFirewallConfigured": "Configurato", - "listingColumnsIpGameFirewallConfiguredTooltip": "Il Game Firewall è configurato. Clicca per configurare di nuovo." + "listingColumnsIpGameFirewallConfiguredTooltip": "Il Game Firewall è configurato. Clicca per configurare di nuovo.", + "listingNoResultFoundIpv6": "Nessun risultato (Clicca qui. per configurare un reverse DNS affinché il tuo IP sia elencato)" } diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_pl_PL.json b/packages/manager/apps/ips/public/translations/listing/Messages_pl_PL.json index 6c55acd34ae6..31e6b8c4c014 100644 --- a/packages/manager/apps/ips/public/translations/listing/Messages_pl_PL.json +++ b/packages/manager/apps/ips/public/translations/listing/Messages_pl_PL.json @@ -114,5 +114,6 @@ "survey_link": "Jaka jest Twoja opinia?", "survey_link_tooltip": "Podoba Ci się ta strona? Podziel się z nami swoimi uwagami!", "listingColumnsIpGameFirewallConfigured": "Skonfigurowany", - "listingColumnsIpGameFirewallConfiguredTooltip": "Game Firewall został skonfigurowany. Kliknij, aby ponownie skonfigurować." + "listingColumnsIpGameFirewallConfiguredTooltip": "Game Firewall został skonfigurowany. Kliknij, aby ponownie skonfigurować.", + "listingNoResultFoundIpv6": "Brak wyników (Kliknij tutaj. aby skonfigurować reverse DNS, aby Twój adres IP był wymieniony)" } diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_pt_PT.json b/packages/manager/apps/ips/public/translations/listing/Messages_pt_PT.json index a2fdcc39c5c3..cb9b5d6f65e3 100644 --- a/packages/manager/apps/ips/public/translations/listing/Messages_pt_PT.json +++ b/packages/manager/apps/ips/public/translations/listing/Messages_pt_PT.json @@ -114,5 +114,6 @@ "survey_link": "Gostou?", "survey_link_tooltip": "Gostou desta página? Partilhe connosco as suas observações!", "listingColumnsIpGameFirewallConfigured": "Configurado", - "listingColumnsIpGameFirewallConfiguredTooltip": "O Game Firewall está configurado. Clique para reconfigurar." + "listingColumnsIpGameFirewallConfiguredTooltip": "O Game Firewall está configurado. Clique para reconfigurar.", + "listingNoResultFoundIpv6": "Nenhum resultado (Clique aqui. para configurar um reverse DNS para que o seu IP seja listado)" } diff --git a/packages/manager/apps/ips/public/translations/order/Messages_de_DE.json b/packages/manager/apps/ips/public/translations/order/Messages_de_DE.json index ce46dbc3c7a0..0134549c438f 100644 --- a/packages/manager/apps/ips/public/translations/order/Messages_de_DE.json +++ b/packages/manager/apps/ips/public/translations/order/Messages_de_DE.json @@ -47,5 +47,16 @@ "new_prefix_ipv6_card_description": "Rufen Sie Ihr neues IPv6-Präfix zur Verwendung mit den vRack-Diensten ab.", "ipv6_limit_reached_error": "Die maximale Anzahl an zusätzlichen IPv6-Blöcken wurde bereits erreicht.", "ipv6_region_3_blocks_limit_reached_error": "Die maximale Anzahl von 3 zusätzlichen IPv6-Blöcken wurde in der ausgewählten Region bereits erreicht. Bitte wählen Sie eine andere Region.", - "ipv6_region_already_used_error": "Sie verfügen bereits über einen Block aus der ausgewählten Region in Ihrem vRack. Bitte wählen Sie eine andere Region." + "ipv6_region_already_used_error": "Sie verfügen bereits über einen Block aus der ausgewählten Region in Ihrem vRack. Bitte wählen Sie eine andere Region.", + "vrack_bandwidth_section_title": "Wählen Sie eine öffentliche Bandbreite aus", + "vrack_bandwidth_section_description": "Bei der Zuweisung einer zusätzlichen IP zu einem privaten vRack-Netzwerk muss die öffentliche Bandbreite aktiviert werden.", + "ap_bandwidth_limit_tooltip_info": "Die Verfügbarkeit der Bandbreite kann in der Region Asien-Pazifik eingeschränkt sein.", + "ip_order_success_message": "Ihre Bestellung für die zusätzliche IP hat in einem neuen Tab begonnen. Bitte schließen Sie diese ab, um Ihren Kauf abzuschließen. Wenn sich der neue Tab nicht öffnet, hier klicken.", + "upgrade_bandwidth_order_success_message": "Ihre Bestellung für die Bandbreite hat in einem neuen Tab begonnen. Bitte schließen Sie diese ab, um Ihren Kauf abzuschließen. Wenn sich der neue Tab nicht öffnet, hier klicken.", + "bandwidth_option_card_tooltip": "Ein anteiliger Kostenbetrag gilt für die verbleibenden Tage Ihres aktuellen Abrechnungszyklus, und die volle Preisgestaltung beginnt im nächsten Abrechnungszyklus.", + "vrack_bandwidth_double_order_info_message": "Eine Bestellung für die öffentliche Bandbreite und eine Bestellung für die zusätzliche IP sind erforderlich, wenn Sie bereits eine aktivierte öffentliche Bandbreite auf Ihrem vRack haben. Wenn Sie noch keine aktivierte öffentliche Bandbreite haben, ist nur eine Bestellung erforderlich.", + "upgrade_bandwidth_order_error_message": "Ein Fehler ist bei der Bestellung der öffentlichen Bandbreite aufgetreten. Bitte versuchen Sie es später noch einmal.", + "vrack_bandwidth_section_description_1": "Bei der Zuweisung einer zusätzlichen IP-Adresse zu einem vRack-Netzwerk gilt die öffentliche Bandbreite für den Datenverkehr zwischen OVHcloud und dem Internet.", + "vrack_bandwidth_section_description_2": "Die ausgewählte öffentliche Bandbreite gilt für ein vRack-Netzwerk in der angegebenen Region und wird zwischen allen zusätzlichen IP-Adressen an diesem Standort, die an dieses Netzwerk geroutet sind, geteilt.", + "vrack_bandwidth_section_description_3": "Bitte wählen Sie das für Ihre Bedürfnisse geeignete Paket aus (es kann später aufgerüstet werden)." } diff --git a/packages/manager/apps/ips/public/translations/order/Messages_en_GB.json b/packages/manager/apps/ips/public/translations/order/Messages_en_GB.json index eea307a2dc40..ca4bee4e7ee3 100644 --- a/packages/manager/apps/ips/public/translations/order/Messages_en_GB.json +++ b/packages/manager/apps/ips/public/translations/order/Messages_en_GB.json @@ -1,6 +1,6 @@ { "title": "Order Additional IP addresses", - "per_ip": "IP", + "per_ip": "/IP", "per_ip_full": "per IP", "ip_version_title": "Select the IP address version", "ip_version_description": "In many cases, you may need either a standard IPv4 or the latest IPv6 protocol. Please note that the list of compatible products may vary.", @@ -47,5 +47,16 @@ "new_prefix_ipv6_card_description": "Get your new IPv6 prefix to use in vRack services.", "ipv6_limit_reached_error": "You have reached the maximum limit of additional IPv6 blocks", "ipv6_region_3_blocks_limit_reached_error": "You have reached the maximum limit of 3 additional IPv6 blocks in the selected region. Please select another region", - "ipv6_region_already_used_error": "You already have a block from the region selected in your vRack. Please select another region" + "ipv6_region_already_used_error": "You already have a block from the region selected in your vRack. Please select another region", + "vrack_bandwidth_section_title": "Select a public bandwidth", + "vrack_bandwidth_section_description": "When assigning an additional IP to a private vRack network, public bandwidth must be enabled.", + "ap_bandwidth_limit_tooltip_info": "Bandwidth availability may be limited in the Asia-Pacific regions.", + "ip_order_success_message": "Your additional IP order has started in a new tab. Please complete it to finalise your purchase. If the new tab does not open, click here.", + "upgrade_bandwidth_order_success_message": "Your bandwidth order has started in a new tab. Please complete it to finalise your purchase. If the new tab does not open, click here.", + "bandwidth_option_card_tooltip": "A pro-rata charge applies for the remaining days of your current billing cycle, and full pricing begins in the next billing cycle.", + "vrack_bandwidth_double_order_info_message": "An order for public bandwidth and an order for the additional IP are required if you already have public bandwidth enabled on your vRack. If you do not yet have public bandwidth enabled, only one order is necessary.", + "upgrade_bandwidth_order_error_message": "An error occurred while ordering the public bandwidth. Please try again later.", + "vrack_bandwidth_section_description_1": "When assigning an additional IP address to a vRack network, the public bandwidth applies to the traffic between OVHcloud and the Internet.", + "vrack_bandwidth_section_description_2": "The selected public bandwidth applies to a vRack network in the given region and is shared among all additional IP addresses at that location routed to this network.", + "vrack_bandwidth_section_description_3": "Please select the plan applicable to your needs (it can be upgraded later)." } diff --git a/packages/manager/apps/ips/public/translations/order/Messages_es_ES.json b/packages/manager/apps/ips/public/translations/order/Messages_es_ES.json index a7a6b2e4e0c4..1e47eb9f29d1 100644 --- a/packages/manager/apps/ips/public/translations/order/Messages_es_ES.json +++ b/packages/manager/apps/ips/public/translations/order/Messages_es_ES.json @@ -47,5 +47,16 @@ "new_prefix_ipv6_card_description": "Obtenga su nuevo prefijo IPv6 para utilizarlo en los servicios vRack.", "ipv6_limit_reached_error": "Ya ha alcanzado el número máximo de bloques de IPv6 adicionales.", "ipv6_region_3_blocks_limit_reached_error": "Ya ha alcanzado el número máximo de 3 bloques de IPv6 adicionales en la región seleccionada. Por favor, seleccione otra región.", - "ipv6_region_already_used_error": "Ya tiene un bloque de la región seleccionada en el vRack. Por favor, seleccione otra región." + "ipv6_region_already_used_error": "Ya tiene un bloque de la región seleccionada en el vRack. Por favor, seleccione otra región.", + "vrack_bandwidth_section_title": "Seleccionar un ancho de banda público", + "vrack_bandwidth_section_description": "Al asignar una IP adicional a una red privada vRack, el ancho de banda público debe estar activado.", + "ap_bandwidth_limit_tooltip_info": "La disponibilidad del ancho de banda puede estar limitada en las regiones de Asia-Pacífico.", + "ip_order_success_message": "Tu pedido de IP adicional ha comenzado en una nueva pestaña. Por favor, termínalo para finalizar tu compra. Si la nueva pestaña no se abre, Haga clic aquí.", + "upgrade_bandwidth_order_success_message": "Tu pedido de ancho de banda ha comenzado en una nueva pestaña. Por favor, termínalo para finalizar tu compra. Si la nueva pestaña no se abre, Haga clic aquí.", + "bandwidth_option_card_tooltip": "Se aplica un coste prorrateado por los días restantes de tu ciclo de facturación actual y la tarificación completa comienza en el siguiente ciclo de facturación.", + "vrack_bandwidth_double_order_info_message": "Se necesita un pedido para el ancho de banda público y un pedido para la IP adicional, si ya tienes un ancho de banda público activado en tu vRack. Si aún no tienes un ancho de banda público activado, solo se necesita un pedido.", + "upgrade_bandwidth_order_error_message": "Se ha producido un error al realizar el pedido del ancho de banda público. Por favor, vuelva a intentarlo más adelante.", + "vrack_bandwidth_section_description_1": "Al asignar una dirección IP adicional a una red vRack, el ancho de banda público se aplica al tráfico entre OVHcloud e Internet.", + "vrack_bandwidth_section_description_2": "El ancho de banda público seleccionado se aplica a una red vRack en la región dada y se comparte entre todas las direcciones IP adicionales de esa ubicación que están enrutadas a esta red.", + "vrack_bandwidth_section_description_3": "Por favor, selecciona el paquete aplicable a tus necesidades (se puede actualizar más adelante)." } diff --git a/packages/manager/apps/ips/public/translations/order/Messages_fr_CA.json b/packages/manager/apps/ips/public/translations/order/Messages_fr_CA.json index 4c1c79d5da00..5aa88c3d7cc2 100644 --- a/packages/manager/apps/ips/public/translations/order/Messages_fr_CA.json +++ b/packages/manager/apps/ips/public/translations/order/Messages_fr_CA.json @@ -47,5 +47,15 @@ "new_prefix_ipv6_card_description": "Obtenez votre nouveau préfixe IPv6 à utiliser dans les services vRack.", "ipv6_limit_reached_error": "Le nombre maximum de blocs IPv6 supplémentaires a déjà été atteint", "ipv6_region_3_blocks_limit_reached_error": "Le nombre maximum de 3 blocs IPv6 supplémentaires a déjà été atteint dans la région sélectionnée. Veuillez choisir une autre région.", - "ipv6_region_already_used_error": "Vous disposez déjà d'un bloc de la région sélectionnée dans votre vRack. Veuillez choisir une autre région." + "ipv6_region_already_used_error": "Vous disposez déjà d'un bloc de la région sélectionnée dans votre vRack. Veuillez choisir une autre région.", + "vrack_bandwidth_section_title": "Sélectionner une bande passante publique", + "vrack_bandwidth_section_description_1": "Lors de l’attribution d’une adresse IP supplémentaire à un réseau vRack, la bande passante publique s’applique au trafic entre OVHcloud et Internet.", + "vrack_bandwidth_section_description_2": "La bande passante publique sélectionnée s’applique à un réseau vRack dans la région donnée et est partagée entre toutes les adresses IP supplémentaires de cet emplacement routées vers ce réseau.", + "vrack_bandwidth_section_description_3": "Veuillez sélectionner le forfait applicable à vos besoins (il peut être mis à niveau ultérieurement).", + "ap_bandwidth_limit_tooltip_info": "La disponibilité de la bande passante peut être limitée dans les régions Asie-Pacifique.", + "ip_order_success_message": "Votre commande d'IP additionnelle a commencé dans un nouvel onglet. Veuillez la terminer pour finaliser votre achat. Si le nouvel onglet ne s'ouvre pas, cliquez ici.", + "upgrade_bandwidth_order_success_message": "Votre commande de bande passante a commencé dans un nouvel onglet. Veuillez la terminer pour finaliser votre achat. Si le nouvel onglet ne s'ouvre pas, cliquez ici.", + "bandwidth_option_card_tooltip": "Un coût au prorata s'applique pour les jours restants de votre cycle de facturation actuel et la tarification complète commence le cycle de facturation suivant.", + "vrack_bandwidth_double_order_info_message": "Une commande pour la bande passante publique et une commande pour l'IP additionnelle, sont nécessaires si vous avez déjà une bande passante publique activée sur votre vRack. Si vous n'avez pas encore de bande passante publique activée, une seule commande est nécessaire.", + "upgrade_bandwidth_order_error_message": "Une erreur est survenue lors de la commande de la bande passante publique. Veuillez réessayer ultérieurement." } diff --git a/packages/manager/apps/ips/public/translations/order/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/order/Messages_fr_FR.json index 4c1c79d5da00..5aa88c3d7cc2 100644 --- a/packages/manager/apps/ips/public/translations/order/Messages_fr_FR.json +++ b/packages/manager/apps/ips/public/translations/order/Messages_fr_FR.json @@ -47,5 +47,15 @@ "new_prefix_ipv6_card_description": "Obtenez votre nouveau préfixe IPv6 à utiliser dans les services vRack.", "ipv6_limit_reached_error": "Le nombre maximum de blocs IPv6 supplémentaires a déjà été atteint", "ipv6_region_3_blocks_limit_reached_error": "Le nombre maximum de 3 blocs IPv6 supplémentaires a déjà été atteint dans la région sélectionnée. Veuillez choisir une autre région.", - "ipv6_region_already_used_error": "Vous disposez déjà d'un bloc de la région sélectionnée dans votre vRack. Veuillez choisir une autre région." + "ipv6_region_already_used_error": "Vous disposez déjà d'un bloc de la région sélectionnée dans votre vRack. Veuillez choisir une autre région.", + "vrack_bandwidth_section_title": "Sélectionner une bande passante publique", + "vrack_bandwidth_section_description_1": "Lors de l’attribution d’une adresse IP supplémentaire à un réseau vRack, la bande passante publique s’applique au trafic entre OVHcloud et Internet.", + "vrack_bandwidth_section_description_2": "La bande passante publique sélectionnée s’applique à un réseau vRack dans la région donnée et est partagée entre toutes les adresses IP supplémentaires de cet emplacement routées vers ce réseau.", + "vrack_bandwidth_section_description_3": "Veuillez sélectionner le forfait applicable à vos besoins (il peut être mis à niveau ultérieurement).", + "ap_bandwidth_limit_tooltip_info": "La disponibilité de la bande passante peut être limitée dans les régions Asie-Pacifique.", + "ip_order_success_message": "Votre commande d'IP additionnelle a commencé dans un nouvel onglet. Veuillez la terminer pour finaliser votre achat. Si le nouvel onglet ne s'ouvre pas, cliquez ici.", + "upgrade_bandwidth_order_success_message": "Votre commande de bande passante a commencé dans un nouvel onglet. Veuillez la terminer pour finaliser votre achat. Si le nouvel onglet ne s'ouvre pas, cliquez ici.", + "bandwidth_option_card_tooltip": "Un coût au prorata s'applique pour les jours restants de votre cycle de facturation actuel et la tarification complète commence le cycle de facturation suivant.", + "vrack_bandwidth_double_order_info_message": "Une commande pour la bande passante publique et une commande pour l'IP additionnelle, sont nécessaires si vous avez déjà une bande passante publique activée sur votre vRack. Si vous n'avez pas encore de bande passante publique activée, une seule commande est nécessaire.", + "upgrade_bandwidth_order_error_message": "Une erreur est survenue lors de la commande de la bande passante publique. Veuillez réessayer ultérieurement." } diff --git a/packages/manager/apps/ips/public/translations/order/Messages_it_IT.json b/packages/manager/apps/ips/public/translations/order/Messages_it_IT.json index e6228a695817..053ebec25187 100644 --- a/packages/manager/apps/ips/public/translations/order/Messages_it_IT.json +++ b/packages/manager/apps/ips/public/translations/order/Messages_it_IT.json @@ -47,5 +47,16 @@ "new_prefix_ipv6_card_description": "Ottieni il nuovo prefisso IPv6 da utilizzare nei servizi vRack.", "ipv6_limit_reached_error": "È stato raggiunto il numero massimo di blocchi IPv6 aggiuntivi", "ipv6_region_3_blocks_limit_reached_error": "È stato raggiunto il numero massimo di 3 blocchi IPv6 aggiuntivi nella Region selezionata. Scegli un'altra Region", - "ipv6_region_already_used_error": "Disponi già di un blocco della Region selezionata nella tua vRack. Scegli un'altra Region" + "ipv6_region_already_used_error": "Disponi già di un blocco della Region selezionata nella tua vRack. Scegli un'altra Region", + "vrack_bandwidth_section_title": "Selezionare una larghezza di banda pubblica", + "vrack_bandwidth_section_description": "Quando si assegna un'IP aggiuntiva a una rete privata vRack, la larghezza di banda pubblica deve essere attivata.", + "ap_bandwidth_limit_tooltip_info": "La disponibilità della larghezza di banda può essere limitata nelle regioni dell'Asia-Pacifico.", + "ip_order_success_message": "Il tuo ordine di IP aggiuntiva è iniziato in una nuova scheda. Si prega di completarlo per finalizzare l'acquisto. Se la nuova scheda non si apre, clicca qui.", + "upgrade_bandwidth_order_success_message": "Il tuo ordine di larghezza di banda è iniziato in una nuova scheda. Si prega di completarlo per finalizzare l'acquisto. Se la nuova scheda non si apre, clicca qui.", + "bandwidth_option_card_tooltip": "Si applica un costo proporzionale per i giorni rimanenti del tuo ciclo di fatturazione attuale e la tariffazione completa inizia nel ciclo di fatturazione successivo.", + "vrack_bandwidth_double_order_info_message": "È necessario un ordine per la larghezza di banda pubblica e un ordine per l'IP aggiuntiva, se hai già una larghezza di banda pubblica attivata sul tuo vRack. Se non hai ancora una larghezza di banda pubblica attivata, è necessario un solo ordine.", + "upgrade_bandwidth_order_error_message": "Si è verificato un errore durante l'ordine della larghezza di banda pubblica. Riprova più tardi.", + "vrack_bandwidth_section_description_1": "Quando si assegna un indirizzo IP aggiuntivo a una rete vRack, la larghezza di banda pubblica si applica al traffico tra OVHcloud e Internet.", + "vrack_bandwidth_section_description_2": "La larghezza di banda pubblica selezionata si applica a una rete vRack nella regione specificata ed è condivisa tra tutti gli indirizzi IP aggiuntivi di quella posizione instradati verso questa rete.", + "vrack_bandwidth_section_description_3": "Si prega di selezionare il piano applicabile alle proprie esigenze (può essere aggiornato in seguito)." } diff --git a/packages/manager/apps/ips/public/translations/order/Messages_pl_PL.json b/packages/manager/apps/ips/public/translations/order/Messages_pl_PL.json index 8cc17bc79352..357c9b2ab4e2 100644 --- a/packages/manager/apps/ips/public/translations/order/Messages_pl_PL.json +++ b/packages/manager/apps/ips/public/translations/order/Messages_pl_PL.json @@ -47,5 +47,16 @@ "new_prefix_ipv6_card_description": "Uzyskaj nowy prefiks IPv6, którego będziesz używał w usługach vRack.", "ipv6_limit_reached_error": "Maksymalna liczba dodatkowych bloków IPv6 została już osiągnięta", "ipv6_region_3_blocks_limit_reached_error": "W wybranym regionie została już osiągnięta maksymalna liczba 3 dodatkowych bloków IPv6. Wybierz inny region.", - "ipv6_region_already_used_error": "Dysponujesz już blokiem wybranego regionu w ramach usługi vRack. Wybierz inny region." + "ipv6_region_already_used_error": "Dysponujesz już blokiem wybranego regionu w ramach usługi vRack. Wybierz inny region.", + "vrack_bandwidth_section_title": "Wybierz publiczną przepustowość", + "vrack_bandwidth_section_description": "Podczas przypisywania dodatkowego adresu IP do prywatnej sieci vRack, publiczna przepustowość musi być aktywna.", + "ap_bandwidth_limit_tooltip_info": "Dostępność przepustowości może być ograniczona w regionach Azji i Pacyfiku.", + "ip_order_success_message": "Twoje zamówienie na dodatkowy adres IP rozpoczęło się w nowej karcie. Proszę je dokończyć, aby sfinalizować zakupu. Jeśli nowa karta się nie otworzy, kliknij tutaj.", + "upgrade_bandwidth_order_success_message": "Twoje zamówienie na przepustowość rozpoczęło się w nowej karcie. Proszę je dokończyć, aby sfinalizować zakupu. Jeśli nowa karta się nie otworzy, kliknij tutaj.", + "bandwidth_option_card_tooltip": "Koszt proporcjonalny dotyczy pozostałych dni bieżącego cyklu rozliczeniowego, a pełna cena zaczyna się w następnym cyklu rozliczeniowym.", + "vrack_bandwidth_double_order_info_message": "Zamówienie na publiczną przepustowość oraz zamówienie na dodatkowy adres IP są wymagane, jeśli masz już aktywną publiczną przepustowość na swoim vRack. Jeśli nie masz jeszcze aktywnej publicznej przepustowości, potrzebne jest tylko jedno zamówienie.", + "upgrade_bandwidth_order_error_message": "Wystąpił błąd podczas zamawiania publicznej przepustowości. Spróbuj ponownie później.", + "vrack_bandwidth_section_description_1": "Podczas przydzielania dodatkowego adresu IP do sieci vRack, pasmo publiczne dotyczy ruchu między OVHcloud a Internetem.", + "vrack_bandwidth_section_description_2": "Wybrane pasmo publiczne dotyczy sieci vRack w danym regionie i jest dzielone między wszystkie dodatkowe adresy IP w tej lokalizacji, które są routowane do tej sieci.", + "vrack_bandwidth_section_description_3": "Proszę wybrać odpowiedni plan do swoich potrzeb (może być później zaktualizowany)." } diff --git a/packages/manager/apps/ips/public/translations/order/Messages_pt_PT.json b/packages/manager/apps/ips/public/translations/order/Messages_pt_PT.json index b18db901fe21..f7e04f729207 100644 --- a/packages/manager/apps/ips/public/translations/order/Messages_pt_PT.json +++ b/packages/manager/apps/ips/public/translations/order/Messages_pt_PT.json @@ -47,5 +47,16 @@ "new_prefix_ipv6_card_description": "Obtenha o seu novo prefixo IPv6 para utilizar nos serviços vRack.", "ipv6_limit_reached_error": "O número máximo de blocos IPv6 adicionais já foi atingido", "ipv6_region_3_blocks_limit_reached_error": "O número máximo de 3 blocos IPv6 adicionais já foi atingido na região selecionada. Escolha outra região.", - "ipv6_region_already_used_error": "Já dispõe de um bloco da região selecionada no seu vRack. Escolha outra região." + "ipv6_region_already_used_error": "Já dispõe de um bloco da região selecionada no seu vRack. Escolha outra região.", + "vrack_bandwidth_section_title": "Selecionar uma largura de banda pública", + "vrack_bandwidth_section_description": "Ao atribuir um IP adicional a uma rede privada vRack, a largura de banda pública deve ser ativada.", + "ap_bandwidth_limit_tooltip_info": "A disponibilidade da largura de banda pode ser limitada nas regiões da Ásia-Pacífico.", + "ip_order_success_message": "O seu pedido de IP adicional começou numa nova aba. Por favor, termine-o para finalizar a sua compra. Se a nova aba não abrir, clique aqui.", + "upgrade_bandwidth_order_success_message": "O seu pedido de largura de banda começou numa nova aba. Por favor, termine-o para finalizar a sua compra. Se a nova aba não abrir, clique aqui.", + "bandwidth_option_card_tooltip": "Um custo proporcional aplica-se aos dias restantes do seu ciclo de faturação atual e a tarifação completa começa no ciclo de faturação seguinte.", + "vrack_bandwidth_double_order_info_message": "Um pedido para a largura de banda pública e um pedido para o IP adicional são necessários se já tiver uma largura de banda pública ativada no seu vRack. Se ainda não tiver largura de banda pública ativada, é necessário apenas um pedido.", + "upgrade_bandwidth_order_error_message": "Ocorreu um erro ao fazer o pedido da largura de banda pública. Volte a tentar mais tarde", + "vrack_bandwidth_section_description_1": "Ao atribuir um endereço IP adicional a uma rede vRack, a largura de banda pública aplica-se ao tráfego entre a OVHcloud e a Internet.", + "vrack_bandwidth_section_description_2": "A largura de banda pública selecionada aplica-se a uma rede vRack na região dada e é partilhada entre todos os endereços IP adicionais desse local que estão encaminhados para esta rede.", + "vrack_bandwidth_section_description_3": "Por favor, selecione o plano aplicável às suas necessidades (pode ser atualizado posteriormente)." } diff --git a/packages/manager/apps/ips/src/__mocks__/vrack.ts b/packages/manager/apps/ips/src/__mocks__/vrack.ts index 14e7dcd232d7..4ee8977e1760 100644 --- a/packages/manager/apps/ips/src/__mocks__/vrack.ts +++ b/packages/manager/apps/ips/src/__mocks__/vrack.ts @@ -21,6 +21,132 @@ export const vrackMockList = [ }, ]; +export const publicRoutingRegionMocks = [ + { + region: 'eu-west-par', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'eu-west-gra', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'eu-west-lim', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'eu-west-eri', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'eu-west-rbx', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'eu-west-sbg', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'eu-central-waw', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'ca-east-bhs', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'ca-east-tor', + defaultBandwidthLimit: 5000, + publicRoutingType: 'PUBLIC-ROUTING-3-AZ', + }, + { + region: 'ap-south-mum', + defaultBandwidthLimit: 100, + publicRoutingType: 'PUBLIC-ROUTING-1-AZ', + }, + { + region: 'ap-southeast-sgp', + defaultBandwidthLimit: 100, + publicRoutingType: 'PUBLIC-ROUTING-1-AZ', + }, + { + region: 'ap-southeast-syd', + defaultBandwidthLimit: 100, + publicRoutingType: 'PUBLIC-ROUTING-1-AZ', + }, +]; + +export const publicRoutingBandwidthLimitMocks = [ + { + region: 'eu-west-par', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'eu-west-gra', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'eu-west-lim', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'eu-west-eri', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'eu-west-rbx', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'eu-west-sbg', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'eu-central-waw', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'ca-east-bhs', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'ca-east-tor', + bandwidthLimit: 5000, + bandwidthLimitType: 'default', + }, + { + region: 'ap-south-mum', + bandwidthLimit: 100, + bandwidthLimitType: 'default', + }, + { + region: 'ap-southeast-sgp', + bandwidthLimit: 100, + bandwidthLimitType: 'default', + }, + { + region: 'ap-southeast-syd', + bandwidthLimit: 100, + bandwidthLimitType: 'default', + }, +]; + export type GetVrackMocksParams = { nbVrack?: number; getVrackKo?: boolean; @@ -32,6 +158,16 @@ export const getVrackMocks = ({ getVrackKo, isVrackExpired, }: GetVrackMocksParams): Handler[] => [ + { + url: '/vrack/publicRoutingRegion', + response: publicRoutingRegionMocks, + api: 'v6', + }, + { + url: '/vrack/:serviceName/publicRoutingBandwidthLimit', + response: publicRoutingBandwidthLimitMocks, + api: 'v6', + }, { url: '/vrack/:serviceName/task/:taskId', response: {}, @@ -58,4 +194,14 @@ export const getVrackMocks = ({ api: 'v6', status: getVrackKo ? 400 : 200, }, + { + url: '/order/cartServiceOption/vrack/:serviceName', + response: [], + api: 'v6', + }, + { + url: '/order/upgrade/bandwidthVrack', + response: [], + api: 'v6', + }, ]; diff --git a/packages/manager/apps/ips/src/components/ApiError/ApiErrorMessage.tsx b/packages/manager/apps/ips/src/components/ApiError/ApiErrorMessage.tsx index 80be193126a9..44642895298c 100644 --- a/packages/manager/apps/ips/src/components/ApiError/ApiErrorMessage.tsx +++ b/packages/manager/apps/ips/src/components/ApiError/ApiErrorMessage.tsx @@ -9,11 +9,14 @@ import { ApiError } from '@ovh-ux/manager-core-api'; import { TRANSLATION_NAMESPACES } from '@/utils'; -export const useApiErrorMessage = (error?: ApiError | null) => { +export const useApiErrorMessage = (error?: ApiError | Error | null) => { const { t } = useTranslation(TRANSLATION_NAMESPACES.error); - const errorMessage = error?.response?.data?.message || error?.message; - const ovhQueryId = error?.response?.headers?.['x-ovh-queryid'] as string; + const errorMessage = + (error as ApiError)?.response?.data?.message || error?.message; + const ovhQueryId = (error as ApiError)?.response?.headers?.[ + 'x-ovh-queryid' + ] as string; if (!errorMessage) { return undefined; @@ -25,7 +28,7 @@ export const useApiErrorMessage = (error?: ApiError | null) => { }; export type ApiErrorMessageProps = { - error?: ApiError | null; + error?: ApiError | Error | null; isDismissible?: boolean; className?: string; }; diff --git a/packages/manager/apps/ips/src/components/BandwidthOptionCard/BandwidthOptionCard.tsx b/packages/manager/apps/ips/src/components/BandwidthOptionCard/BandwidthOptionCard.tsx new file mode 100644 index 000000000000..46c1678c0b17 --- /dev/null +++ b/packages/manager/apps/ips/src/components/BandwidthOptionCard/BandwidthOptionCard.tsx @@ -0,0 +1,112 @@ +import React from 'react'; + +import { useTranslation } from 'react-i18next'; + +import { + ODS_CARD_COLOR, + ODS_ICON_NAME, + ODS_MESSAGE_COLOR, + ODS_TEXT_PRESET, +} from '@ovhcloud/ods-components'; +import { + OdsCard, + OdsIcon, + OdsMessage, + OdsRadio, + OdsText, + OdsTooltip, +} from '@ovhcloud/ods-components/react'; + +import { useBandwidthFormatConverter } from '@ovh-ux/manager-network-common'; +import { handleClick } from '@ovh-ux/manager-react-components'; + +import { PriceDescription } from '../PriceDescription/PriceDescription'; + +import './bandwidth-option-card.scss'; + +export type BandwidthOptionCardProps = { + className?: string; + isDisabled?: boolean; + isSelected?: boolean; + message?: string; + messageColor?: ODS_MESSAGE_COLOR; + onClick?: () => void; + bandwidthLimit: number; + price: number; + tooltip?: string; +}; + +export const BandwidthOptionCard: React.FC = ({ + className, + isDisabled, + isSelected, + message, + messageColor = ODS_MESSAGE_COLOR.information, + onClick, + bandwidthLimit, + price, + tooltip, +}) => { + const { t } = useTranslation(); + const stateStyle = isDisabled + ? 'cursor-not-allowed bg-neutral-100' + : 'cursor-pointer hover:shadow-md'; + const cardStyle = isSelected ? 'option_card_selected' : 'option_card m-[1px]'; + const converter = useBandwidthFormatConverter(); + return ( + !isDisabled && onClick?.())} + color={ODS_CARD_COLOR.neutral} + > +
+ + + {converter(bandwidthLimit).perSecondFormat} + {tooltip && ( + <> + + + + {tooltip} + + + + )} + + + {price === 0 ? ( + {t('free_price')} + ) : ( + + )} + +
+ {!!message && ( + + {message} + + )} +
+ ); +}; diff --git a/packages/manager/apps/ips/src/components/BandwidthOptionCard/bandwidth-option-card.scss b/packages/manager/apps/ips/src/components/BandwidthOptionCard/bandwidth-option-card.scss new file mode 100644 index 000000000000..b0d1e23cbfc7 --- /dev/null +++ b/packages/manager/apps/ips/src/components/BandwidthOptionCard/bandwidth-option-card.scss @@ -0,0 +1,20 @@ +@import '@ovhcloud/ods-themes/default'; + +.option_card { + border-color: var(--ods-color-primary-100); + .card-children { + background-color: var(--ods-color-neutral-050); + } +} + +.option_card_selected { + border: 2px solid var(--ods-color-information-500); + background-color: #f3fdff; + margin: 0; +} + +.free-price { + color: var(--ods-color-promotion); + font-weight: 700; + font-size: 16px; +} diff --git a/packages/manager/apps/ips/src/components/OrderSection/OrderSection.component.tsx b/packages/manager/apps/ips/src/components/OrderSection/OrderSection.component.tsx index 73465bfabcaa..3219ba56acd6 100644 --- a/packages/manager/apps/ips/src/components/OrderSection/OrderSection.component.tsx +++ b/packages/manager/apps/ips/src/components/OrderSection/OrderSection.component.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { ODS_SPINNER_SIZE, ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; import { @@ -11,23 +11,54 @@ export const OrderSection: React.FC< React.PropsWithChildren<{ title: string; description?: string; + description2?: string; + description3?: string; isLoading?: boolean; + className?: string; + isError?: boolean; + errorComponent?: ReactNode; }> -> = ({ title, description, isLoading, children }) => ( -
+> = ({ + title, + description, + description2, + description3, + isLoading, + className, + isError, + errorComponent, + children, +}) => ( +
{title} - - {description} - - {isLoading ? ( + {(description || description2 || description3) && ( +
+ {description && ( + + {description} + + )} + {description2 && ( + + {description2} + + )} + {description3 && ( + + {description3} + + )} +
+ )} + {isLoading && (
- ) : ( - children )} + {!isLoading && !isError &&
{children}
} + {!isLoading && isError && errorComponent}
); diff --git a/packages/manager/apps/ips/src/components/RegionCard/RegionCard.component.tsx b/packages/manager/apps/ips/src/components/RegionCard/RegionCard.component.tsx index dfb63130a7d3..5d127ee4256f 100644 --- a/packages/manager/apps/ips/src/components/RegionCard/RegionCard.component.tsx +++ b/packages/manager/apps/ips/src/components/RegionCard/RegionCard.component.tsx @@ -2,12 +2,18 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ODS_CARD_COLOR, ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { + ODS_CARD_COLOR, + ODS_ICON_NAME, + ODS_TEXT_PRESET, +} from '@ovhcloud/ods-components'; import { OdsCard, + OdsIcon, OdsPopover, OdsRadio, OdsText, + OdsTooltip, } from '@ovhcloud/ods-components/react'; import { getCountryCode } from '@/utils'; @@ -24,6 +30,7 @@ export type RegionCardProps = React.PropsWithChildren<{ disabledMessage?: string; isSelected?: boolean; onClick?: () => void; + tooltipInfo?: string; }>; export const RegionCard: React.FC = ({ @@ -31,6 +38,7 @@ export const RegionCard: React.FC = ({ disabledMessage, isSelected, onClick, + tooltipInfo, }) => { const stateStyle = disabledMessage ? 'cursor-not-allowed bg-neutral-100' @@ -50,27 +58,52 @@ export const RegionCard: React.FC = ({ !disabledMessage && onClick?.()} color={ODS_CARD_COLOR.neutral} > - - - - -
- - {t(getCountryKey(region))} - {t(getCityNameKey(region))} - +
+
+ + +
+
+ + {t(getCountryKey(region))} - {t(getCityNameKey(region))} + - {region} + + {region} + {!disabledMessage && tooltipInfo && ( + <> + + + {tooltipInfo} + + + + + )} + +
diff --git a/packages/manager/apps/ips/src/components/RegionSelector/__snapshots__/region-selector.spec.tsx.snap b/packages/manager/apps/ips/src/components/RegionSelector/__snapshots__/region-selector.spec.tsx.snap index 7f1c6f86e46c..909fc6a6412d 100644 --- a/packages/manager/apps/ips/src/components/RegionSelector/__snapshots__/region-selector.spec.tsx.snap +++ b/packages/manager/apps/ips/src/components/RegionSelector/__snapshots__/region-selector.spec.tsx.snap @@ -38,523 +38,565 @@ exports[`RegionSelector component > disables region correctly 1`] = `
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-par - - - eu-west-par - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-par + + + eu-west-par + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-gra - - - eu-west-gra - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-gra + + + eu-west-gra + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-rbx - - - eu-west-rbx - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-rbx + + + eu-west-rbx + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-sbg - - - eu-west-sbg - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-sbg + + + eu-west-sbg + +
- - - -
- - region-selector-country-name_DE - region-selector-city-name_eu-west-lim - - - eu-west-lim - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_DE - region-selector-city-name_eu-west-lim + + + eu-west-lim + +
- - - -
- - region-selector-country-name_PL - region-selector-city-name_eu-central-waw - - - eu-central-waw - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_PL - region-selector-city-name_eu-central-waw + + + eu-central-waw + +
- - - -
- - region-selector-country-name_GB - region-selector-city-name_eu-west-eri - - - eu-west-eri - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_GB - region-selector-city-name_eu-west-eri + + + eu-west-eri + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-vin - - - us-east-vin - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-vin + + + us-east-vin + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-west-hil - - - us-west-hil - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-west-hil + + + us-west-hil + +
- - - -
- - region-selector-country-name_CA - region-selector-city-name_ca-east-bhs - - - ca-east-bhs - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_CA - region-selector-city-name_ca-east-bhs + + + ca-east-bhs + +
- - - -
- - region-selector-country-name_SG - region-selector-city-name_ap-southeast-sgp - - - ap-southeast-sgp - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_SG - region-selector-city-name_ap-southeast-sgp + + + ap-southeast-sgp + +
- - - -
- - region-selector-country-name_AU - region-selector-city-name_ap-southeast-syd - - - ap-southeast-syd - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_AU - region-selector-city-name_ap-southeast-syd + + + ap-southeast-syd + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-rbx-snc - - - eu-west-rbx-snc - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-rbx-snc + + + eu-west-rbx-snc + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-sbg-snc - - - eu-west-sbg-snc - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-sbg-snc + + + eu-west-sbg-snc + +
@@ -572,630 +614,681 @@ exports[`RegionSelector component > disables region correctly 1`] = ` - - - -
- - region-selector-country-name_CA - region-selector-city-name_ca-east-tor - - - ca-east-tor - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_CA - region-selector-city-name_ca-east-tor + + + ca-east-tor + +
- - - -
- - region-selector-country-name_IN - region-selector-city-name_ap-south-mum - - - ap-south-mum - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_IN - region-selector-city-name_ap-south-mum + + + ap-south-mum + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_labeu-west-1-preprod - - - labeu-west-1-preprod - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_labeu-west-1-preprod + + + labeu-west-1-preprod + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_labeu-west-1-dev-2 - - - labeu-west-1-dev-2 - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_labeu-west-1-dev-2 + + + labeu-west-1-dev-2 + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_labeu-west-1-dev-1 - - - labeu-west-1-dev-1 - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_labeu-west-1-dev-1 + + + labeu-west-1-dev-1 + +
- - - -
- - region-selector-country-name_BE - region-selector-city-name_eu-west-lz-bru - - - eu-west-lz-bru - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_BE - region-selector-city-name_eu-west-lz-bru + + + eu-west-lz-bru + +
- - - -
- - region-selector-country-name_ES - region-selector-city-name_eu-west-lz-mad - - - eu-west-lz-mad - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_ES - region-selector-city-name_eu-west-lz-mad + + + eu-west-lz-mad + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-gra-snc - - - eu-west-gra-snc - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-gra-snc + + + eu-west-gra-snc + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-dal - - - us-east-lz-dal - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-dal + + + us-east-lz-dal + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-west-lz-lax - - - us-west-lz-lax - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-west-lz-lax + + + us-west-lz-lax + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-chi - - - us-east-lz-chi - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-chi + + + us-east-lz-chi + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-nyc - - - us-east-lz-nyc - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-nyc + + + us-east-lz-nyc + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-mia - - - us-east-lz-mia - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-mia + + + us-east-lz-mia + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-west-lz-pao - - - us-west-lz-pao - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-west-lz-pao + + + us-west-lz-pao + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-west-lz-den - - - us-west-lz-den - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-west-lz-den + + + us-west-lz-den + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-atl - - - us-east-lz-atl - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-atl + + + us-east-lz-atl + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-lz-mrs - - - eu-west-lz-mrs - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-lz-mrs + + + eu-west-lz-mrs + +
@@ -1208,7 +1301,7 @@ exports[`RegionSelector component > does not break if there is no region at all
@@ -1252,1152 +1345,1245 @@ exports[`RegionSelector component > renders correctly 1`] = `
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-par - - - eu-west-par - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-par + + + eu-west-par + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-gra - - - eu-west-gra - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-gra + + + eu-west-gra + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-rbx - - - eu-west-rbx - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-rbx + + + eu-west-rbx + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-sbg - - - eu-west-sbg - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-sbg + + + eu-west-sbg + +
- - - -
- - region-selector-country-name_DE - region-selector-city-name_eu-west-lim - - - eu-west-lim - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_DE - region-selector-city-name_eu-west-lim + + + eu-west-lim + +
- - - -
- - region-selector-country-name_PL - region-selector-city-name_eu-central-waw - - - eu-central-waw - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_PL - region-selector-city-name_eu-central-waw + + + eu-central-waw + +
- - - -
- - region-selector-country-name_GB - region-selector-city-name_eu-west-eri - - - eu-west-eri - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_GB - region-selector-city-name_eu-west-eri + + + eu-west-eri + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-vin - - - us-east-vin - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-vin + + + us-east-vin + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-west-hil - - - us-west-hil - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-west-hil + + + us-west-hil + +
- - - -
- - region-selector-country-name_CA - region-selector-city-name_ca-east-bhs - - - ca-east-bhs - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_CA - region-selector-city-name_ca-east-bhs + + + ca-east-bhs + +
- - - -
- - region-selector-country-name_SG - region-selector-city-name_ap-southeast-sgp - - - ap-southeast-sgp - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_SG - region-selector-city-name_ap-southeast-sgp + + + ap-southeast-sgp + +
- - - -
- - region-selector-country-name_AU - region-selector-city-name_ap-southeast-syd - - - ap-southeast-syd - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_AU - region-selector-city-name_ap-southeast-syd + + + ap-southeast-syd + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-rbx-snc - - - eu-west-rbx-snc - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-rbx-snc + + + eu-west-rbx-snc + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-sbg-snc - - - eu-west-sbg-snc - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-sbg-snc + + + eu-west-sbg-snc + +
- - - -
- - region-selector-country-name_CA - region-selector-city-name_ca-east-tor - - - ca-east-tor - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_CA - region-selector-city-name_ca-east-tor + + + ca-east-tor + +
- - - -
- - region-selector-country-name_IN - region-selector-city-name_ap-south-mum - - - ap-south-mum - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_IN - region-selector-city-name_ap-south-mum + + + ap-south-mum + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_labeu-west-1-preprod - - - labeu-west-1-preprod - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_labeu-west-1-preprod + + + labeu-west-1-preprod + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_labeu-west-1-dev-2 - - - labeu-west-1-dev-2 - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_labeu-west-1-dev-2 + + + labeu-west-1-dev-2 + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_labeu-west-1-dev-1 - - - labeu-west-1-dev-1 - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_labeu-west-1-dev-1 + + + labeu-west-1-dev-1 + +
- - - -
- - region-selector-country-name_BE - region-selector-city-name_eu-west-lz-bru - - - eu-west-lz-bru - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_BE - region-selector-city-name_eu-west-lz-bru + + + eu-west-lz-bru + +
- - - -
- - region-selector-country-name_ES - region-selector-city-name_eu-west-lz-mad - - - eu-west-lz-mad - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_ES - region-selector-city-name_eu-west-lz-mad + + + eu-west-lz-mad + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-gra-snc - - - eu-west-gra-snc - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-gra-snc + + + eu-west-gra-snc + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-dal - - - us-east-lz-dal - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-dal + + + us-east-lz-dal + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-west-lz-lax - - - us-west-lz-lax - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-west-lz-lax + + + us-west-lz-lax + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-chi - - - us-east-lz-chi - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-chi + + + us-east-lz-chi + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-nyc - - - us-east-lz-nyc - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-nyc + + + us-east-lz-nyc + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-mia - - - us-east-lz-mia - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-mia + + + us-east-lz-mia + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-west-lz-pao - - - us-west-lz-pao - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-west-lz-pao + + + us-west-lz-pao + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-west-lz-den - - - us-west-lz-den - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-west-lz-den + + + us-west-lz-den + +
- - - -
- - region-selector-country-name_US - region-selector-city-name_us-east-lz-atl - - - us-east-lz-atl - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_US - region-selector-city-name_us-east-lz-atl + + + us-east-lz-atl + +
- - - -
- - region-selector-country-name_FR - region-selector-city-name_eu-west-lz-mrs - - - eu-west-lz-mrs - + class="flex gap-3 p-4" + > +
+ + +
+
+ + region-selector-country-name_FR - region-selector-city-name_eu-west-lz-mrs + + + eu-west-lz-mrs + +
diff --git a/packages/manager/apps/ips/src/components/RegionSelector/region-selector.component.tsx b/packages/manager/apps/ips/src/components/RegionSelector/region-selector.component.tsx index 6584b4cb5397..71a977775ddc 100644 --- a/packages/manager/apps/ips/src/components/RegionSelector/region-selector.component.tsx +++ b/packages/manager/apps/ips/src/components/RegionSelector/region-selector.component.tsx @@ -17,16 +17,23 @@ export type DisabledRegion = { message: string; }; +export type TooltipConfig = { + isTooltipDisplayed: (region: string) => boolean; + message: string; +}; + export type RegionSelectorProps = { regionList: string[]; disabledRegions?: DisabledRegion[]; selectedRegion?: string; setSelectedRegion: (region?: string) => void; + tooltipList?: TooltipConfig[]; }; export const RegionSelector: React.FC = ({ regionList, disabledRegions = [], + tooltipList = [], selectedRegion, setSelectedRegion, }) => { @@ -44,7 +51,7 @@ export const RegionSelector: React.FC = ({ setApFilter={() => setCurrentFilter(RegionFilter.ap)} /> )} -
+
{regionList .filter((region) => { switch (currentFilter) { @@ -65,6 +72,9 @@ export const RegionSelector: React.FC = ({ const disabledMessage = disabledRegions.find( (item) => item.region === region, )?.message; + const tooltipInfo = tooltipList.find(({ isTooltipDisplayed }) => + isTooltipDisplayed(region), + )?.message; const isSelected = selectedRegion === region && !disabledMessage; return ( @@ -74,6 +84,7 @@ export const RegionSelector: React.FC = ({ disabledMessage={disabledMessage} isSelected={isSelected} onClick={() => !disabledMessage && setSelectedRegion(region)} + tooltipInfo={tooltipInfo} /> ); })} diff --git a/packages/manager/apps/ips/src/components/SurveyLink/SurveyLink.tsx b/packages/manager/apps/ips/src/components/SurveyLink/SurveyLink.tsx index 8de09adff713..52712e3e3316 100644 --- a/packages/manager/apps/ips/src/components/SurveyLink/SurveyLink.tsx +++ b/packages/manager/apps/ips/src/components/SurveyLink/SurveyLink.tsx @@ -43,7 +43,9 @@ export const SurveyLink: React.FC = () => { iconAlignment={ODS_LINK_ICON_ALIGNMENT.left} /> - {t('survey_link_tooltip')} + + {t('survey_link_tooltip')} + ); diff --git a/packages/manager/apps/ips/src/components/SurveyLink/__snapshots__/SurveyLink.spec.tsx.snap b/packages/manager/apps/ips/src/components/SurveyLink/__snapshots__/SurveyLink.spec.tsx.snap index b1d1714c8cc0..6a0f412bc97e 100644 --- a/packages/manager/apps/ips/src/components/SurveyLink/__snapshots__/SurveyLink.spec.tsx.snap +++ b/packages/manager/apps/ips/src/components/SurveyLink/__snapshots__/SurveyLink.spec.tsx.snap @@ -17,7 +17,7 @@ exports[`SurveyLink Component > Should render correctly with survey URL and tool with-arrow="true" > survey_link_tooltip diff --git a/packages/manager/apps/ips/src/components/ipVersionOptionCard/IpVersionOptionCard.component.tsx b/packages/manager/apps/ips/src/components/ipVersionOptionCard/IpVersionOptionCard.component.tsx index d28a2a746197..66d071acd2fd 100644 --- a/packages/manager/apps/ips/src/components/ipVersionOptionCard/IpVersionOptionCard.component.tsx +++ b/packages/manager/apps/ips/src/components/ipVersionOptionCard/IpVersionOptionCard.component.tsx @@ -52,22 +52,24 @@ export const IpVersionOptionCard: React.FC = ({ return ( !isDisabled && onClick?.())} color={ODS_CARD_COLOR.neutral} > - - - + {title} {description && ( {description} @@ -78,7 +80,7 @@ export const IpVersionOptionCard: React.FC = ({
) : ( - + {!isStartingPrice && price === 0 ? ( {t('free_price')} ) : ( @@ -86,7 +88,7 @@ export const IpVersionOptionCard: React.FC = ({ isStartingPrice price={price} suffix={priceSuffix} - shouldOverrideStyle={true} + shouldOverrideStyle /> )} diff --git a/packages/manager/apps/ips/src/data/api/get/ipEdgeFirewall.ts b/packages/manager/apps/ips/src/data/api/get/ipEdgeFirewall.ts index 5bcb15e315a8..56b44e1ec776 100644 --- a/packages/manager/apps/ips/src/data/api/get/ipEdgeFirewall.ts +++ b/packages/manager/apps/ips/src/data/api/get/ipEdgeFirewall.ts @@ -131,11 +131,23 @@ export const postIpEdgeNetworkFirewallRule = async ({ ip: string; ipOnFirewall: string; action: 'permit' | 'deny'; - destinationPort: number | null; + sourcePort?: number | null; + destinationPort?: number | null; + sourcePortRange?: { + from: number; + to: number; + }; + destinationPortRange?: { + from: number; + to: number; + }; + l3PacketLength?: { + from: number; + to: number; + }; protocol: IpEdgeFirewallProtocol; sequence: number; source: string | null; - sourcePort: number | null; tcpOption: { fragments?: boolean | null; option?: 'established' | 'syn' | null; diff --git a/packages/manager/apps/ips/src/data/hooks/catalog/useAdditionalIpsRegions.ts b/packages/manager/apps/ips/src/data/hooks/catalog/useAdditionalIpsRegions.ts index e7291858e095..c512fae369c5 100644 --- a/packages/manager/apps/ips/src/data/hooks/catalog/useAdditionalIpsRegions.ts +++ b/packages/manager/apps/ips/src/data/hooks/catalog/useAdditionalIpsRegions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines-per-function */ import React from 'react'; import { ShellContext } from '@ovh-ux/manager-react-shell-client'; diff --git a/packages/manager/apps/ips/src/data/hooks/catalog/useCatalogLowestPrice.ts b/packages/manager/apps/ips/src/data/hooks/catalog/useCatalogLowestPrice.ts index 1eb48a4a6a15..200bed0fe875 100644 --- a/packages/manager/apps/ips/src/data/hooks/catalog/useCatalogLowestPrice.ts +++ b/packages/manager/apps/ips/src/data/hooks/catalog/useCatalogLowestPrice.ts @@ -12,7 +12,7 @@ const isIpv6Plan = (plan: CatalogIpPlan | PccCatalogPlan) => const getLowestPlanPrice = (lowestPrice: number, plan: CatalogIpPlan) => { const currentPrice = plan?.details?.pricings?.default[0]?.priceInUcents; - if (!currentPrice) { + if (!currentPrice && currentPrice !== 0) { return lowestPrice; } diff --git a/packages/manager/apps/ips/src/data/hooks/catalog/useGetCatalog.ts b/packages/manager/apps/ips/src/data/hooks/catalog/useGetCatalog.ts index c38ee97e1ad9..fba018032b9c 100644 --- a/packages/manager/apps/ips/src/data/hooks/catalog/useGetCatalog.ts +++ b/packages/manager/apps/ips/src/data/hooks/catalog/useGetCatalog.ts @@ -133,7 +133,7 @@ export const useGetCatalog = () => { } } - return (plan as unknown) as Plan; + return plan as unknown as Plan; }, }); }; diff --git a/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useCreateIpEdgeFirewallRule.helpers.spec.ts b/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useCreateIpEdgeFirewallRule.helpers.spec.ts index efac6c73e2d2..b56e7198b74a 100644 --- a/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useCreateIpEdgeFirewallRule.helpers.spec.ts +++ b/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useCreateIpEdgeFirewallRule.helpers.spec.ts @@ -7,6 +7,8 @@ import { hasDestinationPortLowerThanSourcePortError, hasPortRangeError, hasSourceError, + formatPortValue, + formatPortRangeValue, } from './useCreateIpEdgeFirewallRule'; describe('useCreateIpEdgeFirewallRule helpers', () => { @@ -16,10 +18,7 @@ describe('useCreateIpEdgeFirewallRule helpers', () => { expect(hasPortRangeError('')).toBe(false); }); - it('returns true for values below min and above max', () => { - expect( - hasPortRangeError(String(IP_EDGE_FIREWALL_PORT_MIN - 1)), - ).toBeTruthy(); + it('returns true for values above max', () => { expect( hasPortRangeError(String(IP_EDGE_FIREWALL_PORT_MAX + 1)), ).toBeTruthy(); @@ -34,6 +33,15 @@ describe('useCreateIpEdgeFirewallRule helpers', () => { it('handles non-numeric strings gracefully', () => { expect(hasPortRangeError('abc')).toBe(false); }); + + it('handles port ranges', () => { + expect(hasPortRangeError('80-90')).toBe(false); + expect(hasPortRangeError('0-65535')).toBe(false); + expect(hasPortRangeError('65536-70000')).toBe(true); + expect(hasPortRangeError('-100')).toBe(false); + expect(hasPortRangeError('100-')).toBe(false); + expect(hasPortRangeError('200-100')).toBe(true); + }); }); describe('hasDestinationPortLowerThanSourcePortError', () => { @@ -90,6 +98,21 @@ describe('useCreateIpEdgeFirewallRule helpers', () => { }), ).toBeFalsy(); }); + + it('returns false when source or destination are port ranges', () => { + expect( + hasDestinationPortLowerThanSourcePortError({ + source: '80-90', + destination: '85', + }), + ).toBeFalsy(); + expect( + hasDestinationPortLowerThanSourcePortError({ + source: '80', + destination: '70-80', + }), + ).toBeFalsy(); + }); }); describe('hasSourceError', () => { @@ -138,4 +161,58 @@ describe('useCreateIpEdgeFirewallRule helpers', () => { expect(formatSourceValue('10.0.0.0/24')).toBe('10.0.0.0/24'); }); }); + + describe('formatPortValue', () => { + it('returns undefined for undefined, empty or ranges', () => { + expect(formatPortValue(undefined)).toBeUndefined(); + expect(formatPortValue('')).toBeUndefined(); + expect(formatPortValue('80-90')).toBeUndefined(); + }); + + it('parses numeric ports', () => { + expect(formatPortValue('80')).toBe(80); + expect(formatPortValue('0')).toBe(0); + expect(formatPortValue(String(IP_EDGE_FIREWALL_PORT_MAX))).toBe( + IP_EDGE_FIREWALL_PORT_MAX, + ); + }); + + it('returns NaN for non-numeric strings', () => { + const v = formatPortValue('abc'); + expect(Number.isNaN(v)).toBe(true); + }); + }); + + describe('formatPortRangeValue', () => { + it('returns undefined for undefined or non-range values', () => { + expect(formatPortRangeValue(undefined)).toBeUndefined(); + expect(formatPortRangeValue('80')).toBeUndefined(); + }); + + it('returns MAX or MIN for missing start or end', () => { + expect(formatPortRangeValue('-100')).toEqual({ + from: IP_EDGE_FIREWALL_PORT_MIN, + to: 100, + }); + expect(formatPortRangeValue('100-')).toEqual({ + from: 100, + to: IP_EDGE_FIREWALL_PORT_MAX, + }); + }); + + it('returns api format for port range otherwise', () => { + expect(formatPortRangeValue('80-90')).toEqual({ + from: 80, + to: 90, + }); + expect(formatPortRangeValue('200-100')).toEqual({ + from: 200, + to: 100, + }); + expect(formatPortRangeValue('a-b')).toEqual({ + from: NaN, + to: NaN, + }); + }); + }); }); diff --git a/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useCreateIpEdgeFirewallRule.ts b/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useCreateIpEdgeFirewallRule.ts index be931ea5852f..8f3c3fd4b71e 100644 --- a/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useCreateIpEdgeFirewallRule.ts +++ b/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useCreateIpEdgeFirewallRule.ts @@ -23,11 +23,34 @@ export const hasPortRangeError = (port?: string) => { return false; } - const portNumber = parseInt(port, 10); + if (!port.includes('-')) { + const portNumber = parseInt(port, 10); + + return ( + portNumber < IP_EDGE_FIREWALL_PORT_MIN || + portNumber > IP_EDGE_FIREWALL_PORT_MAX + ); + } + + const parts = port.split('-'); + + if (parts.length !== 2) { + return true; + } + + const [startPort, endPort] = parts; + + const startPortNumber = startPort + ? parseInt(startPort, 10) + : IP_EDGE_FIREWALL_PORT_MIN; + const endPortNumber = endPort + ? parseInt(endPort, 10) + : IP_EDGE_FIREWALL_PORT_MAX; return ( - portNumber < IP_EDGE_FIREWALL_PORT_MIN || - portNumber > IP_EDGE_FIREWALL_PORT_MAX + startPortNumber < IP_EDGE_FIREWALL_PORT_MIN || + endPortNumber > IP_EDGE_FIREWALL_PORT_MAX || + startPortNumber > endPortNumber ); }; @@ -38,7 +61,12 @@ export const hasDestinationPortLowerThanSourcePortError = ({ source?: string; destination?: string; }) => { - if (!source || !destination) { + if ( + !source || + !destination || + source.includes('-') || + destination.includes('-') + ) { return false; } @@ -70,6 +98,27 @@ export const formatSourceValue = (source?: string) => { return !source?.includes('/') ? `${source}/32` : source; }; +export const formatPortValue = (port?: string) => { + if (!port || port.includes('-')) { + return undefined; + } + + return parseInt(port, 10); +}; + +export const formatPortRangeValue = (port?: string) => { + if (!port || !port.includes('-')) { + return undefined; + } + + const [startPort, endPort] = port.split('-'); + + return { + from: startPort ? parseInt(startPort, 10) : IP_EDGE_FIREWALL_PORT_MIN, + to: endPort ? parseInt(endPort, 10) : IP_EDGE_FIREWALL_PORT_MAX, + }; +}; + export type CreateFirewallRuleParams = { action: 'permit' | 'deny'; protocol: IpEdgeFirewallProtocol; @@ -187,11 +236,18 @@ export const useCreateIpEdgeNetworkFirewallRule = ({ ipOnFirewall, action, protocol, - destinationPort: - destinationPort && !fragments ? parseInt(destinationPort, 10) : null, + destinationPort: fragments + ? undefined + : formatPortValue(destinationPort), + sourcePort: fragments ? undefined : formatPortValue(sourcePort), + destinationPortRange: fragments + ? undefined + : formatPortRangeValue(destinationPort), + sourcePortRange: fragments + ? undefined + : formatPortRangeValue(sourcePort), sequence, source: formatSourceValue(source), - sourcePort: sourcePort && !fragments ? parseInt(sourcePort, 10) : null, tcpOption: fragments || tcpOption ? { diff --git a/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useIpEdgeFirewallRules.ts b/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useIpEdgeFirewallRules.ts index b7e9c6573aa3..b2d552241e2b 100644 --- a/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useIpEdgeFirewallRules.ts +++ b/packages/manager/apps/ips/src/data/hooks/ip/edge-firewall/useIpEdgeFirewallRules.ts @@ -69,9 +69,8 @@ export const useIpEdgeNetworkFirewallRules = ({ }), ); queryClient.removeQueries({ - queryKey: getIpEdgeNetworkFirewallRuleDetailsQueryKey( - ruleParams, - ), + queryKey: + getIpEdgeNetworkFirewallRuleDetailsQueryKey(ruleParams), exact: true, }); return undefined; diff --git a/packages/manager/apps/ips/src/data/hooks/ip/useMoveIpService.ts b/packages/manager/apps/ips/src/data/hooks/ip/useMoveIpService.ts index f8eeff96255d..562e88757dce 100644 --- a/packages/manager/apps/ips/src/data/hooks/ip/useMoveIpService.ts +++ b/packages/manager/apps/ips/src/data/hooks/ip/useMoveIpService.ts @@ -278,16 +278,13 @@ export function useMoveIpService({ onMoveIpSuccess?: () => void; }) { const queryClient = useQueryClient(); - const { - hasOnGoingVrackMoveTasks, - isVrackTasksLoading, - vrackTasksError, - } = useVrackMoveTasks({ - ip, - serviceName, - enabled: - !!serviceName && getTypeByServiceName(serviceName) === IpTypeEnum.VRACK, - }); + const { hasOnGoingVrackMoveTasks, isVrackTasksLoading, vrackTasksError } = + useVrackMoveTasks({ + ip, + serviceName, + enabled: + !!serviceName && getTypeByServiceName(serviceName) === IpTypeEnum.VRACK, + }); const { hasOnGoingMoveIpTask, isTasksLoading, taskError } = useMoveIpTasks({ ip, }); diff --git a/packages/manager/apps/ips/src/data/hooks/useGetProductServices.ts b/packages/manager/apps/ips/src/data/hooks/useGetProductServices.ts index 04b79f7f73db..e5ac258f2680 100644 --- a/packages/manager/apps/ips/src/data/hooks/useGetProductServices.ts +++ b/packages/manager/apps/ips/src/data/hooks/useGetProductServices.ts @@ -53,22 +53,27 @@ export const useGetProductServices = ( data: result?.data?.data || [], category: productPathsAndCategories[index]?.category, })) - .reduce((acc, { category, data: serviceData }) => { - acc[category] = serviceData.map((service) => { - const iam = service.iam as { id: string; urn: string } | undefined; - const id = iam?.id ?? undefined; - const extractedServiceName = iam?.urn.split(':').pop() || ''; + .reduce( + (acc, { category, data: serviceData }) => { + acc[category] = serviceData.map((service) => { + const iam = service.iam as + | { id: string; urn: string } + | undefined; + const id = iam?.id ?? undefined; + const extractedServiceName = iam?.urn.split(':').pop() || ''; - return { - category, - id, - serviceName: extractedServiceName, - displayName: - getDisplayName(category, service) || extractedServiceName, - }; - }); - return acc; - }, {} as Record); + return { + category, + id, + serviceName: extractedServiceName, + displayName: + getDisplayName(category, service) || extractedServiceName, + }; + }); + return acc; + }, + {} as Record, + ); return { data, isLoading: results.some((result) => result.isLoading), diff --git a/packages/manager/apps/ips/src/data/hooks/useIpv6Availability.ts b/packages/manager/apps/ips/src/data/hooks/useIpv6Availability.ts index cb2728203c88..6103586bf7c8 100644 --- a/packages/manager/apps/ips/src/data/hooks/useIpv6Availability.ts +++ b/packages/manager/apps/ips/src/data/hooks/useIpv6Availability.ts @@ -63,7 +63,7 @@ export const useIpv6Availability = ({ IcebergFetchResultV6, ApiError >({ - queryKey: ['additionalips', 'ipv6'], + queryKey: ['additionalips', ipVersion], queryFn: () => getIcebergIpList({ isAdditionalIp: true, diff --git a/packages/manager/apps/ips/src/pages/actions/aggregate/aggregate.page.tsx b/packages/manager/apps/ips/src/pages/actions/aggregate/aggregate.page.tsx index 01626d54ce15..fa59b7343a43 100644 --- a/packages/manager/apps/ips/src/pages/actions/aggregate/aggregate.page.tsx +++ b/packages/manager/apps/ips/src/pages/actions/aggregate/aggregate.page.tsx @@ -35,9 +35,8 @@ import { export default function AggregateModal() { const queryClient = useQueryClient(); - const { setOnGoingCreatedIps, setOnGoingAggregatedIps } = useContext( - ListingContext, - ); + const { setOnGoingCreatedIps, setOnGoingAggregatedIps } = + useContext(ListingContext); const { parentId } = useParams(); const { ipGroup } = ipFormatter(fromIdToIp(parentId)); const { t } = useTranslation([ diff --git a/packages/manager/apps/ips/src/pages/actions/importIpFromSys/components/Step2.tsx b/packages/manager/apps/ips/src/pages/actions/importIpFromSys/components/Step2.tsx index 31d54ac66262..b4176862fe08 100644 --- a/packages/manager/apps/ips/src/pages/actions/importIpFromSys/components/Step2.tsx +++ b/packages/manager/apps/ips/src/pages/actions/importIpFromSys/components/Step2.tsx @@ -49,15 +49,12 @@ export default function Step2({ error: serverListError, } = useGetProductService(PRODUCT_PATHS_AND_CATEGORIES.dedicated); - const { - isIpMigrationAvailable, - isLoading, - error, - } = useDedicatedServerIpMigrationAvailableDurations({ - ip, - token, - serviceName: destinationServer, - }); + const { isIpMigrationAvailable, isLoading, error } = + useDedicatedServerIpMigrationAvailableDurations({ + ip, + token, + serviceName: destinationServer, + }); return ( <> @@ -107,14 +104,17 @@ export default function Step2({ })} )} - {!isIpMigrationAvailable && !isLoading && !error && destinationServer && ( - - {t('step2UnavailableMigrationMessage', { - serverName: destinationServer, - ip, - })} - - )} + {!isIpMigrationAvailable && + !isLoading && + !error && + destinationServer && ( + + {t('step2UnavailableMigrationMessage', { + serverName: destinationServer, + ip, + })} + + )}
diff --git a/packages/manager/apps/web-domains/src/domain/components/Host/HostDrawer.spec.tsx b/packages/manager/apps/web-domains/src/domain/components/Host/HostDrawer.spec.tsx index 7f08a93f8f2a..f1c3b9220917 100644 --- a/packages/manager/apps/web-domains/src/domain/components/Host/HostDrawer.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/components/Host/HostDrawer.spec.tsx @@ -1,4 +1,8 @@ -import '@/common/setupTests'; +import { + mockAddSuccess, + mockAddError, + mockClearNotifications, +} from '@/common/setupTests'; import { render, screen, @@ -15,23 +19,6 @@ vi.mock('@/domain/hooks/data/query', () => ({ useUpdateDomainResource: vi.fn(), })); -const addSuccess = vi.fn(); -const addError = vi.fn(); -const clearNotifications = vi.fn(); - -vi.mock('@ovh-ux/manager-react-components', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@ovh-ux/manager-react-components'); - - return { - ...actual, - useNotifications: () => ({ - addSuccess, - addError, - clearNotifications, - }), - }; -}); - describe('HostDrawer', () => { const updateDomain = vi.fn(); @@ -61,9 +48,21 @@ describe('HostDrawer', () => { const getPrimaryButton = () => { const drawer = screen.getByTestId('drawer'); - const button = drawer.querySelector( - 'ods-button[variant="default"]', - ) as HTMLElement | null; + // Try to find by data-testid first, then fallback to querySelector + let button = drawer.querySelector('[data-testid="drawer-primary-button"]') as HTMLElement | null; + if (!button) { + button = drawer.querySelector('ods-button[variant="default"]') as HTMLElement | null; + } + if (!button) { + // Last resort: find any button that's not the secondary button + const buttons = drawer.querySelectorAll('button, ods-button'); + button = Array.from(buttons).find((btn) => { + const el = btn as HTMLElement; + return el.getAttribute('variant') === 'default' || + el.getAttribute('data-variant') === 'default' || + (buttons.length > 1 && btn !== buttons[0]); + }) as HTMLElement | null; + } if (!button) { throw new Error('Primary button not found'); @@ -138,12 +137,12 @@ describe('HostDrawer', () => { }); callbacks.onSuccess(); - expect(addSuccess).toHaveBeenCalledWith( + expect(mockAddSuccess).toHaveBeenCalledWith( 'domain_tab_hosts_drawer_add_success_message', ); callbacks.onSettled(); - expect(clearNotifications).toHaveBeenCalled(); + expect(mockClearNotifications).toHaveBeenCalled(); }); it('calls addError when updateDomain fails in Add mode', async () => { @@ -178,7 +177,7 @@ describe('HostDrawer', () => { ]; callbacks.onError('Some error'); - expect(addError).toHaveBeenCalledWith( + expect(mockAddError).toHaveBeenCalledWith( 'domain_tab_hosts_drawer_add_error_message', ); }); @@ -190,13 +189,29 @@ describe('HostDrawer', () => { screen.getByText('domain_tab_hosts_drawer_modify_title'), ).toBeInTheDocument(); - const [hostInput, ipsInput] = screen.getAllByRole('textbox'); - const expectedHostPrefix = sampleHost.host.split('.')[0]; const expectedIps = String(sampleHost.ips); - expect(hostInput).toHaveValue(expectedHostPrefix); - expect(ipsInput).toHaveValue(expectedIps); + // Get inputs by their name attributes + const hostInput = document.querySelector('input[name="host"]') as HTMLInputElement; + const ipsInput = document.querySelector('input[name="ips"]') as HTMLInputElement; + + expect(hostInput).toBeInTheDocument(); + expect(ipsInput).toBeInTheDocument(); + + // Wait for react-hook-form to synchronize values with 'values' prop + await waitFor( + () => { + expect(hostInput.value).toBe(expectedHostPrefix); + }, + { timeout: 3000 }, + ); + await waitFor( + () => { + expect(ipsInput.value).toBe(expectedIps); + }, + { timeout: 3000 }, + ); fireEvent.change(hostInput, { target: { value: 'updated-host' } }); fireEvent.change(ipsInput, { target: { value: '9.9.9.9' } }); @@ -239,11 +254,11 @@ describe('HostDrawer', () => { }); callbacks.onSuccess(); - expect(addSuccess).toHaveBeenCalledWith( + expect(mockAddSuccess).toHaveBeenCalledWith( 'domain_tab_hosts_drawer_modify_success_message', ); callbacks.onSettled(); - expect(clearNotifications).toHaveBeenCalled(); + expect(mockClearNotifications).toHaveBeenCalled(); }); }); diff --git a/packages/manager/apps/web-domains/src/domain/components/Host/HostForm.spec.tsx b/packages/manager/apps/web-domains/src/domain/components/Host/HostForm.spec.tsx index a04e5bc4c29f..65d5e773fc42 100644 --- a/packages/manager/apps/web-domains/src/domain/components/Host/HostForm.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/components/Host/HostForm.spec.tsx @@ -5,6 +5,7 @@ import { screen, fireEvent, waitFor, + act, } from '@/common/utils/test.provider'; import { vi } from 'vitest'; import { FormProvider, useForm } from 'react-hook-form'; @@ -16,24 +17,34 @@ import { DrawerActionEnum } from '@/common/enum/common.enum'; const mocks = vi.hoisted(() => ({ getHostnameErrorMessageMock: vi.fn(), getIpsErrorMessageMock: vi.fn(), - tranformIpsStringToArrayMock: vi.fn((value: string) => - value + tranformIpsStringToArrayMock: vi.fn((value: string | undefined) => { + if (!value) return []; + return value .split(',') .map((v) => v.trim()) - .filter((v) => v !== ''), - ), + .filter((v) => v !== ''); + }), })); vi.mock('@/domain/utils/utils', () => ({ makeHostValidators: vi.fn((hostsTargetSpec, serviceName, t) => ({ - custom: (value: string) => + noDuplicate: (value: string) => + mocks.getHostnameErrorMessageMock(value, serviceName, hostsTargetSpec) || + true, + validSyntax: (value: string) => mocks.getHostnameErrorMessageMock(value, serviceName, hostsTargetSpec) || true, })), - makeIpsValidator: vi.fn((ipsSupported) => (value: string) => { - const ipsArray = mocks.tranformIpsStringToArrayMock(value); - return mocks.getIpsErrorMessageMock(ipsArray, ipsSupported) || true; - }), + makeIpsValidator: vi.fn((ipsSupported) => ({ + noDuplicate: (value: string) => { + const ipsArray = mocks.tranformIpsStringToArrayMock(value); + return mocks.getIpsErrorMessageMock(ipsArray, ipsSupported) || true; + }, + validIps: (value: string) => { + const ipsArray = mocks.tranformIpsStringToArrayMock(value); + return mocks.getIpsErrorMessageMock(ipsArray, ipsSupported) || true; + }, + })), transformIpsStringToArray: mocks.tranformIpsStringToArrayMock, })); @@ -44,8 +55,9 @@ type FormValues = { function FormWrapper({ children }: PropsWithChildren) { const methods = useForm({ - mode: 'onChange', + mode: 'all', defaultValues: { host: '', ips: '' }, + criteriaMode: 'all', }); return {children}; @@ -74,12 +86,13 @@ const { describe('HostForm', () => { beforeEach(() => { vi.clearAllMocks(); - tranformIpsStringToArrayMock.mockImplementation((value: string) => - value + tranformIpsStringToArrayMock.mockImplementation((value: string | undefined) => { + if (!value) return []; + return value .split(',') .map((v) => v.trim()) - .filter((v) => v !== ''), - ); + .filter((v) => v !== ''); + }); }); it('renders host and ips inputs', () => { @@ -99,9 +112,12 @@ describe('HostForm', () => { renderHostForm(); const [hostInput] = screen.getAllByRole('textbox'); + const hostField = hostInput.closest('[data-ods="form-field"]'); - fireEvent.change(hostInput, { target: { value: 'bad-host' } }); - fireEvent.blur(hostInput); + await act(async () => { + fireEvent.change(hostInput, { target: { value: 'bad-host' } }); + fireEvent.blur(hostInput); + }); await waitFor(() => { expect(getHostnameErrorMessageMock).toHaveBeenCalledWith( @@ -111,11 +127,13 @@ describe('HostForm', () => { ); }); - expect( - await screen.findByText( + await waitFor(() => { + const errorElement = hostField?.querySelector('[data-ods="form-field-error"]'); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent( 'domain_tab_hosts_drawer_add_invalid_host_format', - ), - ).toBeInTheDocument(); + ); + }); }); it('does not validate hostname in Modify mode and input is readonly', () => { @@ -134,9 +152,12 @@ describe('HostForm', () => { renderHostForm(); const [, ipsInput] = screen.getAllByRole('textbox'); + const ipsField = ipsInput.closest('[data-ods="form-field"]'); - fireEvent.change(ipsInput, { target: { value: 'not-an-ip' } }); - fireEvent.blur(ipsInput); + await act(async () => { + fireEvent.change(ipsInput, { target: { value: 'not-an-ip' } }); + fireEvent.blur(ipsInput); + }); await waitFor(() => { expect(getIpsErrorMessageMock).toHaveBeenCalledWith( @@ -145,9 +166,13 @@ describe('HostForm', () => { ); }); - expect( - await screen.findByText('domain_tab_hosts_drawer_add_invalid_ips_format'), - ).toBeInTheDocument(); + await waitFor(() => { + const errorElement = ipsField?.querySelector('[data-ods="form-field-error"]'); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent( + 'domain_tab_hosts_drawer_add_invalid_ips_format', + ); + }); }); it('shows warning message when ips list is not empty', async () => { @@ -155,7 +180,9 @@ describe('HostForm', () => { const [, ipsInput] = screen.getAllByRole('textbox'); - fireEvent.change(ipsInput, { target: { value: '1.2.3.4' } }); + await act(async () => { + fireEvent.change(ipsInput, { target: { value: '1.2.3.4' } }); + }); await waitFor(() => { expect( diff --git a/packages/manager/apps/web-domains/src/domain/components/Host/HostForm.tsx b/packages/manager/apps/web-domains/src/domain/components/Host/HostForm.tsx index 1a88a284d392..07afa97a1832 100644 --- a/packages/manager/apps/web-domains/src/domain/components/Host/HostForm.tsx +++ b/packages/manager/apps/web-domains/src/domain/components/Host/HostForm.tsx @@ -75,7 +75,7 @@ export default function HostForm({ /> - {t(`${errors.host?.message.toString()}`)} + {errors.host?.message ? t(String(errors.host.message)) : null} - {errors.ips?.message.toString()} + {errors.ips?.message ? String(errors.ips.message) : null} diff --git a/packages/manager/apps/web-domains/src/domain/components/LinkToOngoingOperations/LinkToOngoingOperations.spec.tsx b/packages/manager/apps/web-domains/src/domain/components/LinkToOngoingOperations/LinkToOngoingOperations.spec.tsx index 202c0f17520f..7be944530802 100644 --- a/packages/manager/apps/web-domains/src/domain/components/LinkToOngoingOperations/LinkToOngoingOperations.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/components/LinkToOngoingOperations/LinkToOngoingOperations.spec.tsx @@ -1,25 +1,8 @@ import '@/common/setupTests'; -import React from 'react'; import { render, screen } from '@/common/utils/test.provider'; -import { vi } from 'vitest'; import LinkToOngoingOperations from './LinkToOngoingOperations'; import { TOngoingOperationTarget } from '@/domain/constants/serviceDetail'; -vi.mock('@ovhcloud/ods-react', () => ({ - Link: ({ href, children }: { href: string; children: React.ReactNode }) => ( - {children} - ), - Icon: ({ name }: { name: string }) => , - BADGE_COLOR: { - success: 'success', - critical: 'critical', - warning: 'warning', - information: 'information', - }, - ICON_NAME: { - arrowRight: 'arrow-right', - }, -})); describe('LinkToOngoingOperations', () => { it('should display the link with the correct URL', () => { @@ -42,7 +25,8 @@ describe('LinkToOngoingOperations', () => { it('should display the arrow-right icon', () => { render(); - expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument(); + const icon = document.querySelector('[data-ods="icon"]'); + expect(icon).toBeInTheDocument(); }); it('should render the link and icon in the correct order', () => { @@ -50,11 +34,10 @@ describe('LinkToOngoingOperations', () => { const link = screen.getByRole('link'); const children = link.childNodes; + const icon = document.querySelector('[data-ods="icon"]'); expect(children.length).toBeGreaterThanOrEqual(2); - expect(children[children.length - 1]).toBe( - screen.getByTestId('icon-arrow-right'), - ); + expect(children[children.length - 1]).toBe(icon); }); it('should support different target values', () => { @@ -69,7 +52,8 @@ describe('LinkToOngoingOperations', () => { 'href', expect.stringContaining(`/${target}`), ); - expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument(); + const icon = document.querySelector('[data-ods="icon"]'); + expect(icon).toBeInTheDocument(); expect( screen.getByText( 'domain_tab_general_information_banner_link_ongoing_operations', diff --git a/packages/manager/apps/web-domains/src/domain/components/ServiceDetail/ServiceDetailTabs.tsx b/packages/manager/apps/web-domains/src/domain/components/ServiceDetail/ServiceDetailTabs.tsx index 139815f9d9dd..11f74b829607 100644 --- a/packages/manager/apps/web-domains/src/domain/components/ServiceDetail/ServiceDetailTabs.tsx +++ b/packages/manager/apps/web-domains/src/domain/components/ServiceDetail/ServiceDetailTabs.tsx @@ -1,6 +1,11 @@ import { useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { matchPath, useLocation, useNavigate, useParams } from 'react-router-dom'; +import { + matchPath, + useLocation, + useNavigate, + useParams, +} from 'react-router-dom'; import { ShellContext } from '@ovh-ux/manager-react-shell-client'; import { Icon, @@ -14,7 +19,7 @@ import { TooltipContent, TooltipTrigger, } from '@ovhcloud/ods-react'; -import { useNotifications } from '@ovh-ux/manager-react-components'; +import { useFeatureAvailability, useNotifications } from '@ovh-ux/manager-react-components'; import { ServiceDetailTabsProps, legacyTabs, @@ -22,6 +27,7 @@ import { } from '@/domain/constants/serviceDetail'; import { TDomainResource } from '@/domain/types/domainResource'; import DnsConfigurationTab from '@/domain/pages/domainTabs/dns/dnsConfiguration'; +import { useGetEnvironmentData } from '@/common/hooks/environment/data'; interface ServiceDetailsTabsProps { readonly domainResource: TDomainResource; @@ -37,8 +43,20 @@ export default function ServiceDetailsTabs({ const [value, setValue] = useState(''); const navigate = useNavigate(); const { clearNotifications } = useNotifications(); + const { data: availability } = useFeatureAvailability([ + 'web-domains:zone', + ]); + + const { region } = useGetEnvironmentData(); + + const visibleTabs = ServiceDetailTabsProps.filter( + (tab) => tab.id !== 'dynhost' || region === 'EU', + ); const handleValueChange = async (event: TabsValueChangeEvent) => { + if (!availability?.['web-domains:zone']) { + legacyTabs.push('zone'); + } if (legacyTabs.includes(event.value)) { const fetchedUrl = (await shell.navigation?.getURL( 'web', @@ -54,11 +72,8 @@ export default function ServiceDetailsTabs({ useEffect(() => { const tab = - ServiceDetailTabsProps.find((tabName) => - matchPath( - `/domain/:serviceName/${tabName.value}/*`, - location.pathname, - ), + visibleTabs.find((tabName) => + matchPath(`/domain/:serviceName/${tabName.value}/*`, location.pathname), )?.value || DEFAULT_TAB; if (location.pathname) { setValue(tab); @@ -71,7 +86,7 @@ export default function ServiceDetailsTabs({ return ( - {ServiceDetailTabsProps.map((tab) => { + {visibleTabs.map((tab) => { const disabled = tab.rule ? tab.rule(domainResource) : false; return ( { ); await waitFor(() => { - expect(screen.getByText(/contacts/i)).toBeInTheDocument(); + expect( + screen.getByText(/@ovh-ux\/manager-common-translations\/contact:contacts/i), + ).toBeInTheDocument(); expect( screen.getByText( /firstname lastname: domain_tab_general_information_subscription_contact_owner/i, diff --git a/packages/manager/apps/web-domains/src/domain/components/SubscriptionCards/CreationDate.spec.tsx b/packages/manager/apps/web-domains/src/domain/components/SubscriptionCards/CreationDate.spec.tsx index c81b826a1b87..22b4e6ca603f 100644 --- a/packages/manager/apps/web-domains/src/domain/components/SubscriptionCards/CreationDate.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/components/SubscriptionCards/CreationDate.spec.tsx @@ -16,9 +16,8 @@ describe('CreationDate component', () => { { wrapper }, ); - expect( - screen.getByTestId('navigation-action-trigger-action'), - ).toBeInTheDocument(); + const actionButtons = screen.getAllByTestId('navigation-action-trigger-action-popover'); + expect(actionButtons.length).toBeGreaterThan(0); }); it('renders populated state with creation date information', async () => { diff --git a/packages/manager/apps/web-domains/src/domain/components/WebHostingOrder/webHostingOrderComponent.tsx b/packages/manager/apps/web-domains/src/domain/components/WebHostingOrder/webHostingOrderComponent.tsx index f437363f4d9a..0e8dd3019b02 100644 --- a/packages/manager/apps/web-domains/src/domain/components/WebHostingOrder/webHostingOrderComponent.tsx +++ b/packages/manager/apps/web-domains/src/domain/components/WebHostingOrder/webHostingOrderComponent.tsx @@ -2,7 +2,7 @@ import { Suspense, useContext } from 'react'; import { WebHostingComponent } from './webHostingOrderModule'; import { ShellContext } from '@ovh-ux/manager-react-shell-client'; import { useTranslation } from 'react-i18next'; -import Loading from '../Loading/Loading'; +import Loading from '@/common/components/Loading/Loading'; export default function WebHostingOrderComponent() { const { i18n } = useTranslation(); diff --git a/packages/manager/apps/web-domains/src/domain/constants/guideLinks.ts b/packages/manager/apps/web-domains/src/domain/constants/guideLinks.ts index 765d117a288c..0a40fad0d5b5 100644 --- a/packages/manager/apps/web-domains/src/domain/constants/guideLinks.ts +++ b/packages/manager/apps/web-domains/src/domain/constants/guideLinks.ts @@ -9,6 +9,8 @@ export enum GuideNameEnum { TRANSFER_LINK = 'TRANSFER_LINK', ORDER_API_LINK = 'ORDER_API_LINK', FAQ_LINK = 'FAQ_LINK', + DNS_ZONE = 'DNS_ZONE', + DNS_HISTORY = 'DNS_HISTORY', } const helpRoot = 'https://help.ovhcloud.com/csm/'; @@ -32,6 +34,7 @@ export interface Links { PT?: string; QC?: string; SG?: string; + AU?: string; SN?: string; TN?: string; } @@ -39,12 +42,14 @@ export interface Links { export const Languages = { DEFAULT: 'en-ie', ASIA: 'asia', + AU: 'en-au', CA: 'en-ca', DE: 'de', ES: 'es-es', FR: 'fr', GB: 'en-gb', IN: 'en-in', + IE: 'en-ie', IT: 'it', LT: 'es', MA: 'fr', @@ -81,11 +86,13 @@ const guides: Record = { articles: { DEFAULT: 'KB0051745', ASIA: 'KB0039681', + AU: '', CA: 'KB0051746', DE: 'KB0051751', ES: 'KB0051755', FR: 'KB0051754', GB: 'KB0051749', + IE: '', IN: 'KB0069751', IT: 'KB0051762', LT: 'KB0051758', @@ -103,13 +110,15 @@ const guides: Record = { type: 'article', target: '-dns-servers-edit', articles: { - DEFAULT: 'KB0063603', + DEFAULT: 'KB0063603', ASIA: 'KB0063601', + AU: '', CA: 'KB0063613', DE: 'KB0063611', ES: 'KB0063604', FR: 'KB0063455', GB: 'KB0063607', + IE: '', IN: 'KB0069726', IT: 'KB0063610', LT: 'KB0063599', @@ -129,11 +138,13 @@ const guides: Record = { articles: { DEFAULT: 'KB0029780', ASIA: 'KB0042824', + AU: '', CA: 'KB0042827', DE: 'KB0042837', ES: 'KB0042847', FR: 'KB0042839', GB: 'KB0042838', + IE: '', IN: 'KB0067855', IT: 'KB0042846', LT: 'KB0042849', @@ -153,11 +164,13 @@ const guides: Record = { articles: { DEFAULT: 'KB0039746', ASIA: 'KB0051804', + AU: '', CA: 'KB0051807', DE: 'KB0051806', ES: 'KB0051816', FR: 'KB0051808', GB: 'KB0051799', + IE: '', IN: 'KB0069758', IT: 'KB0051809', LT: 'KB0051800', @@ -177,11 +190,13 @@ const guides: Record = { articles: { DEFAULT: 'KB0051562', ASIA: 'KB0039482', + AU: '', CA: 'KB0051558', DE: 'KB0051556', ES: 'KB0051564', FR: 'KB0051566', GB: 'KB0051560', + IE: '', IN: 'KB0069711', IT: 'KB0051570', LT: 'KB0051569', @@ -201,11 +216,13 @@ const guides: Record = { articles: { DEFAULT: 'KB0072951', ASIA: 'KB0072954', + AU: '', CA: 'KB0072950', DE: 'KB0072949', ES: 'KB0072952', FR: 'KB0072959', GB: 'KB0072955', + IE: '', IN: 'KB0072956', IT: 'KB0072960', LT: 'KB0072947', @@ -219,7 +236,60 @@ const guides: Record = { TN: 'KB0072959', }, }, -}; + [GuideNameEnum.DNS_ZONE]: { + type: 'article', + target: '-dns-edit-dns-zone', + articles: { + DEFAULT: 'KB0051682', + ASIA: 'KB0039681', + AU: 'KB0051673', + CA: 'KB0051675', + DE: 'KB0051668', + ES: 'KB0051692', + FR: 'KB0051684', + GB: 'KB0039608', + IE: 'KB0051685', + IN: 'KB0069751', + IT: 'KB0051687', + LT: 'KB0051685', + MA: 'KB0051684', + NL: 'KB0051682', + PL: 'KB0051689', + PT: 'KB0051694', + QC: 'KB0051686', + SG: 'KB0051681', + SN: 'KB0051684', + TN: 'KB0051684', + }, + }, + [GuideNameEnum.DNS_HISTORY]: { + type: 'article', + target: 'fr-dns-zone-history', + articles: { + DEFAULT: 'KB0051682', + ASIA: 'KB0061053', + AU: 'KB0061043', + CA: 'KB0061050', + DE: 'KB0061048', + ES: 'KB0061051', + FR: 'KB0061056', + GB: 'KB0061045', + IE: 'KB0061055', + IN: 'KB0069739', + IT: 'KB0061049', + LT: 'KB0061044', + MA: 'KB0061056', + NL: 'KB0051682', + PL: 'KB0061052', + PT: 'KB0061047', + QC: 'KB0061057', + SG: 'KB0061054', + SN: 'KB0061056', + TN: 'KB0061056', + } as Record, + }, + }; + const buildLinksByLanguage = ( guides: Record, @@ -238,7 +308,7 @@ const buildLinksByLanguage = ( } else { urls[guideKey as GuideNameEnum] = `${helpRoot}${Languages[lang]}${config.target}?id=kb_article_view&sysparm_article=` + - (config.articles[lang] ?? config.articles.DEFAULT); + (config.articles[lang] || config.articles.DEFAULT); } } return [lang, urls]; @@ -249,4 +319,4 @@ export const LINKS_BY_LANGUAGE = buildLinksByLanguage(guides); export const useLinks = (language: Subsidiary): Record => useMemo(() => LINKS_BY_LANGUAGE[language] || LINKS_BY_LANGUAGE.DEFAULT, [ language, - ]); + ]); \ No newline at end of file diff --git a/packages/manager/apps/web-domains/src/domain/constants/serviceDetail.ts b/packages/manager/apps/web-domains/src/domain/constants/serviceDetail.ts index e842a8adefab..33c0daaae9e9 100644 --- a/packages/manager/apps/web-domains/src/domain/constants/serviceDetail.ts +++ b/packages/manager/apps/web-domains/src/domain/constants/serviceDetail.ts @@ -66,7 +66,7 @@ export const ServiceDetailTabsProps: DashboardTabItemProps[] = [ }, ]; -export const legacyTabs = ['zone', 'redirection', 'dynhost']; +export const legacyTabs = [ 'redirection', 'dynhost']; export const changelogLinks: ChangelogLinks = { changelog: diff --git a/packages/manager/apps/web-domains/src/domain/data/api/hosting.ts b/packages/manager/apps/web-domains/src/domain/data/api/hosting.ts index 9305a2f8cdd2..71043336d3e7 100644 --- a/packages/manager/apps/web-domains/src/domain/data/api/hosting.ts +++ b/packages/manager/apps/web-domains/src/domain/data/api/hosting.ts @@ -4,7 +4,7 @@ import { FreeHostingOptions } from '@/domain/components/AssociatedServicesCards/ import { formatConfigurationValue } from '@/domain/utils/utils'; import { TInitialOrderFreeHosting } from '@/domain/types/hosting'; import { TServiceInfo } from '@/common/types/common.types'; -import { FREE_HOSTING_PLAN_CODE } from '@/domain/constants/order'; +import { FREE_HOSTING_PLAN_CODE } from '@/common/constants/order'; export const getAssociatedHosting = async ( serviceName: string, diff --git a/packages/manager/apps/web-domains/src/domain/hooks/data/query.ts b/packages/manager/apps/web-domains/src/domain/hooks/data/query.ts index 1a55ba2ca22a..a40594813204 100644 --- a/packages/manager/apps/web-domains/src/domain/hooks/data/query.ts +++ b/packages/manager/apps/web-domains/src/domain/hooks/data/query.ts @@ -5,10 +5,7 @@ import { useQueries, } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { - OvhSubsidiary, - useNotifications, -} from '@ovh-ux/manager-react-components'; +import { useNotifications } from '@ovh-ux/manager-react-components'; import { Subsidiary } from '@ovh-ux/manager-config'; import { getDomainAnycastOption, @@ -33,8 +30,6 @@ import { getServiceDnssec, } from '@/domain/data/api/domainZone'; import { TDomainZone } from '@/domain/types/domainZone'; -import { order } from '@/domain/types/orderCatalog'; -import { getOrderCatalog } from '@/domain/data/api/order'; import { getDomainContact, getMXPlan, @@ -56,6 +51,8 @@ import { import { FreeHostingOptions } from '@/domain/components/AssociatedServicesCards/Hosting'; import { DnssecStatusEnum } from '@/domain/enum/dnssecStatus.enum'; import { DnsConfigurationTypeEnum } from '@/domain/enum/dnsConfigurationType.enum'; +import { ApiError } from '@ovh-ux/manager-core-api'; +export { useGetOrderCatalogDns } from '@/common/hooks/data/query'; export const useGetDomainResource = (serviceName: string) => { const { data, isLoading, error } = useQuery({ @@ -71,7 +68,6 @@ export const useGetDomainResource = (serviceName: string) => { export const useGetDomainZone = ( serviceName: string, - domainResource: TDomainResource, enabled: boolean = false, ) => { const { data, isLoading, error } = useQuery({ @@ -83,20 +79,7 @@ export const useGetDomainZone = ( return { domainZone: data, isFetchingDomainZone: isLoading, - domainZoneError: error, - }; -}; - -export const useGetOrderCatalogDns = (subsidiary: OvhSubsidiary) => { - const { data, isLoading, error } = useQuery({ - queryKey: ['order', 'catalog', 'dns', subsidiary], - queryFn: () => - getOrderCatalog({ ovhSubsidiary: subsidiary, productName: 'dns' }), - }); - return { - dnsCatalog: data, - isFetchingDnsCatalog: isLoading, - dnsCatalogError: error, + domainZoneError: error as ApiError, }; }; @@ -148,8 +131,8 @@ export const useTerminateAnycastMutation = ( addSuccess( t('domain_dns_tab_terminate_anycast_success', { action: restore - ? t('domain_action_restore') - : t('domain_action_terminate'), + ? t('domain_action_restored') + : t('domain_action_terminated'), }), ); }, diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/anycastOrder.spec.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/anycastOrder.spec.tsx deleted file mode 100644 index 9f216fa39aac..000000000000 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/anycastOrder.spec.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { render } from '@/common/utils/test.provider'; -import { describe, expect, Mock, vi } from 'vitest'; -import { - useGetDomainResource, - useGetDomainZone, -} from '@/domain/hooks/data/query'; -import AnycastOrder from './anycastOrder'; -import { wrapper } from '@/common/utils/test.provider'; -import { serviceInfoDetail } from '@/domain/__mocks__/serviceInfoDetail'; - -vi.mock('@/domain/hooks/data/query', () => ({ - useGetDomainZone: vi.fn(), - useGetDomainResource: vi.fn(), -})); - -vi.mock('@/domain/components/AnycastOrder/AnycastOrder', () => ({ - default: () => ( -
Anycast Order Component
- ), -})); - -describe('Anycast Order Page', () => { - it('Render Anycast order page loading', async () => { - (useGetDomainZone as Mock).mockReturnValue({ - domainZone: {}, - isFetchingDomainZone: true, - }); - (useGetDomainResource as Mock).mockReturnValue({ - domainResource: {}, - isFetchingDomainResource: true, - }); - const { getByTestId, container } = render(, { - wrapper, - }); - expect(getByTestId('listing-page-spinner')).toBeInTheDocument(); - await expect(container).toBeAccessible({ - rules: { - 'heading-order': { enabled: false }, - }, - }); - }); - - it('Render Anycast order page', async () => { - (useGetDomainZone as Mock).mockReturnValue({ - domainZone: {}, - isFetchingDomainZone: false, - }); - (useGetDomainResource as Mock).mockReturnValue({ - domainResource: serviceInfoDetail, - isFetchingDomainResource: false, - }); - const { getByTestId, container } = render(, { - wrapper, - }); - expect(getByTestId('order-component')).toBeInTheDocument(); - await expect(container).toBeAccessible({ - rules: { 'heading-order': { enabled: false } }, - }); - }); -}); - -describe('Anycast Order Page W3C Validation', () => { - it('should have valid html when loading', async () => { - (useGetDomainZone as Mock).mockReturnValue({ - domainZone: {}, - isFetchingDomainZone: true, - }); - (useGetDomainResource as Mock).mockReturnValue({ - domainResource: {}, - isFetchingDomainResource: true, - }); - const { container } = render(, { wrapper }); - const html = container.innerHTML; - - await expect(html).toBeValidHtml(); - }); - - it('should have valid html when loaded', async () => { - (useGetDomainZone as Mock).mockReturnValue({ - domainZone: {}, - isFetchingDomainZone: false, - }); - (useGetDomainResource as Mock).mockReturnValue({ - domainResource: serviceInfoDetail, - isFetchingDomainResource: false, - }); - const { container } = render(, { wrapper }); - const html = container.innerHTML; - - await expect(html).toBeValidHtml(); - }); -}); diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsConfiguration.spec.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsConfiguration.spec.tsx index 43e11cc8db57..17ddf85a6818 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsConfiguration.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsConfiguration.spec.tsx @@ -1,13 +1,11 @@ import '@/common/setupTests'; -import React from 'react'; -import { render, renderHook } from '@/common/utils/test.provider'; +import { render, renderHook, wrapper } from '@/common/utils/test.provider'; import { Mock, vi, expect } from 'vitest'; import { dnsDatagridMock, dnsDatagridMockError, } from '@/domain/__mocks__/dnsDetails'; import { serviceInfoDetail } from '@/domain/__mocks__/serviceInfoDetail'; -import { wrapper } from '@/common/utils/test.provider'; import { computeDnsDetails } from '@/domain/utils/utils'; import { useDomainDnsDatagridColumns } from '@/domain/hooks/domainTabs/useDomainDnsDatagridColumns'; import DnsConfigurationTab from './dnsConfiguration'; diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsModify.spec.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsModify.spec.tsx index 2118e4a47b3d..359814d78d5b 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsModify.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsModify.spec.tsx @@ -17,6 +17,14 @@ vi.mock('@/domain/utils/dnsUtils', () => ({ computeActiveConfiguration: vi.fn(), })); +vi.mock('@/common/components/Loading/Loading', () => ({ + default: () => ( +
+ Loading... +
+ ), +})); + vi.mock('@/domain/components/ModifyNameServer/DnsConfigurationRadio', () => ({ default: () =>
Dns Configuration Radio Component
, })); diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsModify.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsModify.tsx index 8001fa2b8fb6..b0d2543c9550 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsModify.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dns/dnsModify.tsx @@ -23,7 +23,7 @@ import { import { changelogLinks } from '@/domain/constants/serviceDetail'; import { useGenerateUrl } from '@/common/hooks/generateUrl/useGenerateUrl'; import { urls } from '@/domain/routes/routes.constant'; -import Loading from '@/domain/components/Loading/Loading'; +import Loading from '@/common/components/Loading/Loading'; import { useGetDomainResource, useGetDomainZone, @@ -44,7 +44,6 @@ export default function DnsModifyPage() { ); const { domainZone, isFetchingDomainZone } = useGetDomainZone( serviceName, - domainResource, true, ); const context = useContext(ShellContext); diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dsRecords/dsRecordsListing.spec.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dsRecords/dsRecordsListing.spec.tsx index 403b45074d62..c25ab19e1154 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dsRecords/dsRecordsListing.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dsRecords/dsRecordsListing.spec.tsx @@ -47,19 +47,6 @@ vi.mock('@/common/hooks/iam/useGetIAMResource', () => ({ useGetIAMResource: vi.fn(), })); -vi.mock('@ovh-ux/manager-react-components', async () => { - const actual = await vi.importActual< - typeof import('@ovh-ux/manager-react-components') - >('@ovh-ux/manager-react-components'); - - return { - ...actual, - useAuthorizationIam: vi.fn(() => ({ - isPending: false, - isAuthorized: true, - })), - }; -}); describe('DS Records Columns', () => { const setDrawer = vi.fn(); diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dsRecords/dsRecordsListing.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dsRecords/dsRecordsListing.tsx index 1cc9a94d41c0..b23071c45c71 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dsRecords/dsRecordsListing.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/dsRecords/dsRecordsListing.tsx @@ -50,7 +50,7 @@ export default function DsRecordsListing() { serviceName, ); const { data: dnsZoneIAMRessources } = useGetIAMResource( - domainResource.id, + domainResource?.id, 'dnsZone', ); const urn = dnsZoneIAMRessources?.[0]?.urn; @@ -61,13 +61,12 @@ export default function DsRecordsListing() { const isInternalDnsConfiguration = domainResource?.currentState?.dnsConfiguration.configurationType !== - DnsConfigurationTypeEnum.EXTERNAL && + DnsConfigurationTypeEnum.EXTERNAL && domainResource?.currentState?.dnsConfiguration.configurationType !== - DnsConfigurationTypeEnum.MIXED; + DnsConfigurationTypeEnum.MIXED; const { domainZone, isFetchingDomainZone } = useGetDomainZone( serviceName, - domainResource, isInternalDnsConfiguration, ); const [isModalOpen, setIsModalOpen] = useState(false); @@ -94,18 +93,26 @@ export default function DsRecordsListing() { serviceName, }, ); - if (!domainResource.currentState.dnssecConfiguration.dnssecSupported) { - navigate(generalInformationUrl, { replace: true }); - } useEffect(() => { + if ( + domainResource && + !domainResource.currentState.dnssecConfiguration.dnssecSupported + ) { + navigate(generalInformationUrl, { replace: true }); + } + }, [domainResource, generalInformationUrl, navigate]); + + useEffect(() => { + if (!domainResource) return; + const { dsData: dsRecordCurrentState, supportedAlgorithms, - } = domainResource?.currentState.dnssecConfiguration; + } = domainResource.currentState.dnssecConfiguration; const { dsData: dsRecordTargetSpec, - } = domainResource?.targetSpec?.dnssecConfiguration; + } = domainResource.targetSpec?.dnssecConfiguration ?? { dsData: [] }; if (!dsRecordCurrentState.length && !dsRecordTargetSpec.length) { setItems([]); @@ -177,8 +184,8 @@ export default function DsRecordsListing() { }); const { dnssecStatus, isDnssecStatusLoading } = useGetDnssecStatus( - domainResource.currentState, - domainResource.targetSpec, + domainResource?.currentState, + domainResource?.targetSpec, ); let message: JSX.Element = <>; @@ -216,11 +223,11 @@ export default function DsRecordsListing() { action: dnssecStatus === DnssecStatusEnum.ENABLE_IN_PROGRESS ? t( - 'domain_tab_dsrecords_message_information_action_in_progress_activate', - ) + 'domain_tab_dsrecords_message_information_action_in_progress_activate', + ) : t( - 'domain_tab_dsrecords_message_information_action_in_progress_deactivate', - ), + 'domain_tab_dsrecords_message_information_action_in_progress_deactivate', + ), }} components={{ Link: , diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostDelete.spec.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostDelete.spec.tsx index 93f3ab016a51..b2c5238c32a8 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostDelete.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostDelete.spec.tsx @@ -1,55 +1,12 @@ -import '@/common/setupTests'; -import React from 'react'; +import { mockAddSuccess, mockAddError } from '@/common/setupTests'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@/common/utils/test.provider'; +import { render, screen, fireEvent, wrapper } from '@/common/utils/test.provider'; import { useParams, useNavigate } from 'react-router-dom'; import HostDelete from '@/domain/pages/domainTabs/hosts/hostDelete'; -import { wrapper } from '@/common/utils/test.provider'; import { supportedAlgorithms } from '@/domain/constants/dsRecords'; const updateDomain = vi.fn(); const navigate = vi.fn(); -const addSuccess = vi.fn(); -const addError = vi.fn(); - -vi.mock('@ovh-ux/manager-react-components', async () => { - const actual = await vi.importActual('@ovh-ux/manager-react-components'); - - type ModalProps = { - isOpen: boolean; - heading: string; - primaryLabel: string; - secondaryLabel: string; - onPrimaryButtonClick: () => void; - onSecondaryButtonClick: () => void; - children?: React.ReactNode; - }; - - return { - ...actual, - Modal: ({ - isOpen, - heading, - primaryLabel, - secondaryLabel, - onPrimaryButtonClick, - onSecondaryButtonClick, - children, - }: ModalProps) => - isOpen ? ( -
-

{heading}

- - - {children} -
- ) : null, - useNotifications: () => ({ - addSuccess, - addError, - }), - }; -}); const mockTargetSpec = { dnsConfiguration: { @@ -115,7 +72,7 @@ describe('HostDelete', () => { it('calls updateDomain with filtered hosts on delete click', () => { render(, { wrapper }); - fireEvent.click(screen.getByRole('button', { name: /actions:delete/i })); + fireEvent.click(screen.getByTestId('primary-button')); expect(updateDomain).toHaveBeenCalledTimes(1); const call = updateDomain.mock.calls[0][0]; @@ -127,31 +84,31 @@ describe('HostDelete', () => { it('calls addSuccess + navigate on delete success', () => { render(, { wrapper }); - fireEvent.click(screen.getByRole('button', { name: /actions:delete/i })); + fireEvent.click(screen.getByTestId('primary-button')); const { onSuccess, onSettled } = updateDomain.mock.calls[0][1]; onSuccess(); onSettled(); - expect(addSuccess).toHaveBeenCalledTimes(1); + expect(mockAddSuccess).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledWith('/domain/foobar/hosts'); }); it('calls addError on delete error', () => { render(, { wrapper }); - fireEvent.click(screen.getByRole('button', { name: /actions:delete/i })); + fireEvent.click(screen.getByTestId('primary-button')); const { onError, onSettled } = updateDomain.mock.calls[0][1]; onError(); onSettled(); - expect(addError).toHaveBeenCalledTimes(1); + expect(mockAddError).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledWith('/domain/foobar/hosts'); }); it('navigates back on cancel click', () => { render(, { wrapper }); - fireEvent.click(screen.getByRole('button', { name: /actions:cancel/i })); + fireEvent.click(screen.getByTestId('secondary-button')); expect(navigate).toHaveBeenCalledWith('/domain/foobar/hosts'); }); }); diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostsListing.spec.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostsListing.spec.tsx index 2c8dd9db7f0e..f48640623bee 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostsListing.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostsListing.spec.tsx @@ -96,34 +96,36 @@ describe('Host Datagrid', () => { }); describe('Host Datagrid W3C Validation', () => { - // Skipped: ODS input components generate empty aria-describedby attributes, - // causing W3C validation failures. Re-enable once ODS fixes this upstream. - // See: https://github.com/ovh/design-system/issues/XXXX + // ODS input components render empty aria-describedby="" attributes, + // which is invalid per W3C spec. We strip them before validation + // since this is an upstream ODS issue, not our code. + const stripEmptyAriaDescribedby = (html: string) => + html.replaceAll(/\s*aria-describedby=""/g, ''); - it.skip('should have valid html', async () => { + it('should have valid html', async () => { nichandle.auth.account = 'admin-id'; const { container } = render(, { wrapper }); - const html = container.innerHTML; + const html = stripEmptyAriaDescribedby(container.innerHTML); await expect(html).toBeValidHtml(); }); - it.skip('should have valid html with drawer open', async () => { + it('should have valid html with drawer open', async () => { nichandle.auth.account = 'admin-id'; const { getByTestId, container } = render(, { wrapper }); const addButton = getByTestId('addButton'); fireEvent.click(addButton); - const html = container.innerHTML; + const html = stripEmptyAriaDescribedby(container.innerHTML); await expect(html).toBeValidHtml(); }); - it.skip('should have valid html with warning message', async () => { + it('should have valid html with warning message', async () => { nichandle.auth.account = 'adminxxx'; const { container } = render(, { wrapper }); - const html = container.innerHTML; + const html = stripEmptyAriaDescribedby(container.innerHTML); await expect(html).toBeValidHtml(); }); diff --git a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostsListing.tsx b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostsListing.tsx index 4d016097e87d..326283ae4099 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostsListing.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/domainTabs/hosts/hostsListing.tsx @@ -18,7 +18,7 @@ import { useHostsDatagridColumns } from '@/domain/hooks/domainTabs/useHostsDatag import { useGetDomainResource } from '@/domain/hooks/data/query'; import { StatusEnum } from '@/domain/enum/Status.enum'; import HostDrawer from '@/domain/components/Host/HostDrawer'; -import Loading from '@/domain/components/Loading/Loading'; +import Loading from '@/common/components/Loading/Loading'; import { useNichandleInformation } from '@/common/hooks/nichandle/useNichandleInformation'; import { THost } from '@/domain/types/host'; import { useGenerateUrl } from '@/common/hooks/generateUrl/useGenerateUrl'; diff --git a/packages/manager/apps/web-domains/src/domain/pages/service/serviceDetail/serviceDetail.tsx b/packages/manager/apps/web-domains/src/domain/pages/service/serviceDetail/serviceDetail.tsx index 58650fdeeb7e..ba8e39a2323b 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/service/serviceDetail/serviceDetail.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/service/serviceDetail/serviceDetail.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { Outlet, useNavigate, useParams } from 'react-router-dom'; import { @@ -14,7 +14,7 @@ import { } from '@ovh-ux/manager-react-components'; import { useGetDomainResource } from '@/domain/hooks/data/query'; import appConfig from '@/web-domains.config'; -import Loading from '@/domain/components/Loading/Loading'; +import Loading from '@/common/components/Loading/Loading'; import ServiceDetailsTabs from '@/domain/components/ServiceDetail/ServiceDetailTabs'; import { urls } from '@/domain/routes/routes.constant'; import { changelogLinks } from '@/domain/constants/serviceDetail'; @@ -22,7 +22,7 @@ import { ShellContext } from '@ovh-ux/manager-react-shell-client'; import { useLinks } from '@/domain/constants/guideLinks'; export default function ServiceDetail() { - const { t } = useTranslation(['domain', 'web-domains/error']); + const { t } = useTranslation(['domain', 'web-domains/error', 'zone']); const navigate = useNavigate(); const { serviceName } = useParams<{ serviceName: string }>(); const { notifications } = useNotifications(); @@ -37,6 +37,18 @@ export default function ServiceDetail() { target: '_blank', label: t('domain_guide_button_label'), }, + { + id: 2, + href: guideUrls.DNS_ZONE, + target: '_blank', + label: t('zone:zone_page_guide_button_edit_label'), + }, + { + id: 3, + href: guideUrls.DNS_HISTORY, + target: '_blank', + label: t('zone:zone_page_guide_button_history_label'), + }, ]; const header: HeadersProps = { @@ -49,7 +61,7 @@ export default function ServiceDetail() { domainResourceError, isFetchingDomainResource, domainResource, - } = useGetDomainResource(serviceName); + } = useGetDomainResource(serviceName!); if (isFetchingDomainResource) { return ; @@ -76,12 +88,12 @@ export default function ServiceDetail() { /> } header={header} - tabs={} + tabs={} backLinkLabel={t('domain_back_to_service_list')} onClickReturn={() => { navigate(urls.domainRoot, { replace: true }); }} - message={notifications.length > 0 ? : null} + message={notifications.length > 0 ? : undefined} > diff --git a/packages/manager/apps/web-domains/src/domain/pages/service/serviceList/serviceList.spec.tsx b/packages/manager/apps/web-domains/src/domain/pages/service/serviceList/serviceList.spec.tsx index e5048ea52e5d..8cedc240961f 100644 --- a/packages/manager/apps/web-domains/src/domain/pages/service/serviceList/serviceList.spec.tsx +++ b/packages/manager/apps/web-domains/src/domain/pages/service/serviceList/serviceList.spec.tsx @@ -1,8 +1,8 @@ -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { ReactElement } from 'react'; +import { render } from '@/common/utils/test.provider'; +import '@/common/setupTests'; import ServiceList from './serviceList'; -import { MemoryRouter } from 'react-router-dom'; vi.mock('@/common/components/DomainsList/domainsList', () => ({ default: vi.fn(() =>
DomainsList
), @@ -12,16 +12,76 @@ vi.mock('@/common/components/DomainsList/guideButton', () => ({ default: vi.fn(() => ), })); -vi.mock('@ovh-ux/manager-react-components', () => ({ - ChangelogButton: vi.fn(() => ( - - )), -})); +vi.mock('@ovh-ux/manager-react-components', async () => { + const actual = await vi.importActual('@ovh-ux/manager-react-components'); + return { + ...actual, + ErrorBanner: ({ error }: any) => ( +
{error?.data?.message}
+ ), + ChangelogButton: () => ( +
Changelog
+ ), + }; +}); -vi.mock('@ovh-ux/muk', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('@ovh-ux/muk', async () => { + const actual = await vi.importActual('@ovh-ux/muk'); return { ...actual, + BaseLayout: ({ children, header, message }: any) => ( +
+ {header?.title &&
{header.title}
} + {header?.changelogButton && ( +
{header.changelogButton}
+ )} + {header?.guideMenu && header.guideMenu} + {header?.headerButton && header.headerButton} + {message} + {children} +
+ ), + Datagrid: ({ data, isLoading, topbar, rowSelection }: any) => { + const [selected, setSelected] = (rowSelection + ? [rowSelection.rowSelection, rowSelection.setRowSelection] + : [undefined, undefined]) as any; + if (isLoading) { + return ( +
+
Loading...
+ {topbar} +
+ ); + } + return ( +
+ {topbar} +
+ {(data ?? []).map((row: any) => ( +
+ {setSelected && ( + { + const next = { ...selected }; + if (next[row.id]) { + delete next[row.id]; + } else { + next[row.id] = true; + } + setSelected(next); + }} + /> + )} + {row.id} +
+ ))} +
+
+ ); + }, useNotifications: vi.fn(() => ({ notifications: [], })), @@ -31,42 +91,49 @@ vi.mock('@ovh-ux/muk', async (importOriginal) => { }; }); -describe('ServiceList', () => { - const renderWithRouter = (component: ReactElement) => { - return render({component}); - }; +vi.mock('./guideButton', () => ({ + default: () =>
Guide Button
, +})); +vi.mock('@/domain/hooks/useDomainDatagridColumns', () => ({ + useDomainDatagridColumns: vi.fn(() => [ + { id: 'domain', header: 'Domain' }, + { id: 'state', header: 'State' }, + ]), +})); + +describe('ServiceList', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should render without crashing', () => { - const { container } = renderWithRouter(); + const { container } = render(); expect(container).toBeInTheDocument(); }); it('should display the title', () => { - renderWithRouter(); + render(); expect(screen.getByText('title')).toBeInTheDocument(); }); it('should render the DomainsList component', () => { - renderWithRouter(); + render(); expect(screen.getByTestId('domains-list')).toBeInTheDocument(); }); it('should render the guide button', () => { - renderWithRouter(); + render(); expect(screen.getByTestId('guide-button')).toBeInTheDocument(); }); it('should render the changelog button', () => { - renderWithRouter(); + render(); expect(screen.getByTestId('changelog-button')).toBeInTheDocument(); }); it('should not display notifications when notifications array is empty', () => { - renderWithRouter(); + render(); expect(screen.queryByTestId('notifications')).not.toBeInTheDocument(); }); @@ -76,7 +143,7 @@ describe('ServiceList', () => { notifications: [{ id: '1', message: 'Test notification' }] as any, }); - renderWithRouter(); + render(); expect(screen.getByTestId('notifications')).toBeInTheDocument(); }); }); diff --git a/packages/manager/apps/web-domains/src/domain/routes/routes.constant.ts b/packages/manager/apps/web-domains/src/domain/routes/routes.constant.ts index e4bbb5b3cac2..1972ee9d0200 100644 --- a/packages/manager/apps/web-domains/src/domain/routes/routes.constant.ts +++ b/packages/manager/apps/web-domains/src/domain/routes/routes.constant.ts @@ -17,4 +17,21 @@ export const urls = { domainTabContactManagement: '/domain/:serviceName/contact-management', domainTabContactManagementEdit: '/domain/:serviceName/contact-management/:holderId/edit', + // zone routes + zoneRoot: '/domain/:serviceName/zone/zone', + //activate zone + zoneActivate: '/domain/:serviceName/zone/activate', + //entry + zoneAddEntry: '/domain/:serviceName/zone/add-entry', + zoneModifyEntry: '/domain/:serviceName/zone/modify-entry', + zoneDeleteEntry: '/domain/:serviceName/zone/delete-entry', + // modify + zoneModifyTextualRecord: '/domain/:serviceName/zone/modify-textual-record', + zoneModifyTtlRecord: '/domain/:serviceName/zone/modify-ttl', + // history + zoneHistory: '/domain/:serviceName/zone/history', + // reset + zoneReset: '/domain/:serviceName/zone/reset', + // delete + zoneDelete: '/domain/:serviceName/zone/delete', }; diff --git a/packages/manager/apps/web-domains/src/domain/routes/routes.tsx b/packages/manager/apps/web-domains/src/domain/routes/routes.tsx index 1989ef1a8d4c..6b9c96c9a1da 100644 --- a/packages/manager/apps/web-domains/src/domain/routes/routes.tsx +++ b/packages/manager/apps/web-domains/src/domain/routes/routes.tsx @@ -3,6 +3,7 @@ import { Navigate, Outlet, Route, useParams } from 'react-router-dom'; import { PageType } from '@ovh-ux/manager-react-shell-client'; import { ErrorBoundary } from '@ovh-ux/manager-react-components'; import { urls } from '@/domain/routes/routes.constant'; +import { urls as zoneUrls } from '@/zone/routes/routes.constant'; import WebHostingOrderPage from '../pages/domainTabs/generalInformations/webhostingOrder'; const LayoutPage = React.lazy(() => import('@/domain/pages/layout')); @@ -18,8 +19,8 @@ const OnboardingPage = React.lazy(() => import('@/domain/pages/onboarding/onboarding'), ); -const AnycastOrderPage = React.lazy(() => - import('@/domain/pages/domainTabs/dns/anycastOrder'), +const DnsOrderPage = React.lazy(() => + import('@/common/pages/DnsOrder/DnsOrder.page'), ); const DnsModifyPage = React.lazy(() => @@ -49,12 +50,24 @@ const ContactEditPage = React.lazy(() => const DsRecordListingPage = React.lazy(() => import('@/domain/pages/domainTabs/dsRecords/dsRecordsListing'), ); +// zone routes and pages +const ZoneLayout = React.lazy(() => import('@/zone/pages/Layout')); +const ZonePage = React.lazy(() => import('@/zone/pages/zone/Zone.page')); +const HistoryPage = React.lazy(() => + import('@/zone/pages/zone/history/History.page'), +); +const CompareZonesPage = React.lazy(() => + import('@/zone/pages/zone/compare/CompareZones.page'), +); +const ModifyTextualRecordPage = React.lazy(() => + import('@/zone/pages/zone/modify/ModifyTextualRecord.page'), +); function RedirectToDefaultTab() { const { serviceName } = useParams<{ serviceName: string }>(); return ( ); @@ -103,7 +116,9 @@ export default ( path={urls.domainTabInformation} Component={GeneralInformationsPage} /> - + + + @@ -124,12 +139,19 @@ export default ( Component={ContactEditPage} /> - + + + + + { ).toBe(true); }); }); + + describe('.be domain — readonly constraints (real API shape)', () => { + // These tests use the exact constraint shapes returned by the API for .be domains. + // The readonly constraint for firstName uses conditions.and with two items: + // 1. legalForm must be "individual" + // 2. firstName must already have a value (required = non-empty) + + const firstNameReadonlyConstraint: TConfigurationRuleConstraint = { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'firstName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + fields: { + label: 'legalForm', + constraints: [{ operator: OPERATORS.EQ, value: 'individual' }], + }, + }, + }; + + const lastNameReadonlyConstraint: TConfigurationRuleConstraint = { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'lastName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + fields: { + label: 'legalForm', + constraints: [{ operator: OPERATORS.EQ, value: 'individual' }], + }, + }, + }; + + // The email constraint from .be API has NO readonly operator — only required + a + // condition block without operator (which must NOT be treated as readonly). + const emailConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'email', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + }, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + it('firstName should be readonly for individual with existing value', () => { + const formValues: ContactEditFormValues = { + legalForm: 'individual', + firstName: 'Jean', + }; + const contactInfo = { legalForm: 'individual', firstName: 'Jean' }; + expect( + isConstraintSatisfied(firstNameReadonlyConstraint, formValues, contactInfo, 'firstName'), + ).toBe(true); + }); + + it('firstName should NOT be readonly for corporation', () => { + const formValues: ContactEditFormValues = { + legalForm: 'corporation', + firstName: 'Jean', + }; + const contactInfo = { legalForm: 'corporation', firstName: 'Jean' }; + expect( + isConstraintSatisfied(firstNameReadonlyConstraint, formValues, contactInfo, 'firstName'), + ).toBe(false); + }); + + it('firstName should NOT be readonly for individual when firstName is empty', () => { + const formValues: ContactEditFormValues = { + legalForm: 'individual', + firstName: '', + }; + const contactInfo: Record = { legalForm: 'individual' }; + expect( + isConstraintSatisfied(firstNameReadonlyConstraint, formValues, contactInfo, 'firstName'), + ).toBe(false); + }); + + it('lastName should be readonly for individual with existing value', () => { + const formValues: ContactEditFormValues = { + legalForm: 'individual', + lastName: 'Dupont', + }; + const contactInfo = { legalForm: 'individual', lastName: 'Dupont' }; + expect( + isConstraintSatisfied(lastNameReadonlyConstraint, formValues, contactInfo, 'lastName'), + ).toBe(true); + }); + + it('lastName should NOT be readonly for corporation', () => { + const formValues: ContactEditFormValues = { + legalForm: 'corporation', + lastName: 'Dupont', + }; + const contactInfo = { legalForm: 'corporation', lastName: 'Dupont' }; + expect( + isConstraintSatisfied(lastNameReadonlyConstraint, formValues, contactInfo, 'lastName'), + ).toBe(false); + }); + + it('email should NOT have a readonly constraint (findMatchingConstraint returns undefined)', () => { + const formValues: ContactEditFormValues = { + legalForm: 'individual', + email: 'test@ovh.com', + }; + const contactInfo = { legalForm: 'individual', email: 'test@ovh.com' }; + expect( + findMatchingConstraint(emailConstraints, OPERATORS.READONLY, formValues, contactInfo, 'email'), + ).toBeUndefined(); + }); + + it('findMatchingConstraint should find readonly for firstName (individual + existing)', () => { + const constraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + firstNameReadonlyConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + const formValues: ContactEditFormValues = { + legalForm: 'individual', + firstName: 'Jean', + }; + const contactInfo = { legalForm: 'individual', firstName: 'Jean' }; + expect( + findMatchingConstraint(constraints, OPERATORS.READONLY, formValues, contactInfo, 'firstName'), + ).toBeDefined(); + }); + + it('findMatchingConstraint should NOT find readonly for firstName (corporation)', () => { + const constraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + firstNameReadonlyConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + const formValues: ContactEditFormValues = { + legalForm: 'corporation', + firstName: 'Jean', + }; + const contactInfo = { legalForm: 'corporation', firstName: 'Jean' }; + expect( + findMatchingConstraint(constraints, OPERATORS.READONLY, formValues, contactInfo, 'firstName'), + ).toBeUndefined(); + }); + }); + + describe('.fr domain — full configurationRules validation (real API shape)', () => { + // Exact constraint shapes from the .fr OWNER_CONTACT configurationRules API response. + + // --- firstName: required(condition: legalForm != corporation), readonly(condition: AND), maxlength --- + const firstNameRequiredCondition: TConfigurationRuleConstraint = { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'corporation' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint; + + const firstNameReadonlyCondition = { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'firstName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + constraints: [], + }, + } as unknown as TConfigurationRuleConstraint; + + const firstNameAllConstraints: TConfigurationRuleConstraint[] = [ + firstNameRequiredCondition, + firstNameReadonlyCondition, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- lastName: required(condition: legalForm != corporation), readonly(condition: AND), maxlength --- + const lastNameRequiredCondition: TConfigurationRuleConstraint = { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'corporation' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint; + + const lastNameReadonlyCondition = { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'lastName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + constraints: [], + }, + } as unknown as TConfigurationRuleConstraint; + + const lastNameAllConstraints: TConfigurationRuleConstraint[] = [ + lastNameRequiredCondition, + lastNameReadonlyCondition, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- legalForm: contains, required, readonly(condition: value in list), maxlength --- + const legalFormReadonlyCondition: TConfigurationRuleConstraint = { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint; + + const legalFormAllConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { operator: OPERATORS.REQUIRED }, + legalFormReadonlyCondition, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- organisationName: required(condition: legalForm != individual), readonly(condition: has value), maxlength --- + const orgNameRequiredCondition: TConfigurationRuleConstraint = { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint; + + const orgNameReadonlyCondition: TConfigurationRuleConstraint = { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'organisationName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint; + + const orgNameAllConstraints: TConfigurationRuleConstraint[] = [ + orgNameRequiredCondition, + orgNameReadonlyCondition, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- email: required (unconditional), maxlength --- + const emailAllConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- address.zip: required(condition: country NOT IN exclusion list), maxlength --- + const zipRequiredCondition: TConfigurationRuleConstraint = { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'address.country', + constraints: [ + { operator: OPERATORS.NOTCONTAINS, values: ['IE', 'AZ', 'DJ', 'LA', 'CI', 'AN', 'HK', 'BO', 'PA', 'HN', 'NI', 'SV', 'CO'] }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint; + + const zipAllConstraints: TConfigurationRuleConstraint[] = [ + zipRequiredCondition, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- address.country: contains (EU list), required, maxlength --- + const countryContainsConstraint: TConfigurationRuleConstraint = { + operator: OPERATORS.CONTAINS, + values: ['AT', 'AX', 'BE', 'BG', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GF', 'GI', 'GP', 'GR', 'HR', 'HU', 'IE', 'IS', 'IT', 'LT', 'LI', 'LU', 'LV', 'MT', 'MQ', 'NC', 'NL', 'NO', 'PF', 'PL', 'PM', 'PT', 'RE', 'RO', 'SE', 'SI', 'SK', 'TF', 'WF', 'YT'], + }; + + const countryAllConstraints: TConfigurationRuleConstraint[] = [ + countryContainsConstraint, + { operator: OPERATORS.REQUIRED }, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + describe('firstName conditional REQUIRED (legalForm != corporation)', () => { + it('should be required for individual', () => { + const formValues: ContactEditFormValues = { legalForm: 'individual' }; + const contactInfo = { legalForm: 'individual' }; + expect( + findMatchingConstraint(firstNameAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'firstName'), + ).toBeDefined(); + }); + + it('should NOT be required for corporation', () => { + const formValues: ContactEditFormValues = { legalForm: 'corporation' }; + const contactInfo = { legalForm: 'corporation' }; + expect( + findMatchingConstraint(firstNameAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'firstName'), + ).toBeUndefined(); + }); + + it('should be required for association', () => { + const formValues: ContactEditFormValues = { legalForm: 'association' }; + const contactInfo = { legalForm: 'association' }; + expect( + findMatchingConstraint(firstNameAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'firstName'), + ).toBeDefined(); + }); + }); + + describe('lastName conditional REQUIRED (legalForm != corporation)', () => { + it('should be required for individual', () => { + const formValues: ContactEditFormValues = { legalForm: 'individual' }; + const contactInfo = { legalForm: 'individual' }; + expect( + findMatchingConstraint(lastNameAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'lastName'), + ).toBeDefined(); + }); + + it('should NOT be required for corporation', () => { + const formValues: ContactEditFormValues = { legalForm: 'corporation' }; + const contactInfo = { legalForm: 'corporation' }; + expect( + findMatchingConstraint(lastNameAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'lastName'), + ).toBeUndefined(); + }); + }); + + describe('legalForm readonly (value in contains list)', () => { + it('should be readonly when legalForm is individual', () => { + const formValues: ContactEditFormValues = { legalForm: 'individual' }; + const contactInfo = { legalForm: 'individual' }; + expect( + findMatchingConstraint(legalFormAllConstraints, OPERATORS.READONLY, formValues, contactInfo, 'legalForm'), + ).toBeDefined(); + }); + + it('should be readonly when legalForm is corporation', () => { + const formValues: ContactEditFormValues = { legalForm: 'corporation' }; + const contactInfo = { legalForm: 'corporation' }; + expect( + findMatchingConstraint(legalFormAllConstraints, OPERATORS.READONLY, formValues, contactInfo, 'legalForm'), + ).toBeDefined(); + }); + + it('should be readonly when legalForm is association', () => { + const formValues: ContactEditFormValues = { legalForm: 'association' }; + const contactInfo = { legalForm: 'association' }; + expect( + findMatchingConstraint(legalFormAllConstraints, OPERATORS.READONLY, formValues, contactInfo, 'legalForm'), + ).toBeDefined(); + }); + + it('should NOT be readonly when legalForm is empty', () => { + const formValues: ContactEditFormValues = { legalForm: '' }; + const contactInfo: Record = {}; + expect( + findMatchingConstraint(legalFormAllConstraints, OPERATORS.READONLY, formValues, contactInfo, 'legalForm'), + ).toBeUndefined(); + }); + }); + + describe('organisationName conditional REQUIRED (legalForm != individual)', () => { + it('should be required for corporation', () => { + const formValues: ContactEditFormValues = { legalForm: 'corporation' }; + const contactInfo = { legalForm: 'corporation' }; + expect( + findMatchingConstraint(orgNameAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'organisationName'), + ).toBeDefined(); + }); + + it('should be required for association', () => { + const formValues: ContactEditFormValues = { legalForm: 'association' }; + const contactInfo = { legalForm: 'association' }; + expect( + findMatchingConstraint(orgNameAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'organisationName'), + ).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const formValues: ContactEditFormValues = { legalForm: 'individual' }; + const contactInfo = { legalForm: 'individual' }; + expect( + findMatchingConstraint(orgNameAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'organisationName'), + ).toBeUndefined(); + }); + }); + + describe('organisationName readonly (has existing value)', () => { + it('should be readonly when organisationName has a value', () => { + const formValues: ContactEditFormValues = { organisationName: 'ACME Corp' }; + const contactInfo = { organisationName: 'ACME Corp' }; + expect( + findMatchingConstraint(orgNameAllConstraints, OPERATORS.READONLY, formValues, contactInfo, 'organisationName'), + ).toBeDefined(); + }); + + it('should NOT be readonly when organisationName is empty', () => { + const formValues: ContactEditFormValues = { organisationName: '' }; + const contactInfo: Record = {}; + expect( + findMatchingConstraint(orgNameAllConstraints, OPERATORS.READONLY, formValues, contactInfo, 'organisationName'), + ).toBeUndefined(); + }); + }); + + describe('email — always required, never readonly', () => { + it('should always be required', () => { + const formValues: ContactEditFormValues = { email: 'test@ovh.com' }; + const contactInfo = { email: 'test@ovh.com' }; + expect( + findMatchingConstraint(emailAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'email'), + ).toBeDefined(); + }); + + it('should NOT have a readonly constraint', () => { + const formValues: ContactEditFormValues = { email: 'test@ovh.com' }; + const contactInfo = { email: 'test@ovh.com' }; + expect( + findMatchingConstraint(emailAllConstraints, OPERATORS.READONLY, formValues, contactInfo, 'email'), + ).toBeUndefined(); + }); + }); + + describe('address.zip conditional REQUIRED (country NOTCONTAINS exclusion list)', () => { + it('should be required when country is BE (not in exclusion list)', () => { + const formValues: ContactEditFormValues = { 'address.country': 'BE' }; + const contactInfo = { address: { country: 'BE' } }; + expect( + findMatchingConstraint(zipAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'address.zip'), + ).toBeDefined(); + }); + + it('should be required when country is FR', () => { + const formValues: ContactEditFormValues = { 'address.country': 'FR' }; + const contactInfo = { address: { country: 'FR' } }; + expect( + findMatchingConstraint(zipAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'address.zip'), + ).toBeDefined(); + }); + + it('should NOT be required when country is IE (in exclusion list)', () => { + const formValues: ContactEditFormValues = { 'address.country': 'IE' }; + const contactInfo = { address: { country: 'IE' } }; + expect( + findMatchingConstraint(zipAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'address.zip'), + ).toBeUndefined(); + }); + + it('should NOT be required when country is HK (in exclusion list)', () => { + const formValues: ContactEditFormValues = { 'address.country': 'HK' }; + const contactInfo = { address: { country: 'HK' } }; + expect( + findMatchingConstraint(zipAllConstraints, OPERATORS.REQUIRED, formValues, contactInfo, 'address.zip'), + ).toBeUndefined(); + }); + }); + + describe('address.country CONTAINS (EU/EEA list)', () => { + it('should find CONTAINS constraint (unconditional)', () => { + const formValues: ContactEditFormValues = { 'address.country': 'BE' }; + const contactInfo = { address: { country: 'BE' } }; + const match = findMatchingConstraint(countryAllConstraints, OPERATORS.CONTAINS, formValues, contactInfo, 'address.country'); + expect(match).toBeDefined(); + expect(match?.values).toContain('BE'); + expect(match?.values).toContain('FR'); + }); + + it('should have values list that includes EU/EEA countries', () => { + const formValues: ContactEditFormValues = { 'address.country': 'FR' }; + const contactInfo = { address: { country: 'FR' } }; + const match = findMatchingConstraint(countryAllConstraints, OPERATORS.CONTAINS, formValues, contactInfo, 'address.country'); + expect(match?.values).toContain('AT'); + expect(match?.values).toContain('DE'); + expect(match?.values).toContain('IT'); + expect(match?.values).toContain('NL'); + }); + + it('should have values list that does NOT include non-EU countries', () => { + const formValues: ContactEditFormValues = { 'address.country': 'US' }; + const contactInfo = { address: { country: 'US' } }; + const match = findMatchingConstraint(countryAllConstraints, OPERATORS.CONTAINS, formValues, contactInfo, 'address.country'); + // The constraint is found (unconditional), but US is not in the allowed values + expect(match).toBeDefined(); + expect(match?.values).not.toContain('US'); + expect(match?.values).not.toContain('JP'); + }); + }); + }); + + describe('.fi domain — full configurationRules validation (real API shape)', () => { + // Exact constraint shapes from the .fi OWNER_CONTACT configurationRules API response. + // Key .fi differences vs .fr/.be: + // - firstName/lastName REQUIRED only for individual (eq), NOT for corporation/association + // - firstName/lastName have NO READONLY constraint + // - legalForm READONLY only for individual + // - legalForm conditional CONTAINS restricts to [association, corporation, other] for non-individual + // - nationality READONLY when FI, NE condition prevents changing TO FI + // - nationalIdentificationNumber REQUIRED when individual + FI nationality (AND) + // - birthDay REQUIRED when individual + non-FI nationality (AND) + // - companyNationalIdentificationNumber/vat/organisationType REQUIRED for non-individual + // - vat/companyNationalIdentificationNumber/nationalIdentificationNumber/birthDay READONLY when have existing value + + // --- firstName: required(condition: legalForm == individual), maxlength --- + const fiFirstNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- lastName: required(condition: legalForm == individual), maxlength --- + const fiLastNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- legalForm: required, contains (all), conditional contains (non-individual only), readonly (individual), maxlength --- + const fiLegalFormConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { + operator: OPERATORS.CONTAINS, + values: ['association', 'corporation', 'other'], + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- nationalIdentificationNumber: readonly(notempty), required(AND: individual + FI), maxlength --- + const fiNatIdConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'nationalIdentificationNumber', + constraints: [{ operator: OPERATORS.NOTEMPTY }], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + and: [ + { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.NOTEMPTY }, + ], + }, + { + label: 'nationality', + constraints: [ + { operator: OPERATORS.EQ, value: 'FI' }, + { operator: OPERATORS.NOTEMPTY }, + ], + }, + ], + constraints: [], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- birthDay: readonly(notempty), required(AND: individual + nationality != FI), maxlength --- + const fiBirthDayConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'birthDay', + constraints: [{ operator: OPERATORS.NOTEMPTY }], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + and: [ + { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + { + label: 'nationality', + constraints: [ + { operator: OPERATORS.NE, value: 'FI' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + ], + constraints: [], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '10' }, + ]; + + // --- nationality: contains, ne FI (conditional), readonly (FI), required --- + const fiNationalityConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.CONTAINS, values: ['AD', 'AE', 'AF', 'FI', 'FR', 'DE', 'US'] }, + { + operator: OPERATORS.NE, + value: 'FI', + conditions: { + fields: { + label: 'nationality', + constraints: [ + { operator: OPERATORS.NE, value: 'FI' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'nationality', + constraints: [{ operator: OPERATORS.EQ, value: 'FI' }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.REQUIRED }, + ]; + + // --- companyNationalIdentificationNumber: readonly(notempty), required(ne individual), maxlength --- + const fiCompanyNatIdConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'companyNationalIdentificationNumber', + constraints: [{ operator: OPERATORS.NOTEMPTY }], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- vat: readonly(notempty), required(ne individual), maxlength --- + const fiVatConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'vat', + constraints: [{ operator: OPERATORS.NOTEMPTY }], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- organisationType: contains(conditional ne individual), required(ne individual), maxlength --- + const fiOrgTypeConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.CONTAINS, + values: ['Company', 'Public_corporation', 'Foundation', 'Political_party', 'Municipality', 'State', 'Association'], + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- organisationName: required(ne individual), maxlength --- + const fiOrgNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- email: required (unconditional), maxlength --- + const fiEmailConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + describe('firstName — required only for individual, never readonly', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(fiFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'firstName')).toBeDefined(); + }); + + it('should NOT be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(fiFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'firstName')).toBeUndefined(); + }); + + it('should NOT be required for association', () => { + const fv: ContactEditFormValues = { legalForm: 'association' }; + expect(findMatchingConstraint(fiFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'association' }, 'firstName')).toBeUndefined(); + }); + + it('should have NO readonly constraint', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', firstName: 'Matti' }; + expect(findMatchingConstraint(fiFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', firstName: 'Matti' }, 'firstName')).toBeUndefined(); + }); + }); + + describe('lastName — required only for individual, never readonly', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(fiLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'lastName')).toBeDefined(); + }); + + it('should NOT be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(fiLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'lastName')).toBeUndefined(); + }); + + it('should have NO readonly constraint', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', lastName: 'Virtanen' }; + expect(findMatchingConstraint(fiLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', lastName: 'Virtanen' }, 'lastName')).toBeUndefined(); + }); + }); + + describe('legalForm — readonly for individual, conditional CONTAINS for non-individual', () => { + it('should be readonly when legalForm is individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(fiLegalFormConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'legalForm')).toBeDefined(); + }); + + it('should NOT be readonly when legalForm is corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(fiLegalFormConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation' }, 'legalForm')).toBeUndefined(); + }); + + it('should find conditional CONTAINS [association,corporation,other] for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + const match = findMatchingConstraint(fiLegalFormConstraints, OPERATORS.CONTAINS, fv, { legalForm: 'corporation' }, 'legalForm'); + // Two CONTAINS exist; the first is unconditional. Both should match for corporation. + expect(match).toBeDefined(); + }); + + it('should find unconditional CONTAINS that includes individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + const match = findMatchingConstraint(fiLegalFormConstraints, OPERATORS.CONTAINS, fv, { legalForm: 'individual' }, 'legalForm'); + expect(match).toBeDefined(); + expect(match?.values).toContain('individual'); + }); + }); + + describe('nationalIdentificationNumber — required for individual+FI, readonly when has value', () => { + it('should be required for individual with FI nationality', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', nationality: 'FI' }; + expect(findMatchingConstraint(fiNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual', nationality: 'FI' }, 'nationalIdentificationNumber')).toBeDefined(); + }); + + it('should NOT be required for individual with FR nationality', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', nationality: 'FR' }; + expect(findMatchingConstraint(fiNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual', nationality: 'FR' }, 'nationalIdentificationNumber')).toBeUndefined(); + }); + + it('should NOT be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', nationality: 'FI' }; + expect(findMatchingConstraint(fiNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation', nationality: 'FI' }, 'nationalIdentificationNumber')).toBeUndefined(); + }); + + it('should be readonly when nationalIdentificationNumber has a value', () => { + const fv: ContactEditFormValues = { nationalIdentificationNumber: '010180-1234' }; + expect(findMatchingConstraint(fiNatIdConstraints, OPERATORS.READONLY, fv, { nationalIdentificationNumber: '010180-1234' }, 'nationalIdentificationNumber')).toBeDefined(); + }); + + it('should NOT be readonly when nationalIdentificationNumber is empty', () => { + const fv: ContactEditFormValues = { nationalIdentificationNumber: '' }; + expect(findMatchingConstraint(fiNatIdConstraints, OPERATORS.READONLY, fv, {}, 'nationalIdentificationNumber')).toBeUndefined(); + }); + }); + + describe('birthDay — required for individual+non-FI, readonly when has value', () => { + it('should be required for individual with FR nationality', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', nationality: 'FR' }; + expect(findMatchingConstraint(fiBirthDayConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual', nationality: 'FR' }, 'birthDay')).toBeDefined(); + }); + + it('should NOT be required for individual with FI nationality', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', nationality: 'FI' }; + expect(findMatchingConstraint(fiBirthDayConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual', nationality: 'FI' }, 'birthDay')).toBeUndefined(); + }); + + it('should NOT be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', nationality: 'FR' }; + expect(findMatchingConstraint(fiBirthDayConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation', nationality: 'FR' }, 'birthDay')).toBeUndefined(); + }); + + it('should be readonly when birthDay has a value', () => { + const fv: ContactEditFormValues = { birthDay: '1990-01-15' }; + expect(findMatchingConstraint(fiBirthDayConstraints, OPERATORS.READONLY, fv, { birthDay: '1990-01-15' }, 'birthDay')).toBeDefined(); + }); + + it('should NOT be readonly when birthDay is empty', () => { + const fv: ContactEditFormValues = { birthDay: '' }; + expect(findMatchingConstraint(fiBirthDayConstraints, OPERATORS.READONLY, fv, {}, 'birthDay')).toBeUndefined(); + }); + }); + + describe('nationality — readonly when FI, editable otherwise', () => { + it('should be readonly when nationality is FI', () => { + const fv: ContactEditFormValues = { nationality: 'FI' }; + expect(findMatchingConstraint(fiNationalityConstraints, OPERATORS.READONLY, fv, { nationality: 'FI' }, 'nationality')).toBeDefined(); + }); + + it('should NOT be readonly when nationality is FR', () => { + const fv: ContactEditFormValues = { nationality: 'FR' }; + expect(findMatchingConstraint(fiNationalityConstraints, OPERATORS.READONLY, fv, { nationality: 'FR' }, 'nationality')).toBeUndefined(); + }); + }); + + describe('companyNationalIdentificationNumber — required for non-individual, readonly when has value', () => { + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(fiCompanyNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'companyNationalIdentificationNumber')).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(fiCompanyNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'companyNationalIdentificationNumber')).toBeUndefined(); + }); + + it('should be readonly when has existing value', () => { + const fv: ContactEditFormValues = { companyNationalIdentificationNumber: 'FI12345678' }; + expect(findMatchingConstraint(fiCompanyNatIdConstraints, OPERATORS.READONLY, fv, { companyNationalIdentificationNumber: 'FI12345678' }, 'companyNationalIdentificationNumber')).toBeDefined(); + }); + + it('should NOT be readonly when empty', () => { + const fv: ContactEditFormValues = { companyNationalIdentificationNumber: '' }; + expect(findMatchingConstraint(fiCompanyNatIdConstraints, OPERATORS.READONLY, fv, {}, 'companyNationalIdentificationNumber')).toBeUndefined(); + }); + }); + + describe('vat — required for non-individual, readonly when has value', () => { + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(fiVatConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'vat')).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(fiVatConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'vat')).toBeUndefined(); + }); + + it('should be readonly when has existing value', () => { + const fv: ContactEditFormValues = { vat: 'FI12345678' }; + expect(findMatchingConstraint(fiVatConstraints, OPERATORS.READONLY, fv, { vat: 'FI12345678' }, 'vat')).toBeDefined(); + }); + + it('should NOT be readonly when empty', () => { + const fv: ContactEditFormValues = { vat: '' }; + expect(findMatchingConstraint(fiVatConstraints, OPERATORS.READONLY, fv, {}, 'vat')).toBeUndefined(); + }); + }); + + describe('organisationType — required and has CONTAINS only for non-individual', () => { + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(fiOrgTypeConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'organisationType')).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(fiOrgTypeConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'organisationType')).toBeUndefined(); + }); + + it('should find CONTAINS for corporation (condition met)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + const match = findMatchingConstraint(fiOrgTypeConstraints, OPERATORS.CONTAINS, fv, { legalForm: 'corporation' }, 'organisationType'); + expect(match).toBeDefined(); + expect(match?.values).toContain('Company'); + expect(match?.values).toContain('Association'); + }); + + it('should NOT find CONTAINS for individual (condition not met)', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(fiOrgTypeConstraints, OPERATORS.CONTAINS, fv, { legalForm: 'individual' }, 'organisationType')).toBeUndefined(); + }); + }); + + describe('organisationName — required for non-individual, never readonly', () => { + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(fiOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(fiOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'organisationName')).toBeUndefined(); + }); + + it('should have NO readonly constraint', () => { + const fv: ContactEditFormValues = { organisationName: 'Nokia Oyj' }; + expect(findMatchingConstraint(fiOrgNameConstraints, OPERATORS.READONLY, fv, { organisationName: 'Nokia Oyj' }, 'organisationName')).toBeUndefined(); + }); + }); + + describe('email — always required, never readonly', () => { + it('should always be required', () => { + expect(findMatchingConstraint(fiEmailConstraints, OPERATORS.REQUIRED, {}, {}, 'email')).toBeDefined(); + }); + + it('should have NO readonly constraint', () => { + const fv: ContactEditFormValues = { email: 'test@fi.com' }; + expect(findMatchingConstraint(fiEmailConstraints, OPERATORS.READONLY, fv, { email: 'test@fi.com' }, 'email')).toBeUndefined(); + }); + }); + }); + + describe('.es domain — full configurationRules validation (real API shape)', () => { + // Exact constraint shapes from the .es OWNER_CONTACT configurationRules API response. + // Key .es differences: + // - vat REQUIRED with AND (legalForm != individual AND address.country != ES) + // - companyNationalIdentificationNumber REQUIRED with AND (legalForm != individual AND address.country == ES) + // - nationalIdentificationNumber REQUIRED for individual (simple) + // - firstName/lastName REQUIRED for individual (eq), READONLY with AND (individual + has value) + // - organisationName REQUIRED for non-individual, READONLY when has value (required condition) + + // --- vat: required(AND: ne individual + country ne ES), maxlength --- + const esVatConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + and: [ + { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + { + label: 'address.country', + constraints: [ + { operator: OPERATORS.NE, value: 'ES' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + ], + constraints: [], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- companyNationalIdentificationNumber: required(AND: ne individual + country eq ES), maxlength --- + const esCompanyNatIdConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + and: [ + { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + { + label: 'address.country', + constraints: [ + { operator: OPERATORS.EQ, value: 'ES' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + ], + constraints: [], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- nationalIdentificationNumber: required(eq individual), maxlength --- + const esNatIdConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- firstName: required(eq individual), readonly(AND: individual + has value), maxlength --- + const esFirstNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'firstName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + constraints: [], + }, + } as unknown as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- lastName: required(eq individual), readonly(AND: individual + has value), maxlength --- + const esLastNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'lastName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + constraints: [], + }, + } as unknown as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- organisationName: required(ne individual), readonly(has value via required condition), maxlength --- + const esOrgNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'organisationName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- email: required (unconditional), maxlength --- + const esEmailConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + describe('vat — required for non-individual AND country != ES', () => { + it('should be required for corporation in FR', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', 'address.country': 'FR' }; + expect(findMatchingConstraint(esVatConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation', address: { country: 'FR' } }, 'vat')).toBeDefined(); + }); + + it('should NOT be required for corporation in ES (country condition fails)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', 'address.country': 'ES' }; + expect(findMatchingConstraint(esVatConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation', address: { country: 'ES' } }, 'vat')).toBeUndefined(); + }); + + it('should NOT be required for individual in FR', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', 'address.country': 'FR' }; + expect(findMatchingConstraint(esVatConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual', address: { country: 'FR' } }, 'vat')).toBeUndefined(); + }); + + it('should NOT be required for individual in ES', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', 'address.country': 'ES' }; + expect(findMatchingConstraint(esVatConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual', address: { country: 'ES' } }, 'vat')).toBeUndefined(); + }); + + it('should be required for association in DE', () => { + const fv: ContactEditFormValues = { legalForm: 'association', 'address.country': 'DE' }; + expect(findMatchingConstraint(esVatConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'association', address: { country: 'DE' } }, 'vat')).toBeDefined(); + }); + }); + + describe('companyNationalIdentificationNumber — required for non-individual AND country == ES', () => { + it('should be required for corporation in ES', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', 'address.country': 'ES' }; + expect(findMatchingConstraint(esCompanyNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation', address: { country: 'ES' } }, 'companyNationalIdentificationNumber')).toBeDefined(); + }); + + it('should NOT be required for corporation in FR (country condition fails)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', 'address.country': 'FR' }; + expect(findMatchingConstraint(esCompanyNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation', address: { country: 'FR' } }, 'companyNationalIdentificationNumber')).toBeUndefined(); + }); + + it('should NOT be required for individual in ES', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', 'address.country': 'ES' }; + expect(findMatchingConstraint(esCompanyNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual', address: { country: 'ES' } }, 'companyNationalIdentificationNumber')).toBeUndefined(); + }); + + it('should be required for association in ES', () => { + const fv: ContactEditFormValues = { legalForm: 'association', 'address.country': 'ES' }; + expect(findMatchingConstraint(esCompanyNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'association', address: { country: 'ES' } }, 'companyNationalIdentificationNumber')).toBeDefined(); + }); + }); + + describe('nationalIdentificationNumber — required for individual', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(esNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'nationalIdentificationNumber')).toBeDefined(); + }); + + it('should NOT be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(esNatIdConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'nationalIdentificationNumber')).toBeUndefined(); + }); + }); + + describe('firstName — required for individual, readonly for individual with value, editable for corporation', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(esFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'firstName')).toBeDefined(); + }); + + it('should NOT be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(esFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'firstName')).toBeUndefined(); + }); + + it('should be readonly for individual with existing value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', firstName: 'Carlos' }; + expect(findMatchingConstraint(esFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', firstName: 'Carlos' }, 'firstName')).toBeDefined(); + }); + + it('should NOT be readonly for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', firstName: 'Carlos' }; + expect(findMatchingConstraint(esFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation', firstName: 'Carlos' }, 'firstName')).toBeUndefined(); + }); + + it('should NOT be readonly for individual without value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', firstName: '' }; + expect(findMatchingConstraint(esFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'firstName')).toBeUndefined(); + }); + }); + + describe('lastName — required for individual, readonly for individual with value, editable for corporation', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(esLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'lastName')).toBeDefined(); + }); + + it('should NOT be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(esLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'lastName')).toBeUndefined(); + }); + + it('should be readonly for individual with existing value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', lastName: 'García' }; + expect(findMatchingConstraint(esLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', lastName: 'García' }, 'lastName')).toBeDefined(); + }); + + it('should NOT be readonly for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', lastName: 'García' }; + expect(findMatchingConstraint(esLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation', lastName: 'García' }, 'lastName')).toBeUndefined(); + }); + }); + + describe('organisationName — required for non-individual, readonly when has value', () => { + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(esOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(esOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'organisationName')).toBeUndefined(); + }); + + it('should be readonly when has existing value', () => { + const fv: ContactEditFormValues = { organisationName: 'Telefónica S.A.' }; + expect(findMatchingConstraint(esOrgNameConstraints, OPERATORS.READONLY, fv, { organisationName: 'Telefónica S.A.' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be readonly when empty', () => { + const fv: ContactEditFormValues = { organisationName: '' }; + expect(findMatchingConstraint(esOrgNameConstraints, OPERATORS.READONLY, fv, {}, 'organisationName')).toBeUndefined(); + }); + }); + + describe('email — always required, never readonly', () => { + it('should always be required', () => { + expect(findMatchingConstraint(esEmailConstraints, OPERATORS.REQUIRED, {}, {}, 'email')).toBeDefined(); + }); + + it('should have NO readonly constraint', () => { + const fv: ContactEditFormValues = { email: 'test@es.com' }; + expect(findMatchingConstraint(esEmailConstraints, OPERATORS.READONLY, fv, { email: 'test@es.com' }, 'email')).toBeUndefined(); + }); + }); + }); + + describe('.pl domain — full configurationRules validation (real API shape)', () => { + // Exact constraint shapes from the .pl OWNER_CONTACT configurationRules API response. + // Key .pl differences vs other TLDs: + // - firstName/lastName ALWAYS required (unconditional), readonly for individual with value + // - email READONLY when has existing value (unlike .fr/.fi/.es where email is always editable) + // - phone has minlength(5) + maxlength(17) + + // --- firstName: required (unconditional), readonly(AND: individual + has value), maxlength --- + const plFirstNameConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'firstName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + constraints: [], + }, + } as unknown as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- lastName: required (unconditional), readonly(AND: individual + has value), maxlength --- + const plLastNameConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'lastName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + constraints: [], + }, + } as unknown as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- email: required (unconditional), readonly(has value via required condition), maxlength --- + const plEmailConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'email', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- legalForm: contains, required, readonly(value in list), maxlength --- + const plLegalFormConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- organisationName: required(ne individual), readonly(has value), maxlength --- + const plOrgNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'organisationName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + describe('firstName — always required, readonly for individual with value', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(plFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'firstName')).toBeDefined(); + }); + + it('should be required for corporation (unconditional)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(plFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'firstName')).toBeDefined(); + }); + + it('should be readonly for individual with existing value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', firstName: 'Marek' }; + expect(findMatchingConstraint(plFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', firstName: 'Marek' }, 'firstName')).toBeDefined(); + }); + + it('should NOT be readonly for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', firstName: 'Marek' }; + expect(findMatchingConstraint(plFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation', firstName: 'Marek' }, 'firstName')).toBeUndefined(); + }); + + it('should NOT be readonly for individual without value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', firstName: '' }; + expect(findMatchingConstraint(plFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'firstName')).toBeUndefined(); + }); + }); + + describe('lastName — always required, readonly for individual with value', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(plLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'lastName')).toBeDefined(); + }); + + it('should be required for corporation (unconditional)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(plLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'lastName')).toBeDefined(); + }); + + it('should be readonly for individual with existing value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', lastName: 'Kowalski' }; + expect(findMatchingConstraint(plLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', lastName: 'Kowalski' }, 'lastName')).toBeDefined(); + }); + + it('should NOT be readonly for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', lastName: 'Kowalski' }; + expect(findMatchingConstraint(plLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation', lastName: 'Kowalski' }, 'lastName')).toBeUndefined(); + }); + + it('should NOT be readonly for individual without value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', lastName: '' }; + expect(findMatchingConstraint(plLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'lastName')).toBeUndefined(); + }); + }); + + describe('email — always required, READONLY when has existing value', () => { + it('should always be required', () => { + expect(findMatchingConstraint(plEmailConstraints, OPERATORS.REQUIRED, {}, {}, 'email')).toBeDefined(); + }); + + it('should be readonly when email has existing value', () => { + const fv: ContactEditFormValues = { email: 'marek@pl.com' }; + expect(findMatchingConstraint(plEmailConstraints, OPERATORS.READONLY, fv, { email: 'marek@pl.com' }, 'email')).toBeDefined(); + }); + + it('should NOT be readonly when email is empty', () => { + const fv: ContactEditFormValues = { email: '' }; + expect(findMatchingConstraint(plEmailConstraints, OPERATORS.READONLY, fv, {}, 'email')).toBeUndefined(); + }); + }); + + describe('legalForm — readonly when has valid value', () => { + it('should be readonly when legalForm is individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(plLegalFormConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'legalForm')).toBeDefined(); + }); + + it('should be readonly when legalForm is corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(plLegalFormConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation' }, 'legalForm')).toBeDefined(); + }); + + it('should NOT be readonly when legalForm is empty', () => { + const fv: ContactEditFormValues = { legalForm: '' }; + expect(findMatchingConstraint(plLegalFormConstraints, OPERATORS.READONLY, fv, {}, 'legalForm')).toBeUndefined(); + }); + }); + + describe('organisationName — required for non-individual, readonly when has value', () => { + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(plOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(plOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'organisationName')).toBeUndefined(); + }); + + it('should be readonly when has existing value', () => { + const fv: ContactEditFormValues = { organisationName: 'KGHM Polska Miedź' }; + expect(findMatchingConstraint(plOrgNameConstraints, OPERATORS.READONLY, fv, { organisationName: 'KGHM Polska Miedź' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be readonly when empty', () => { + const fv: ContactEditFormValues = { organisationName: '' }; + expect(findMatchingConstraint(plOrgNameConstraints, OPERATORS.READONLY, fv, {}, 'organisationName')).toBeUndefined(); + }); + }); + }); + + describe('.cn domain — full configurationRules validation (real API shape)', () => { + // Exact constraint shapes from the .cn OWNER_CONTACT configurationRules API response. + // Key .cn differences vs other TLDs: + // - firstName/lastName ALWAYS required AND readonly when has value (for ALL legalForms, not just individual) + // - email readonly when has value (like .pl) + // - No nationalIdentificationNumber, birthDay, nationality, vat, companyNationalIdentificationNumber fields + // - Simpler constraints overall — no AND conditions + + // --- firstName: required (unconditional), readonly(has value — all legalForms), maxlength --- + const cnFirstNameConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'firstName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- lastName: required (unconditional), readonly(has value — all legalForms), maxlength --- + const cnLastNameConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'lastName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- email: required (unconditional), readonly(has value), maxlength --- + const cnEmailConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'email', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- legalForm: contains, required, readonly(value in list), maxlength --- + const cnLegalFormConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- organisationName: required(ne individual), readonly(has value), maxlength --- + const cnOrgNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'organisationName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + describe('firstName — always required, readonly for ALL legalForms when has value', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(cnFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'firstName')).toBeDefined(); + }); + + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(cnFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'firstName')).toBeDefined(); + }); + + it('should be readonly for individual with existing value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', firstName: 'Wei' }; + expect(findMatchingConstraint(cnFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', firstName: 'Wei' }, 'firstName')).toBeDefined(); + }); + + it('should be readonly for CORPORATION with existing value (unlike .pl/.be/.es)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', firstName: 'Wei' }; + expect(findMatchingConstraint(cnFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation', firstName: 'Wei' }, 'firstName')).toBeDefined(); + }); + + it('should NOT be readonly when firstName is empty', () => { + const fv: ContactEditFormValues = { firstName: '' }; + expect(findMatchingConstraint(cnFirstNameConstraints, OPERATORS.READONLY, fv, {}, 'firstName')).toBeUndefined(); + }); + }); + + describe('lastName — always required, readonly for ALL legalForms when has value', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(cnLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'lastName')).toBeDefined(); + }); + + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(cnLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'lastName')).toBeDefined(); + }); + + it('should be readonly for individual with existing value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', lastName: 'Zhang' }; + expect(findMatchingConstraint(cnLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', lastName: 'Zhang' }, 'lastName')).toBeDefined(); + }); + + it('should be readonly for CORPORATION with existing value (unlike .pl/.be/.es)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', lastName: 'Zhang' }; + expect(findMatchingConstraint(cnLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation', lastName: 'Zhang' }, 'lastName')).toBeDefined(); + }); + + it('should NOT be readonly when lastName is empty', () => { + const fv: ContactEditFormValues = { lastName: '' }; + expect(findMatchingConstraint(cnLastNameConstraints, OPERATORS.READONLY, fv, {}, 'lastName')).toBeUndefined(); + }); + }); + + describe('email — always required, readonly when has value', () => { + it('should always be required', () => { + expect(findMatchingConstraint(cnEmailConstraints, OPERATORS.REQUIRED, {}, {}, 'email')).toBeDefined(); + }); + + it('should be readonly when email has existing value', () => { + const fv: ContactEditFormValues = { email: 'wei@cn.com' }; + expect(findMatchingConstraint(cnEmailConstraints, OPERATORS.READONLY, fv, { email: 'wei@cn.com' }, 'email')).toBeDefined(); + }); + + it('should NOT be readonly when email is empty', () => { + const fv: ContactEditFormValues = { email: '' }; + expect(findMatchingConstraint(cnEmailConstraints, OPERATORS.READONLY, fv, {}, 'email')).toBeUndefined(); + }); + }); + + describe('legalForm — readonly when has valid value', () => { + it('should be readonly when legalForm is individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(cnLegalFormConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'legalForm')).toBeDefined(); + }); + + it('should be readonly when legalForm is corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(cnLegalFormConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation' }, 'legalForm')).toBeDefined(); + }); + + it('should NOT be readonly when legalForm is empty', () => { + const fv: ContactEditFormValues = { legalForm: '' }; + expect(findMatchingConstraint(cnLegalFormConstraints, OPERATORS.READONLY, fv, {}, 'legalForm')).toBeUndefined(); + }); + }); + + describe('organisationName — required for non-individual, readonly when has value', () => { + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(cnOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(cnOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'organisationName')).toBeUndefined(); + }); + + it('should be readonly when has existing value', () => { + const fv: ContactEditFormValues = { organisationName: 'Alibaba Group' }; + expect(findMatchingConstraint(cnOrgNameConstraints, OPERATORS.READONLY, fv, { organisationName: 'Alibaba Group' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be readonly when empty', () => { + const fv: ContactEditFormValues = { organisationName: '' }; + expect(findMatchingConstraint(cnOrgNameConstraints, OPERATORS.READONLY, fv, {}, 'organisationName')).toBeUndefined(); + }); + }); + }); + + describe('.com domain — full configurationRules validation (real API shape)', () => { + // Exact constraint shapes from the .com OWNER_CONTACT configurationRules API response. + // .com has the same structure as .pl: + // - firstName/lastName ALWAYS required (unconditional), readonly for individual with value (AND) + // - email readonly when has value + // - legalForm readonly when has valid value + // - organisationName required for non-individual, readonly when has value + // - address.zip required with notcontains exclusion list + + // --- firstName: required (unconditional), readonly(AND: individual + has value), maxlength --- + const comFirstNameConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'firstName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + constraints: [], + }, + } as unknown as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- lastName: required (unconditional), readonly(AND: individual + has value), maxlength --- + const comLastNameConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + and: [ + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.EQ, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + { + label: 'OWNER_CONTACT', + type: 'contact', + fields: { + label: 'lastName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + ], + constraints: [], + }, + } as unknown as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- email: required (unconditional), readonly(has value), maxlength --- + const comEmailConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'email', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- legalForm: contains, required, readonly(value in list), maxlength --- + const comLegalFormConstraints: TConfigurationRuleConstraint[] = [ + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { operator: OPERATORS.REQUIRED }, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.CONTAINS, values: ['association', 'corporation', 'individual', 'other'] }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- organisationName: required(ne individual), readonly(has value), maxlength --- + const comOrgNameConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'legalForm', + constraints: [ + { operator: OPERATORS.NE, value: 'individual' }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { + operator: OPERATORS.READONLY, + conditions: { + fields: { + label: 'organisationName', + constraints: [{ operator: OPERATORS.REQUIRED }], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + // --- address.zip: required(notcontains exclusion list), maxlength --- + const comZipConstraints: TConfigurationRuleConstraint[] = [ + { + operator: OPERATORS.REQUIRED, + conditions: { + fields: { + label: 'address.country', + constraints: [ + { operator: OPERATORS.NOTCONTAINS, values: ['IE', 'AZ', 'DJ', 'LA', 'CI', 'AN', 'HK', 'BO', 'PA', 'HN', 'NI', 'SV', 'CO'] }, + { operator: OPERATORS.REQUIRED }, + ], + }, + }, + } as TConfigurationRuleConstraint, + { operator: OPERATORS.MAXLENGTH, value: '255' }, + ]; + + describe('firstName — always required, readonly for individual with value', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(comFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'firstName')).toBeDefined(); + }); + + it('should be required for corporation (unconditional)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(comFirstNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'firstName')).toBeDefined(); + }); + + it('should be readonly for individual with existing value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', firstName: 'John' }; + expect(findMatchingConstraint(comFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', firstName: 'John' }, 'firstName')).toBeDefined(); + }); + + it('should NOT be readonly for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', firstName: 'John' }; + expect(findMatchingConstraint(comFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation', firstName: 'John' }, 'firstName')).toBeUndefined(); + }); + + it('should NOT be readonly for individual without value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', firstName: '' }; + expect(findMatchingConstraint(comFirstNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'firstName')).toBeUndefined(); + }); + }); + + describe('lastName — always required, readonly for individual with value', () => { + it('should be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(comLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'lastName')).toBeDefined(); + }); + + it('should be required for corporation (unconditional)', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(comLastNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'lastName')).toBeDefined(); + }); + + it('should be readonly for individual with existing value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', lastName: 'Smith' }; + expect(findMatchingConstraint(comLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual', lastName: 'Smith' }, 'lastName')).toBeDefined(); + }); + + it('should NOT be readonly for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation', lastName: 'Smith' }; + expect(findMatchingConstraint(comLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation', lastName: 'Smith' }, 'lastName')).toBeUndefined(); + }); + + it('should NOT be readonly for individual without value', () => { + const fv: ContactEditFormValues = { legalForm: 'individual', lastName: '' }; + expect(findMatchingConstraint(comLastNameConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'lastName')).toBeUndefined(); + }); + }); + + describe('email — always required, readonly when has value', () => { + it('should always be required', () => { + expect(findMatchingConstraint(comEmailConstraints, OPERATORS.REQUIRED, {}, {}, 'email')).toBeDefined(); + }); + + it('should be readonly when email has existing value', () => { + const fv: ContactEditFormValues = { email: 'john@example.com' }; + expect(findMatchingConstraint(comEmailConstraints, OPERATORS.READONLY, fv, { email: 'john@example.com' }, 'email')).toBeDefined(); + }); + + it('should NOT be readonly when email is empty', () => { + const fv: ContactEditFormValues = { email: '' }; + expect(findMatchingConstraint(comEmailConstraints, OPERATORS.READONLY, fv, {}, 'email')).toBeUndefined(); + }); + }); + + describe('legalForm — readonly when has valid value', () => { + it('should be readonly when legalForm is individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(comLegalFormConstraints, OPERATORS.READONLY, fv, { legalForm: 'individual' }, 'legalForm')).toBeDefined(); + }); + + it('should be readonly when legalForm is corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(comLegalFormConstraints, OPERATORS.READONLY, fv, { legalForm: 'corporation' }, 'legalForm')).toBeDefined(); + }); + + it('should NOT be readonly when legalForm is empty', () => { + const fv: ContactEditFormValues = { legalForm: '' }; + expect(findMatchingConstraint(comLegalFormConstraints, OPERATORS.READONLY, fv, {}, 'legalForm')).toBeUndefined(); + }); + }); + + describe('organisationName — required for non-individual, readonly when has value', () => { + it('should be required for corporation', () => { + const fv: ContactEditFormValues = { legalForm: 'corporation' }; + expect(findMatchingConstraint(comOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'corporation' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be required for individual', () => { + const fv: ContactEditFormValues = { legalForm: 'individual' }; + expect(findMatchingConstraint(comOrgNameConstraints, OPERATORS.REQUIRED, fv, { legalForm: 'individual' }, 'organisationName')).toBeUndefined(); + }); + + it('should be readonly when has existing value', () => { + const fv: ContactEditFormValues = { organisationName: 'Acme Inc.' }; + expect(findMatchingConstraint(comOrgNameConstraints, OPERATORS.READONLY, fv, { organisationName: 'Acme Inc.' }, 'organisationName')).toBeDefined(); + }); + + it('should NOT be readonly when empty', () => { + const fv: ContactEditFormValues = { organisationName: '' }; + expect(findMatchingConstraint(comOrgNameConstraints, OPERATORS.READONLY, fv, {}, 'organisationName')).toBeUndefined(); + }); + }); + + describe('address.zip — required when country NOT in exclusion list', () => { + it('should be required when country is FR', () => { + const fv: ContactEditFormValues = { 'address.country': 'FR' }; + expect(findMatchingConstraint(comZipConstraints, OPERATORS.REQUIRED, fv, { address: { country: 'FR' } }, 'address.zip')).toBeDefined(); + }); + + it('should be required when country is US', () => { + const fv: ContactEditFormValues = { 'address.country': 'US' }; + expect(findMatchingConstraint(comZipConstraints, OPERATORS.REQUIRED, fv, { address: { country: 'US' } }, 'address.zip')).toBeDefined(); + }); + + it('should NOT be required when country is IE (in exclusion list)', () => { + const fv: ContactEditFormValues = { 'address.country': 'IE' }; + expect(findMatchingConstraint(comZipConstraints, OPERATORS.REQUIRED, fv, { address: { country: 'IE' } }, 'address.zip')).toBeUndefined(); + }); + + it('should NOT be required when country is HK (in exclusion list)', () => { + const fv: ContactEditFormValues = { 'address.country': 'HK' }; + expect(findMatchingConstraint(comZipConstraints, OPERATORS.REQUIRED, fv, { address: { country: 'HK' } }, 'address.zip')).toBeUndefined(); + }); + + it('should NOT be required when country is CO (in exclusion list)', () => { + const fv: ContactEditFormValues = { 'address.country': 'CO' }; + expect(findMatchingConstraint(comZipConstraints, OPERATORS.REQUIRED, fv, { address: { country: 'CO' } }, 'address.zip')).toBeUndefined(); + }); + }); + }); }); diff --git a/packages/manager/apps/web-domains/src/domain/utils/contactEditConstraints.ts b/packages/manager/apps/web-domains/src/domain/utils/contactEditConstraints.ts index fdfc8c09c799..e9105e92edb8 100644 --- a/packages/manager/apps/web-domains/src/domain/utils/contactEditConstraints.ts +++ b/packages/manager/apps/web-domains/src/domain/utils/contactEditConstraints.ts @@ -62,6 +62,70 @@ function checkArrayValue( return haveToInclude ? check : !check; } +interface ConstraintLookup { + equalRule?: TConfigurationRuleConstraint; + noEqualRule?: TConfigurationRuleConstraint; + containsRule?: TConfigurationRuleConstraint; + notContainsRule?: TConfigurationRuleConstraint; + emptyRule?: TConfigurationRuleConstraint; + notEmptyRule?: TConfigurationRuleConstraint; + requiredRule?: TConfigurationRuleConstraint; +} + +function findConstraintsByOperator( + constraints: TConfigurationRuleConstraint[], +): ConstraintLookup { + return { + equalRule: constraints.find((c) => c.operator === OPERATORS.EQ), + noEqualRule: constraints.find((c) => c.operator === OPERATORS.NE), + containsRule: constraints.find((c) => c.operator === OPERATORS.CONTAINS), + notContainsRule: constraints.find((c) => c.operator === OPERATORS.NOTCONTAINS), + emptyRule: constraints.find((c) => c.operator === OPERATORS.EMPTY), + notEmptyRule: constraints.find((c) => c.operator === OPERATORS.NOTEMPTY), + requiredRule: constraints.find((c) => c.operator === OPERATORS.REQUIRED), + }; +} + +function evaluateConstraintLookup( + lookup: ConstraintLookup, + currentValue: string, + isConditionContext: boolean, +): boolean | undefined { + const { equalRule, noEqualRule, containsRule, notContainsRule, emptyRule, notEmptyRule, requiredRule } = lookup; + + if (equalRule) { + return equalRule.values + ? checkArrayValue(currentValue, equalRule.values, true) + : checkStringValue(currentValue, equalRule.value || '', true, true); + } + + if (noEqualRule) { + return noEqualRule.values + ? checkArrayValue(currentValue, noEqualRule.values, false) + : checkStringValue(currentValue, noEqualRule.value || '', false, true); + } + + if (containsRule) { + return containsRule.values + ? checkArrayValue(currentValue, containsRule.values, true) + : checkStringValue(currentValue, containsRule.value || '', true, false); + } + + if (notContainsRule) { + return notContainsRule.values + ? checkArrayValue(currentValue, notContainsRule.values, false) + : checkStringValue(currentValue, notContainsRule.value || '', false, false); + } + + if (emptyRule) return currentValue === ''; + + // NOTEMPTY and REQUIRED (in condition context) both check for non-empty value + const checkNonEmpty = notEmptyRule || (isConditionContext && requiredRule); + if (checkNonEmpty) return currentValue !== ''; + + return undefined; +} + export function checkConstraint( rules: TConfigurationRuleConstraint, formValues: ContactEditFormValues, @@ -90,22 +154,12 @@ export function checkConstraint( }); } + const isConditionContext = !!rules.conditions?.fields; const constraints = rules.conditions?.fields?.constraints || [rules]; const fieldLabel = rules.conditions?.fields?.label || ruleLabel; - const equalRule = constraints.find((c) => c.operator === OPERATORS.EQ); - const noEqualRule = constraints.find((c) => c.operator === OPERATORS.NE); - const containsRule = constraints.find( - (c) => c.operator === OPERATORS.CONTAINS, - ); - const notContainsRule = constraints.find( - (c) => c.operator === OPERATORS.NOTCONTAINS, - ); - const emptyRule = constraints.find((c) => c.operator === OPERATORS.EMPTY); - const notEmptyRule = constraints.find( - (c) => c.operator === OPERATORS.NOTEMPTY, - ); + const lookup = findConstraintsByOperator(constraints); const currentValue = fieldCurrentValue( fieldLabel, @@ -114,44 +168,7 @@ export function checkConstraint( fieldLabel, ); - if (equalRule) { - return equalRule.values - ? checkArrayValue(currentValue, equalRule.values, true) - : checkStringValue(currentValue, equalRule.value || '', true, true); - } - - if (noEqualRule) { - return noEqualRule.values - ? checkArrayValue(currentValue, noEqualRule.values, false) - : checkStringValue(currentValue, noEqualRule.value || '', false, true); - } - - if (containsRule) { - return containsRule.values - ? checkArrayValue(currentValue, containsRule.values, true) - : checkStringValue(currentValue, containsRule.value || '', true, false); - } - - if (notContainsRule) { - return notContainsRule.values - ? checkArrayValue(currentValue, notContainsRule.values, false) - : checkStringValue( - currentValue, - notContainsRule.value || '', - false, - false, - ); - } - - if (emptyRule) { - return currentValue === ''; - } - - if (notEmptyRule) { - return currentValue !== ''; - } - - return true; + return evaluateConstraintLookup(lookup, currentValue, isConditionContext) ?? true; } export function isConstraintSatisfied( @@ -162,9 +179,33 @@ export function isConstraintSatisfied( ): boolean { if (constraint.conditions) { if (constraint.conditions.and) { - return constraint.conditions.and.every((r) => - checkConstraint(r.fields, formValues, contactInformations, ruleLabel), - ); + return constraint.conditions.and.every((r) => { + const fields = r.fields as { label?: string; constraints?: TConfigurationRuleConstraint[] }; + if (fields.label && fields.constraints) { + const fieldLabel = fields.label || ruleLabel; + const pseudoConstraint: TConfigurationRuleConstraint = { + operator: constraint.operator, + conditions: { + fields: { + label: fieldLabel, + constraints: fields.constraints, + }, + }, + }; + return checkConstraint( + pseudoConstraint, + formValues, + contactInformations, + fieldLabel, + ); + } + return checkConstraint( + r.fields as TConfigurationRuleConstraint, + formValues, + contactInformations, + ruleLabel, + ); + }); } return checkConstraint( constraint, @@ -190,7 +231,7 @@ export function findMatchingConstraint( ruleLabel: string, ): TConfigurationRuleConstraint | undefined { return constraints - .filter((c) => c.operator === operator || (!c.operator && operator === OPERATORS.READONLY && c.conditions)) + .filter((c) => c.operator === operator) .find((constraint) => isConstraintSatisfied(constraint, formValues, contactInformations, ruleLabel), ); diff --git a/packages/manager/apps/web-domains/src/domain/utils/order.spec.ts b/packages/manager/apps/web-domains/src/domain/utils/order.spec.ts index 071707e777ab..7ab0bf2ecf03 100644 --- a/packages/manager/apps/web-domains/src/domain/utils/order.spec.ts +++ b/packages/manager/apps/web-domains/src/domain/utils/order.spec.ts @@ -8,7 +8,7 @@ vi.mock('jsurl', () => ({ }, })); -vi.mock('@/domain/constants/order', () => ({ +vi.mock('@/common/constants/order', () => ({ ANYCAST_ORDER_CONSTANT: { DURATION: 'P1Y', PRODUCT_ID: 'anycast', @@ -49,14 +49,7 @@ describe('formatOrderProduct', () => { expect(result).toMatchObject({ planCode: base.planCode, configuration: [{ label: 'zone', value: base.zoneName }], - option: [ - { - duration: 'P1Y', - planCode: 'anycast', - quantity: 1, - pricingMode: 'default', - }, - ], + option: [], }); expect(result).not.toHaveProperty('serviceName'); }); @@ -81,14 +74,7 @@ describe('formatOrderProduct', () => { { label: 'zone', value: base.zoneName }, { label: 'dnssec', value: true }, ], - option: [ - { - duration: 'P1Y', - planCode: 'anycast', - quantity: 1, - pricingMode: 'default', - }, - ], + option: [], }); expect(result).not.toHaveProperty('serviceName'); }); diff --git a/packages/manager/apps/web-domains/src/domain/utils/order.ts b/packages/manager/apps/web-domains/src/domain/utils/order.ts index 0c8804d265f9..74ad6817850e 100644 --- a/packages/manager/apps/web-domains/src/domain/utils/order.ts +++ b/packages/manager/apps/web-domains/src/domain/utils/order.ts @@ -3,13 +3,14 @@ import { OrderProduct } from '@/domain/types/order'; import { ANYCAST_ORDER_CONSTANT, DEFAULT_DNS_CONFIGURATION, -} from '@/domain/constants/order'; +} from '@/common/constants/order'; export const formatOrderProduct = ({ planCode, zoneName, dnssec, activateZone, + anycast, }: OrderProduct): Record => { const base = { planCode, @@ -25,24 +26,26 @@ export const formatOrderProduct = ({ if (dnssec) configuration.push(ANYCAST_ORDER_CONSTANT.DNSSEC_CONFIGURATION); + const option = []; + if (anycast) { + option.push({ + duration: ANYCAST_ORDER_CONSTANT.DURATION, + planCode: ANYCAST_ORDER_CONSTANT.ANYCAST_PLAN_CODE, + quantity: ANYCAST_ORDER_CONSTANT.QUANTITY, + pricingMode: ANYCAST_ORDER_CONSTANT.PRICING_MODE, + }); + } return { ...base, ...(activateZone ? { - configuration, - option: [ - { - duration: ANYCAST_ORDER_CONSTANT.DURATION, - planCode: ANYCAST_ORDER_CONSTANT.ANYCAST_PLAN_CODE, - quantity: ANYCAST_ORDER_CONSTANT.QUANTITY, - pricingMode: ANYCAST_ORDER_CONSTANT.PRICING_MODE, - }, - ], - } + configuration, + option, + } : { - configuration, - serviceName: zoneName, - }), + configuration, + serviceName: zoneName, + }), }; }; diff --git a/packages/manager/apps/web-domains/src/index.scss b/packages/manager/apps/web-domains/src/index.scss index 759d54cd31f9..6781d019ceb9 100644 --- a/packages/manager/apps/web-domains/src/index.scss +++ b/packages/manager/apps/web-domains/src/index.scss @@ -1,6 +1,7 @@ @tailwind utilities; @import '@ovhcloud/ods-themes/default'; +@import '@ovh-ux/muk/dist/style.css'; @import '@ovh-ux/manager-react-components/dist/style.css'; html { @@ -41,3 +42,13 @@ html { } } } + +// Hide the empty expander column auto-added by the Datagrid when using renderSubComponent. +// The expander column is always prepended as the first column (before row-selection). +// Expanded sub-component rows have data-index ending with "-expanded-tr" and must stay visible. +.zone-datagrid-no-expander { + thead th:first-child, + tbody tr:not([data-index$='-expanded-tr']) > td:first-child { + display: none !important; + } +} diff --git a/packages/manager/apps/web-domains/src/zone/__mocks__/domainZone.ts b/packages/manager/apps/web-domains/src/zone/__mocks__/domainZone.ts new file mode 100644 index 000000000000..085e905a9675 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/__mocks__/domainZone.ts @@ -0,0 +1,13 @@ +export const domainZoneMock = { + dnssecActivated: false, + dnssecSupported: true, + hasDnsAnycast: true, + lastUpdate: '2025-09-02T10:26:24.610798+02:00', + name: 'example.com', + nameServers: ['ns200.anycast.me', 'dns200.anycast.me', 'sdns2.ovh.net'], + iam: { + id: '988536bd-539e-4fa8-8a21-62b73de4005b', + state: 'OK', + urn: 'urn:v1:eu:resource:dnsZone:example.com', + }, +}; \ No newline at end of file diff --git a/packages/manager/apps/web-domains/src/zone/__mocks__/records.ts b/packages/manager/apps/web-domains/src/zone/__mocks__/records.ts new file mode 100644 index 000000000000..64d47b14dcf3 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/__mocks__/records.ts @@ -0,0 +1,111 @@ +import { + ZoneRecord, + ZoneRecordFieldType, + PaginatedZoneRecords, + DomainZoneRecordsResponse, +} from '@/zone/types/zoneRecords.types'; + +export const zoneRecordsMock: ZoneRecord[] = [ + { + fieldType: 'A', + id: '1', + subDomain: 'www', + subDomainToDisplay: 'www', + ttl: 3600, + target: '192.168.1.1', + targetToDisplay: '192.168.1.1', + zone: 'example.com', + zoneToDisplay: 'example.com', + }, + { + fieldType: 'AAAA', + id: '2', + subDomain: 'www', + subDomainToDisplay: 'www', + ttl: 3600, + target: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + targetToDisplay: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + zone: 'example.com', + zoneToDisplay: 'example.com', + }, + { + fieldType: 'MX', + id: '3', + subDomain: '', + subDomainToDisplay: '', + ttl: 3600, + target: '10 mail.example.com', + targetToDisplay: '10 mail.example.com', + zone: 'example.com', + zoneToDisplay: 'example.com', + }, + { + fieldType: 'CNAME', + id: '4', + subDomain: 'blog', + subDomainToDisplay: 'blog', + ttl: 3600, + target: 'example.com', + targetToDisplay: 'example.com', + zone: 'example.com', + zoneToDisplay: 'example.com', + }, + { + fieldType: 'TXT', + id: '5', + subDomain: '', + subDomainToDisplay: '', + ttl: 3600, + target: 'v=spf1 include:_spf.example.com ~all', + targetToDisplay: 'v=spf1 include:_spf.example.com ~all', + zone: 'example.com', + zoneToDisplay: 'example.com', + }, + { + fieldType: 'NS', + id: '6', + subDomain: '', + subDomainToDisplay: '', + ttl: 3600, + target: 'ns1.example.com', + targetToDisplay: 'ns1.example.com', + zone: 'example.com', + zoneToDisplay: 'example.com', + }, +]; + +export const fieldsTypesMock: ZoneRecordFieldType[] = [ + 'A', + 'AAAA', + 'CAA', + 'CNAME', + 'DKIM', + 'DMARC', + 'DNAME', + 'HTTPS', + 'LOC', + 'MX', + 'NAPTR', + 'NS', + 'RP', + 'SPF', + 'SRV', + 'SSHFP', + 'SVCB', + 'TLSA', + 'TXT', +]; + +export const paginatedZoneRecordsMock: PaginatedZoneRecords = { + count: zoneRecordsMock.length, + pagination: [0, 15, 30], + records: { + results: zoneRecordsMock, + }, +}; + +export const domainZoneRecordsResponseMock: DomainZoneRecordsResponse = { + fieldsTypes: fieldsTypesMock, + fullRecordsIdsList: zoneRecordsMock.map((record) => parseInt(record.id, 10)), + paginatedZone: paginatedZoneRecordsMock, +}; diff --git a/packages/manager/apps/web-domains/src/zone/components/CompareZonesViewer.tsx b/packages/manager/apps/web-domains/src/zone/components/CompareZonesViewer.tsx new file mode 100644 index 000000000000..5bef8126b5ec --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/components/CompareZonesViewer.tsx @@ -0,0 +1,347 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + BUTTON_VARIANT, + Icon, + ICON_NAME, + MESSAGE_COLOR, + Message, + SPINNER_SIZE, + Spinner, + MessageIcon, + MessageBody, +} from '@ovhcloud/ods-react'; +import { TZoneHistoryWithDate } from '@/zone/types/history.types'; +import { useCompareZoneFiles } from '@/zone/hooks/data/history.hooks'; + +interface CharDiff { + readonly isChanged: boolean; + readonly char: string; + readonly position: number; +} + +interface DiffLine { + readonly type: 'added' | 'removed' | 'unchanged'; + readonly content: string; + readonly id: string; + readonly charDiffs?: CharDiff[]; +} + +interface SplitDiff { + readonly baseLines: DiffLine[]; + readonly modifiedLines: DiffLine[]; +} + +interface CompareZonesViewerProps { + readonly baseItem: TZoneHistoryWithDate; + readonly modifiedItem: TZoneHistoryWithDate; +} + +const getCharDiffs = (str1: string, str2: string): [CharDiff[], CharDiff[]] => { + const maxLength = Math.max(str1.length, str2.length); + const diffs1: CharDiff[] = []; + const diffs2: CharDiff[] = []; + + for (let index = 0; index < maxLength; index += 1) { + const char1 = str1[index]; + const char2 = str2[index]; + const isChanged = char1 !== char2; + + diffs1.push({ char: char1 ?? '', isChanged, position: index }); + diffs2.push({ char: char2 ?? '', isChanged, position: index }); + } + + return [diffs1, diffs2]; +}; + +const buildSplitDiff = ( + baseContent: string, + modifiedContent: string, +): SplitDiff => { + const baseLines = baseContent.split('\n'); + const modifiedLines = modifiedContent.split('\n'); + const maxLength = Math.max(baseLines.length, modifiedLines.length); + + const baseDiff: DiffLine[] = []; + const modifiedDiff: DiffLine[] = []; + + for (let index = 0; index < maxLength; index += 1) { + const baseLine = baseLines[index]; + const modifiedLine = modifiedLines[index]; + const baseLineId = `base-${index}`; + const modifiedLineId = `modified-${index}`; + + if (baseLine === modifiedLine) { + if (baseLine !== undefined) { + baseDiff.push({ type: 'unchanged', content: baseLine, id: baseLineId }); + modifiedDiff.push({ + type: 'unchanged', + content: modifiedLine, + id: modifiedLineId, + }); + } + continue; + } + + const baseIsUndefined = baseLine === undefined; + const modifiedIsUndefined = modifiedLine === undefined; + + if (!baseIsUndefined && !modifiedIsUndefined) { + // Both lines exist but are different - compute character-level diff + const [charDiffs1, charDiffs2] = getCharDiffs(baseLine, modifiedLine); + baseDiff.push({ + type: 'removed', + content: baseLine, + id: baseLineId, + charDiffs: charDiffs1, + }); + modifiedDiff.push({ + type: 'added', + content: modifiedLine, + id: modifiedLineId, + charDiffs: charDiffs2, + }); + } else if (!baseIsUndefined && modifiedIsUndefined) { + baseDiff.push({ type: 'removed', content: baseLine, id: baseLineId }); + modifiedDiff.push({ type: 'unchanged', content: '', id: modifiedLineId }); + } else if (baseIsUndefined && !modifiedIsUndefined) { + baseDiff.push({ type: 'unchanged', content: '', id: baseLineId }); + modifiedDiff.push({ + type: 'added', + content: modifiedLine, + id: modifiedLineId, + }); + } + } + + return { baseLines: baseDiff, modifiedLines: modifiedDiff }; +}; + +interface DiffLineRendererProps { + readonly line: DiffLine; + readonly lineNumber: number; +} + +const DiffLineRenderer = ({ line, lineNumber }: DiffLineRendererProps) => { + if (line.charDiffs && (line.type === 'added' || line.type === 'removed')) { + return ( +
+ + {lineNumber} + +
+ {line.charDiffs.map((charDiff) => + charDiff.isChanged ? ( + + {charDiff.char || '\u00A0'} + + ) : ( + + {charDiff.char} + + ), + )} +
+
+ ); + } + + const getLineColor = (type: DiffLine['type']) => { + if (type === 'added') return 'bg-green-50'; + if (type === 'removed') return 'bg-red-50'; + return ''; + }; + + const getLineTextColor = (type: DiffLine['type']) => { + if (type === 'added') return 'text-green-800'; + if (type === 'removed') return 'text-red-800'; + return 'text-gray-700'; + }; + + return ( +
+ + {lineNumber} + +
+ {line.content || '\u00A0'} +
+
+ ); +}; + +export default function CompareZonesViewer({ + baseItem, + modifiedItem, +}: CompareZonesViewerProps) { + const { t } = useTranslation('zone'); + const { + mutate: compareZones, + data: compareData, + isPending: isLoading, + error: compareError, + } = useCompareZoneFiles(); + const [copiedBase, setCopiedBase] = useState(false); + const [copiedModified, setCopiedModified] = useState(false); + + const baseScrollRef = useRef(null); + const modifiedScrollRef = useRef(null); + const isSyncingRef = useRef(false); + + const error = compareError?.message ?? null; + + const diff = useMemo(() => { + if (!compareData) return null; + return buildSplitDiff(compareData.baseContent, compareData.modifiedContent); + }, [compareData]); + + const handleCopy = (lines: DiffLine[], setCopied: (v: boolean) => void) => { + const text = lines.map((line) => line.content).join('\n'); + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + useEffect(() => { + const baseEl = baseScrollRef.current; + const modifiedEl = modifiedScrollRef.current; + if (!baseEl || !modifiedEl) return; + + const syncFromBase = () => { + if (isSyncingRef.current) return; + isSyncingRef.current = true; + modifiedEl.scrollTop = baseEl.scrollTop; + modifiedEl.scrollLeft = baseEl.scrollLeft; + isSyncingRef.current = false; + }; + + const syncFromModified = () => { + if (isSyncingRef.current) return; + isSyncingRef.current = true; + baseEl.scrollTop = modifiedEl.scrollTop; + baseEl.scrollLeft = modifiedEl.scrollLeft; + isSyncingRef.current = false; + }; + + baseEl.addEventListener('scroll', syncFromBase); + modifiedEl.addEventListener('scroll', syncFromModified); + + return () => { + baseEl.removeEventListener('scroll', syncFromBase); + modifiedEl.removeEventListener('scroll', syncFromModified); + }; + }, [diff]); + + useEffect(() => { + if (!baseItem || !modifiedItem || baseItem.id === modifiedItem.id) { + return; + } + compareZones({ + baseUrl: baseItem.zoneFileUrl, + modifiedUrl: modifiedItem.zoneFileUrl, + }); + }, [baseItem, modifiedItem]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + + {t('zone_history_error', { message: error })} + + ); + } + + if ( + !diff || + (diff.baseLines.length === 0 && diff.modifiedLines.length === 0) + ) { + return {t('zone_history_compare_empty')}; + } + + return ( +
+
+
+
+ +
+
+            {diff.baseLines.map((line, index) => (
+              
+            ))}
+          
+
+ +
+
+ +
+
+            {diff.modifiedLines.map((line, index) => (
+              
+            ))}
+          
+
+
+
+ ); +} diff --git a/packages/manager/apps/web-domains/src/zone/components/HistoryCells.spec.tsx b/packages/manager/apps/web-domains/src/zone/components/HistoryCells.spec.tsx new file mode 100644 index 000000000000..54574881f36b --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/components/HistoryCells.spec.tsx @@ -0,0 +1,173 @@ +import '@/common/setupTests'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { wrapper } from '@/common/utils/test.provider'; +import { + CreationDateCell, + SelectRowCell, + ViewZoneCell, + DownloadZoneCell, +} from '@/zone/components/HistoryCells'; +import type { TZoneHistoryWithDate } from '@/zone/types/history.types'; + +const mockDownload = vi.fn(); + +vi.mock('@/zone/hooks/data/history.hooks', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@/zone/hooks/data/history.hooks') + >(); + return { + ...actual, + useDownloadZoneFile: vi.fn(() => ({ + mutate: mockDownload, + isPending: false, + })), + }; +}); + +vi.mock('@ovh-ux/manager-react-components', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@ovh-ux/manager-react-components') + >(); + return { + ...actual, + useFormatDate: vi.fn(() => ({ date }: { date: string }) => date), + }; +}); + +const mockRow: TZoneHistoryWithDate = { + id: 'history-1', + date: '2024-01-15T10:00:00Z', + creationDate: '2024-01-15T10:00:00Z', + zoneFileUrl: 'https://api.example.com/zone/file/1', +}; + +describe('CreationDateCell', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders the creation date', () => { + render(, { wrapper }); + expect(screen.getByText(mockRow.creationDate)).toBeInTheDocument(); + }); + + it('renders active badge when isActive is true', () => { + render(, { wrapper }); + expect(screen.getByText('zone_history_current')).toBeInTheDocument(); + }); + + it('does not render active badge when isActive is false', () => { + render(, { wrapper }); + expect( + screen.queryByText('zone_history_current'), + ).not.toBeInTheDocument(); + }); + + it('does not render active badge by default', () => { + render(, { wrapper }); + expect( + screen.queryByText('zone_history_current'), + ).not.toBeInTheDocument(); + }); +}); + +describe('SelectRowCell', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders a checkbox', () => { + render( + , + { wrapper }, + ); + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + }); + + it('renders checkbox as checked when isSelected is true', () => { + render( + , + { wrapper }, + ); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeChecked(); + }); + + it('renders checkbox as unchecked when isSelected is false', () => { + render( + , + { wrapper }, + ); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + }); +}); + +describe('ViewZoneCell', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders the view button with label', () => { + render( + , + { wrapper }, + ); + expect(screen.getByText('zone_history_view')).toBeInTheDocument(); + }); + + it('calls onView with the row when clicked', () => { + const onView = vi.fn(); + render(, { wrapper }); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(onView).toHaveBeenCalledWith(mockRow); + }); +}); + +describe('DownloadZoneCell', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders the download button with label', () => { + render( + , + { wrapper }, + ); + expect(screen.getByText('zone_history_download')).toBeInTheDocument(); + }); + + it('calls download mutate when clicked', () => { + render( + , + { wrapper }, + ); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(mockDownload).toHaveBeenCalledWith({ + url: mockRow.zoneFileUrl, + zoneName: 'example.com', + }); + }); + + it('disables the button when isPending is true', async () => { + const { useDownloadZoneFile } = await import( + '@/zone/hooks/data/history.hooks' + ); + (useDownloadZoneFile as ReturnType).mockReturnValue({ + mutate: mockDownload, + isPending: true, + }); + render( + , + { wrapper }, + ); + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/packages/manager/apps/web-domains/src/zone/components/HistoryCells.tsx b/packages/manager/apps/web-domains/src/zone/components/HistoryCells.tsx new file mode 100644 index 000000000000..1b9d74ca56a4 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/components/HistoryCells.tsx @@ -0,0 +1,142 @@ +import { useTranslation } from 'react-i18next'; +import { TZoneHistoryWithDate } from '@/zone/types/history.types'; +import { useDownloadZoneFile } from '@/zone/hooks/data/history.hooks'; +import { useFormatDate } from '@ovh-ux/manager-react-components'; +import { Badge, BADGE_COLOR, Button, BUTTON_VARIANT, BUTTON_SIZE, Checkbox, CheckboxControl, ICON_NAME, Text, TEXT_PRESET, Icon, Tooltip, TooltipTrigger, TooltipContent } from '@ovhcloud/ods-react'; + +export interface CreationDateCellProps { + readonly row: TZoneHistoryWithDate; + readonly isActive?: boolean; +} + +export function CreationDateCell({ row, isActive }: CreationDateCellProps) { + const { t } = useTranslation('zone'); + const formatDate = useFormatDate(); + return ( +
+ + {formatDate({ + date: row.creationDate, + format: 'PPpp', + })} + + {isActive && {t('zone_history_current')}} +
+ + ); +} + +interface SelectRowCellProps { + readonly row: TZoneHistoryWithDate; + readonly isSelected: boolean; + readonly onSelectChange: (id: string, checked: boolean) => void; +} + +export function SelectRowCell({ + row, + isSelected, + onSelectChange, +}: SelectRowCellProps) { + return ( + onSelectChange(row.id, e.checked as boolean)} + > + + + ); +} + +interface ViewZoneCellProps { + readonly row: TZoneHistoryWithDate; + readonly onView: (item: TZoneHistoryWithDate) => void; +} + +export function ViewZoneCell({ row, onView }: ViewZoneCellProps) { + const { t } = useTranslation('zone'); + return ( + + ); +} + +interface DownloadZoneCellProps { + readonly row: TZoneHistoryWithDate; + readonly zoneName: string; +} + +export function DownloadZoneCell({ row, zoneName }: DownloadZoneCellProps) { + const { t } = useTranslation('zone'); + const { mutate: download, isPending } = useDownloadZoneFile(); + + const handleDownload = () => { + download({ url: row.zoneFileUrl, zoneName }); + }; + + return ( + + ); +} + +interface RestoreZoneCellProps { + readonly row: TZoneHistoryWithDate; + readonly onRestore: (item: TZoneHistoryWithDate) => void; + readonly isActive?: boolean; + readonly canRestore?: boolean; +} + +export function RestoreZoneCell({ row, onRestore, isActive, canRestore = true }: RestoreZoneCellProps) { + const { t } = useTranslation('zone'); + const isDisabled = isActive || !canRestore; + const showNoPermissionTooltip = !canRestore && !isActive; + + const button = ( + + ); + + if (showNoPermissionTooltip) { + return ( + + + {button} + + + {t('zone_history_restore_no_permission')} + + + ); + } + + return button; +} diff --git a/packages/manager/apps/web-domains/src/zone/components/RestoreZoneModal.spec.tsx b/packages/manager/apps/web-domains/src/zone/components/RestoreZoneModal.spec.tsx new file mode 100644 index 000000000000..22082d395231 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/components/RestoreZoneModal.spec.tsx @@ -0,0 +1,155 @@ +import '@/common/setupTests'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { wrapper } from '@/common/utils/test.provider'; +import RestoreZoneModal from '@/zone/components/RestoreZoneModal'; +import type { TZoneHistoryWithDate } from '@/zone/types/history.types'; + +const mockRestore = vi.fn(); +const mockAddSuccess = vi.fn(); +const mockAddError = vi.fn(); + +vi.mock('@/zone/hooks/data/history.hooks', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@/zone/hooks/data/history.hooks') + >(); + return { + ...actual, + useRestoreZone: vi.fn(() => ({ + mutate: mockRestore, + isPending: false, + })), + }; +}); + +vi.mock('@ovh-ux/manager-react-components', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@ovh-ux/manager-react-components') + >(); + return { + ...actual, + useFormatDate: vi.fn(() => ({ date }: { date: string }) => date), + useNotifications: vi.fn(() => ({ + addSuccess: mockAddSuccess, + addError: mockAddError, + addWarning: vi.fn(), + clearNotifications: vi.fn(), + notifications: [], + })), + }; +}); + +vi.mock('@ovh-ux/manager-react-shell-client', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@ovh-ux/manager-react-shell-client') + >(); + return { + ...actual, + useNavigationGetUrl: vi.fn(() => ({ + data: 'https://www.ovhcloud.com/manager/#/web-ongoing-operations/dns', + })), + }; +}); + +describe('RestoreZoneModal', () => { + const mockItem: TZoneHistoryWithDate = { + id: 'zone-history-1', + date: '2024-01-15T10:00:00Z', + creationDate: '2024-01-15T10:00:00Z', + zoneFileUrl: 'https://api.example.com/zone/file/1', + }; + + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + item: mockItem, + zoneName: 'example.com', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when isOpen is false', () => { + const { container } = render( + , + { wrapper }, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when item is null', () => { + const { container } = render( + , + { wrapper }, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders modal title when open', () => { + render(, { wrapper }); + expect( + screen.getByText('zone_history_restore_modal_title'), + ).toBeInTheDocument(); + }); + + it('renders warning message about restore date', () => { + render(, { wrapper }); + expect( + screen.getByText(/zone_history_restore_modal_description/), + ).toBeInTheDocument(); + }); + + it('renders propagation info message', () => { + render(, { wrapper }); + expect( + screen.getByText('zone_history_restore_modal_propagation_info'), + ).toBeInTheDocument(); + }); + + it('renders cancel button', () => { + render(, { wrapper }); + expect( + screen.getByText('zone_history_restore_modal_cancel'), + ).toBeInTheDocument(); + }); + + it('renders confirm button', () => { + render(, { wrapper }); + expect( + screen.getByText('zone_history_restore_modal_confirm'), + ).toBeInTheDocument(); + }); + + it('calls onClose when cancel button is clicked', () => { + const onClose = vi.fn(); + render(, { wrapper }); + const cancelBtn = screen.getByText('zone_history_restore_modal_cancel'); + fireEvent.click(cancelBtn); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls restore mutate when confirm button is clicked', () => { + render(, { wrapper }); + const confirmBtn = screen.getByText('zone_history_restore_modal_confirm'); + fireEvent.click(confirmBtn); + expect(mockRestore).toHaveBeenCalledWith( + { zoneName: 'example.com', creationDate: mockItem.creationDate }, + expect.any(Object), + ); + }); + + it('disables buttons when isPending is true', async () => { + const { useRestoreZone } = await import('@/zone/hooks/data/history.hooks'); + (useRestoreZone as ReturnType).mockReturnValue({ + mutate: mockRestore, + isPending: true, + }); + + render(, { wrapper }); + const cancelBtn = screen.getByText('zone_history_restore_modal_cancel'); + const confirmBtn = screen.getByText('zone_history_restore_modal_confirm'); + expect(cancelBtn.closest('button')).toBeDisabled(); + expect(confirmBtn.closest('button')).toBeDisabled(); + }); +}); diff --git a/packages/manager/apps/web-domains/src/zone/components/RestoreZoneModal.tsx b/packages/manager/apps/web-domains/src/zone/components/RestoreZoneModal.tsx new file mode 100644 index 000000000000..bb4f6561fb1c --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/components/RestoreZoneModal.tsx @@ -0,0 +1,130 @@ +import { Trans, useTranslation } from 'react-i18next'; +import { + useFormatDate, + useNotifications, +} from '@ovh-ux/manager-react-components'; +import { useQueryClient } from '@tanstack/react-query'; +import { + Button, + BUTTON_VARIANT, + Message, + MessageBody, + Modal, + ModalBody, + ModalContent, + TEXT_PRESET, + Text, + ModalHeader, + MESSAGE_COLOR, + ICON_NAME, + MessageIcon, +} from '@ovhcloud/ods-react'; +import { useRestoreZone } from '@/zone/hooks/data/history.hooks'; +import { TZoneHistoryWithDate } from '@/zone/types/history.types'; +import LinkToOngoingOperations from '@/domain/components/LinkToOngoingOperations/LinkToOngoingOperations'; + +interface RestoreZoneModalProps { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly item: TZoneHistoryWithDate | null; + readonly zoneName: string; +} + +export default function RestoreZoneModal({ + isOpen, + onClose, + item, + zoneName, +}: RestoreZoneModalProps) { + const { t } = useTranslation('zone'); + const formatDate = useFormatDate(); + const { addSuccess, addError, clearNotifications } = useNotifications(); + const queryClient = useQueryClient(); + + const { mutate: restore, isPending } = useRestoreZone(); + + const handleRestore = () => { + if (!item) return; + + restore( + { zoneName, creationDate: item.creationDate }, + { + onSuccess: () => { + clearNotifications(); + addSuccess( + + , + }} + /> + , + true, + ); + onClose(); + }, + onError: (error) => { + clearNotifications(); + addError( + t('zone_history_restore_error', { + message: error.message, + }), + true, + ); + }, + }, + ); + }; + + if (!item || !isOpen) return null; + + return ( + !detail.open && onClose()}> + + + + {t('zone_history_restore_modal_title')} + + + + + + + {t('zone_history_restore_modal_description', { + date: formatDate({ date: item.creationDate }), + })} + + + + + + {t('zone_history_restore_modal_propagation_info')} + + +
+ + +
+
+
+
+ ); +} diff --git a/packages/manager/apps/web-domains/src/zone/components/ViewZoneModal.spec.tsx b/packages/manager/apps/web-domains/src/zone/components/ViewZoneModal.spec.tsx new file mode 100644 index 000000000000..3104220e64cf --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/components/ViewZoneModal.spec.tsx @@ -0,0 +1,178 @@ +import '@/common/setupTests'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { wrapper } from '@/common/utils/test.provider'; +import ViewZoneModal from '@/zone/components/ViewZoneModal'; +import type { TZoneHistoryWithDate } from '@/zone/types/history.types'; + +const mockViewZone = vi.fn(); +const mockContent = '# Zone file\nexample.com. 3600 IN A 1.2.3.4\n'; + +vi.mock('@/zone/hooks/data/history.hooks', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@/zone/hooks/data/history.hooks') + >(); + return { + ...actual, + useViewZoneFile: vi.fn(() => ({ + mutate: mockViewZone, + data: undefined, + isPending: false, + error: null, + })), + }; +}); + +vi.mock('@ovh-ux/manager-react-components', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@ovh-ux/manager-react-components') + >(); + return { + ...actual, + useFormatDate: vi.fn(() => ({ date }: { date: string }) => date), + }; +}); + +describe('ViewZoneModal', () => { + const mockItem: TZoneHistoryWithDate = { + id: 'zone-history-1', + date: '2024-01-15T10:00:00Z', + creationDate: '2024-01-15T10:00:00Z', + zoneFileUrl: 'https://api.example.com/zone/file/1', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when isOpen is false', () => { + const { container } = render( + , + { wrapper }, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when item is null', () => { + const { container } = render( + , + { wrapper }, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the modal title when open with item', async () => { + const { useViewZoneFile } = await import( + '@/zone/hooks/data/history.hooks' + ); + (useViewZoneFile as ReturnType).mockReturnValue({ + mutate: mockViewZone, + data: mockContent, + isPending: false, + error: null, + }); + + render( + , + { wrapper }, + ); + expect(screen.getByText(/zone_history_view_title/i)).toBeInTheDocument(); + }); + + it('calls viewZone with item url on mount when open', async () => { + const { useViewZoneFile } = await import( + '@/zone/hooks/data/history.hooks' + ); + (useViewZoneFile as ReturnType).mockReturnValue({ + mutate: mockViewZone, + data: undefined, + isPending: false, + error: null, + }); + + render( + , + { wrapper }, + ); + expect(mockViewZone).toHaveBeenCalledWith(mockItem.zoneFileUrl); + }); + + it('shows spinner when loading', async () => { + const { useViewZoneFile } = await import( + '@/zone/hooks/data/history.hooks' + ); + (useViewZoneFile as ReturnType).mockReturnValue({ + mutate: mockViewZone, + data: undefined, + isPending: true, + error: null, + }); + + render( + , + { wrapper }, + ); + // Spinner renders when isPending is true + expect( + screen.queryByText('zone_history_error'), + ).not.toBeInTheDocument(); + }); + + it('shows error message when viewZone fails', async () => { + const { useViewZoneFile } = await import( + '@/zone/hooks/data/history.hooks' + ); + (useViewZoneFile as ReturnType).mockReturnValue({ + mutate: mockViewZone, + data: undefined, + isPending: false, + error: { message: 'Network error' }, + }); + + render( + , + { wrapper }, + ); + expect(screen.getByText(/zone_history_error/)).toBeInTheDocument(); + }); + + it('shows zone content when loaded', async () => { + const { useViewZoneFile } = await import( + '@/zone/hooks/data/history.hooks' + ); + (useViewZoneFile as ReturnType).mockReturnValue({ + mutate: mockViewZone, + data: mockContent, + isPending: false, + error: null, + }); + + render( + , + { wrapper }, + ); + expect( + screen.getByText((content) => content.includes('# Zone file')), + ).toBeInTheDocument(); + }); + + it('calls onClose when cancel/close button is triggered', async () => { + const { useViewZoneFile } = await import( + '@/zone/hooks/data/history.hooks' + ); + (useViewZoneFile as ReturnType).mockReturnValue({ + mutate: mockViewZone, + data: mockContent, + isPending: false, + error: null, + }); + const onClose = vi.fn(); + + render( + , + { wrapper }, + ); + // Modal is dismissible — the title should be rendered + expect(screen.getByText(/zone_history_view_title/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/apps/web-domains/src/zone/components/ViewZoneModal.tsx b/packages/manager/apps/web-domains/src/zone/components/ViewZoneModal.tsx new file mode 100644 index 000000000000..9f204b6bec96 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/components/ViewZoneModal.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useFormatDate } from '@ovh-ux/manager-react-components'; +import { + Button, + BUTTON_VARIANT, + Icon, + ICON_NAME, + MESSAGE_COLOR, + Message, + Modal, + ModalBody, + ModalContent, + SPINNER_SIZE, + Spinner, + TEXT_PRESET, + Text, + ModalHeader, + MessageBody, + MessageIcon, +} from '@ovhcloud/ods-react'; +import { useViewZoneFile } from '@/zone/hooks/data/history.hooks'; +import { TZoneHistoryWithDate } from '@/zone/types/history.types'; + +interface ViewZoneModalProps { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly item: TZoneHistoryWithDate | null; +} + +export default function ViewZoneModal({ + isOpen, + onClose, + item, +}: ViewZoneModalProps) { + const { t } = useTranslation('zone'); + const formatDate = useFormatDate(); + const { + mutate: viewZone, + data: content, + isPending: isLoading, + error, + } = useViewZoneFile(); + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + if (content) { + navigator.clipboard.writeText(content).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + }; + + useEffect(() => { + if (item && isOpen) { + viewZone(item.zoneFileUrl); + } + }, [item, isOpen]); + + if (!item || !isOpen) return null; + + return ( + !detail.open && onClose()}> + + + + {t('zone_history_view_title', { + date: formatDate({ date: item.creationDate, format: 'PPpp' }), + })} + + + +
+ {isLoading && ( +
+ +
+ )} + + {error && ( + + + + {t('zone_history_error', { message: error.message })} + + + )} + + {!isLoading && !error && content && ( +
+
+ +
+
+                  
+                    
+                      {content.split('\n').map((line, idx) => {
+                        const key = `${idx}-${line.slice(0, 20)}`;
+                        return (
+                          
+                            
+                            
+                          
+                        );
+                      })}
+                    
+                  
+ {idx + 1} + {line || '\u00A0'}
+
+
+ )} +
+
+
+ +
+
+
+ ); +} diff --git a/packages/manager/apps/web-domains/src/zone/data/api/history.api.ts b/packages/manager/apps/web-domains/src/zone/data/api/history.api.ts new file mode 100644 index 000000000000..65ff20250e32 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/data/api/history.api.ts @@ -0,0 +1,147 @@ +import { v6 } from '@ovh-ux/manager-core-api'; +import { TZoneHistory } from '@/zone/types/history.types'; + +/** + * Get the list of zone history dates + */ +export const getZoneHistory = async ( + zoneName: string, +): Promise => { + const { data } = await v6.get(`/domain/zone/${zoneName}/history`); + return data; +}; + +/** + * Get zone data for a specific date + */ +export const getZoneHistoryByDate = async ( + zoneName: string, + creationDate: string, +): Promise => { + const { data } = await v6.get( + `/domain/zone/${zoneName}/history/${encodeURIComponent(creationDate)}`, + ); + return data; +}; + +/** + * Download zone file content from URL + */ +export const downloadZoneFile = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download zone file: ${response.statusText}`); + } + return response.text(); +}; + +/** + * Restore zone from history + */ +export const restoreZone = async ( + zoneName: string, + creationDate: string, +): Promise => { + await v6.post(`/domain/zone/${zoneName}/import`, { + zoneFile: creationDate, + }); +}; + +export type TZoneSoa = { + ttl: number; + email: string; + expire: number; + nxDomainTtl: number; + refresh: number; + retry: number; + serial: number; + server: string; +}; + +/** + * Get DNS zone SOA + */ +export const getZoneSoa = async (zoneName: string): Promise => { + const { data } = await v6.get(`/domain/zone/${zoneName}/soa`); + return data; +}; + +/** + * Update DNS zone SOA + */ +export const updateZoneSoa = async ( + zoneName: string, + soa: TZoneSoa, +): Promise => { + await v6.put(`/domain/zone/${zoneName}/soa`, soa); +}; + +export type DnsRecord = { + fieldType: string; + target: string; + subDomain?: string; +}; + +/** + * Reset DNS zone + */ +export const resetZone = async ( + zoneName: string, + minimized: boolean, + dnsRecords: DnsRecord[] | null, +): Promise => { + await v6.post(`/domain/zone/${zoneName}/reset`, { + minimized, + ...(dnsRecords?.length ? { DnsRecords: dnsRecords } : {}), + }); +}; + +export type THostingDetails = { + hostingIp: string; +}; + +export const getHostings = async (): Promise => { + const { data } = await v6.get('/hosting/web'); + return data; +}; + +export const getHostingDetails = async (hosting: string): Promise => { + const { data } = await v6.get(`/hosting/web/${hosting}`); + return data; +}; + +export type TEmailDomain = { + offer: string; +}; + +export const getEmailDomain = async (domain: string): Promise => { + const { data } = await v6.get(`/email/domain/${domain}`); + return data; +}; + +export const getEmailRecommendedDNSRecords = async (domain: string): Promise => { + const { data } = await v6.get(`/email/domain/${domain}/recommendedDNSRecords`); + return data; +}; + +/** + * Export DNS zone as text + */ +export const exportDnsZoneText = async ( + zoneName: string, +): Promise => { + const { data } = await v6.get(`/domain/zone/${zoneName}/export`); + return data; +}; + +/** + * Import DNS zone from text + */ +export const importDnsZoneText = async ( + zoneName: string, + zoneFile: string, +): Promise => { + await v6.post(`/domain/zone/${zoneName}/import`, { + zoneFile, + }); +}; diff --git a/packages/manager/apps/web-domains/src/zone/datas/api.ts b/packages/manager/apps/web-domains/src/zone/datas/api.ts new file mode 100644 index 000000000000..27ef6a45b044 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/datas/api.ts @@ -0,0 +1,132 @@ +import { aapi, v6 } from '@ovh-ux/manager-core-api'; +import { + DomainZoneRecordsResponse, + ZoneRecord, +} from '@/zone/types/zoneRecords.types'; + +export const getDomainZoneRecords = async ( + serviceName: string, + offset: number = 0, + recordsCount: number = 0, +): Promise => { + const { data } = await aapi.get( + `sws/domain/${serviceName}/zone/records`, + { + params: { + offset, + recordsCount, + }, + }, + ); + return data as DomainZoneRecordsResponse; +}; + +export type CreateZoneRecordPayload = { + fieldType: string; + subDomain?: string; + target: string; + ttl?: number; +}; + +/** + * Check that no CNAME record already exists for the given subdomain. + * GET /domain/zone/{serviceName}/record?fieldType=CNAME&subDomain={subDomain} + * If records are returned, a CNAME conflict exists and an error is thrown. + */ +export const validateZoneRecord = async ( + serviceName: string, + subDomain: string, + excludeRecordId?: string, +): Promise => { + const { data } = await v6.get( + `/domain/zone/${serviceName}/record`, + { + params: { + fieldType: 'CNAME', + subDomain: subDomain === '@' ? '' : subDomain ?? '', + }, + }, + ); + const conflictingIds = excludeRecordId + ? data.filter((id) => String(id) !== String(excludeRecordId)) + : data; + if (conflictingIds.length > 0) { + throw new Error('CNAME_ALREADY_EXISTS'); + } +}; + +/** + * Create a zone record. + * POST /domain/zone/{serviceName}/record + */ +export const createZoneRecord = async ( + serviceName: string, + payload: CreateZoneRecordPayload, +): Promise => { + const { data } = await v6.post( + `/domain/zone/${serviceName}/record`, + { + fieldType: payload.fieldType, + subDomain: payload.subDomain ?? '', + target: payload.target, + ttl: payload.ttl, + }, + ); + return data; +}; + +export type UpdateZoneRecordPayload = { + subDomain?: string; + target: string; + ttl?: number; +}; + +/** + * Update a zone record. + * PUT /domain/zone/{serviceName}/record/{recordId} + */ +export const updateZoneRecord = async ( + serviceName: string, + recordId: string, + payload: UpdateZoneRecordPayload, +): Promise => { + await v6.put(`/domain/zone/${serviceName}/record/${recordId}`, { + subDomain: payload.subDomain ?? '', + target: payload.target, + ttl: payload.ttl, + }); +}; + +/** + * Refresh the zone (apply pending changes). + * POST /domain/zone/{serviceName}/refresh + */ +export const refreshZone = async (serviceName: string): Promise => { + await v6.post(`/domain/zone/${serviceName}/refresh`); +}; + +export const deleteDomainZoneRecord = async ( + serviceName: string, + recordId: string, +): Promise => { + await v6.delete(`/domain/zone/${serviceName}/record/${recordId}`); +}; + +/** + * Zone status (deployment state + errors/warnings). + * GET /domain/zone/{serviceName}/status + */ +export type ZoneStatus = { + isDeployed: boolean; + errors: string[]; + warnings: string[]; +}; + +export const getZoneStatus = async ( + serviceName: string, +): Promise => { + const { data } = await v6.get( + `/domain/zone/${serviceName}/status`, + ); + return data; +}; diff --git a/packages/manager/apps/web-domains/src/zone/hooks/data/history.hooks.ts b/packages/manager/apps/web-domains/src/zone/hooks/data/history.hooks.ts new file mode 100644 index 000000000000..22f9fedde049 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/hooks/data/history.hooks.ts @@ -0,0 +1,290 @@ +import { useMutation, useQueries, useQuery } from '@tanstack/react-query'; +import { + downloadZoneFile, + exportDnsZoneText, + getEmailDomain, + getEmailRecommendedDNSRecords, + getHostingDetails, + getHostings, + getZoneHistory, + getZoneHistoryByDate, + getZoneSoa, + importDnsZoneText, + resetZone, + restoreZone, + TZoneSoa, + updateZoneSoa, + DnsRecord, +} from '@/zone/data/api/history.api'; +import { TZoneHistoryWithDate } from '@/zone/types/history.types'; + +/** + * Hook to get zone history dates + */ +export const useGetZoneHistory = (zoneName: string) => { + const isEnabled = !!zoneName; + const { data, isLoading, error } = useQuery({ + queryKey: ['zone', 'history', zoneName], + queryFn: () => getZoneHistory(zoneName), + retry: false, + enabled: isEnabled, + }); + + return { + historyDates: data, + isLoadingHistory: isLoading, + historyError: error, + }; +}; + +/** + * Hook to get zone history with all details + * Fetches dates first, then fetches details for each date + */ +export const useGetZoneHistoryWithDetails = ( + zoneName: string, + limit: number = 30, +) => { + const { historyDates, isLoadingHistory, historyError } = useGetZoneHistory( + zoneName, + ); + + const historyDatesSorted = historyDates ? [...historyDates] : []; + historyDatesSorted.sort( + (a, b) => new Date(b).getTime() - new Date(a).getTime(), + ); + const sortedDates = historyDatesSorted.slice(0, limit); + + // Fetch details for each date + const historyQueries = useQueries({ + queries: sortedDates.map((date) => ({ + queryKey: ['zone', 'history', zoneName, date], + queryFn: () => getZoneHistoryByDate(zoneName, date), + enabled: !!zoneName && !!historyDates && historyDates.length > 0, + retry: false, + })), + }); + + const isLoadingDetails = historyQueries.some((query) => query.isLoading); + const hasErrors = historyQueries.some((query) => query.error); + + const historyWithDetails: TZoneHistoryWithDate[] = historyQueries + .map((query, index) => { + if (!query.data) return null; + return { + ...query.data, + date: sortedDates[index], + id: sortedDates[index], + }; + }) + .filter((item): item is TZoneHistoryWithDate => item !== null); + + return { + history: historyWithDetails, + isLoading: isLoadingHistory || isLoadingDetails, + error: historyError || + (hasErrors ? new Error('Failed to load details') : null), + }; +}; + +/** + * Hook to download zone file + */ +export const useDownloadZoneFile = () => { + return useMutation({ + mutationFn: async ({ + url, + zoneName, + }: { + url: string; + zoneName: string; + }) => { + const content = await downloadZoneFile(url); + const blob = new Blob([content], { type: 'text/plain' }); + const blobUrl = globalThis.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.download = `${zoneName}_dns_zone.txt`; + anchor.href = blobUrl; + anchor.click(); + globalThis.URL.revokeObjectURL(blobUrl); + return content; + }, + }); +}; + +/** + * Hook to fetch zone file content for viewing + */ +export const useViewZoneFile = () => { + return useMutation({ + mutationFn: (url: string) => downloadZoneFile(url), + }); +}; + +/** + * Hook to restore zone from history + */ +export const useRestoreZone = () => { + return useMutation({ + mutationFn: ({ + zoneName, + creationDate, + }: { + zoneName: string; + creationDate: string; + }) => restoreZone(zoneName, creationDate), + }); +}; + +/** + * Hook to get zone SOA (used for default TTL) + */ +export const useGetZoneSoa = (zoneName: string) => { + const { data, isLoading, error } = useQuery({ + queryKey: ['zone', 'soa', zoneName], + queryFn: () => getZoneSoa(zoneName), + enabled: !!zoneName, + retry: false, + }); + + return { + zoneSoa: data, + isLoadingZoneSoa: isLoading, + zoneSoaError: error, + }; +}; + +/** + * Hook to update zone SOA (used for default TTL modification) + */ +export const useUpdateZoneSoa = () => { + return useMutation({ + mutationFn: ({ + zoneName, + soa, + }: { + zoneName: string; + soa: TZoneSoa; + }) => updateZoneSoa(zoneName, soa), + }); +}; + +/** + * Hook to reset a DNS zone + */ +export const useResetZone = () => { + return useMutation({ + mutationFn: ({ + zoneName, + minimized, + dnsRecords, + }: { + zoneName: string; + minimized: boolean; + dnsRecords: DnsRecord[] | null; + }) => resetZone(zoneName, minimized, dnsRecords), + }); +}; + +/** + * Hook to get the list of web hostings + */ +export const useGetHostings = () => { + const { data, isLoading } = useQuery({ + queryKey: ['hosting', 'web'], + queryFn: getHostings, + }); + return { hostings: data ?? [], isLoadingHostings: isLoading }; +}; + +/** + * Hook to get details of a specific web hosting (to retrieve hostingIp) + */ +export const useGetHostingDetails = (hosting: string) => { + const { data, isLoading } = useQuery({ + queryKey: ['hosting', 'web', hosting], + queryFn: () => getHostingDetails(hosting), + enabled: !!hosting, + }); + return { hostingDetails: data, isLoadingHostingDetails: isLoading }; +}; + +/** + * Hook to check if an email domain exists and its offer type + */ +export const useGetEmailDomain = (domain: string) => { + const { data, isLoading, error } = useQuery({ + queryKey: ['email', 'domain', domain], + queryFn: () => getEmailDomain(domain), + enabled: !!domain, + retry: false, + }); + return { emailDomain: data, isLoadingEmailDomain: isLoading, emailDomainError: error }; +}; + +/** + * Hook to get recommended DNS records for an email domain + */ +export const useGetEmailRecommendedDNS = (domain: string, enabled: boolean) => { + const { data, isLoading } = useQuery({ + queryKey: ['email', 'domain', domain, 'recommendedDNS'], + queryFn: () => getEmailRecommendedDNSRecords(domain), + enabled: !!domain && enabled, + retry: false, + }); + return { emailRecommendedDNS: data ?? [], isLoadingEmailDNS: isLoading }; +}; + +/** + * Hook to compare two zone files by downloading them in parallel + */ +export const useCompareZoneFiles = () => { + return useMutation({ + mutationFn: async ({ + baseUrl, + modifiedUrl, + }: { + baseUrl: string; + modifiedUrl: string; + }) => { + const [baseContent, modifiedContent] = await Promise.all([ + downloadZoneFile(baseUrl), + downloadZoneFile(modifiedUrl), + ]); + return { baseContent, modifiedContent }; + }, + }); +}; + +/** + * Hook to export DNS zone as text + */ +export const useExportDnsZoneText = (zoneName: string) => { + const { data, isLoading, error } = useQuery({ + queryKey: ['zone', 'export', zoneName], + queryFn: () => exportDnsZoneText(zoneName), + enabled: !!zoneName, + retry: false, + }); + + return { + zoneText: data, + isLoadingZoneText: isLoading, + zoneTextError: error, + }; +}; + +/** + * Hook to import DNS zone from text + */ +export const useImportDnsZoneText = () => { + return useMutation({ + mutationFn: ({ + zoneName, + zoneFile, + }: { + zoneName: string; + zoneFile: string; + }) => importDnsZoneText(zoneName, zoneFile), + }); +}; diff --git a/packages/manager/apps/web-domains/src/zone/hooks/useAddZoneRecord/useAddZoneRecord.ts b/packages/manager/apps/web-domains/src/zone/hooks/useAddZoneRecord/useAddZoneRecord.ts new file mode 100644 index 000000000000..8c576a7af8d6 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/hooks/useAddZoneRecord/useAddZoneRecord.ts @@ -0,0 +1,57 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNotifications } from '@ovh-ux/manager-react-components'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { + createZoneRecord, + refreshZone, + validateZoneRecord, + type CreateZoneRecordPayload, +} from '@/zone/datas/api'; + +export const useAddZoneRecord = (serviceName: string) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(['zone']); + const { addSuccess, addError, clearNotifications } = useNotifications(); + + const { mutate, isPending } = useMutation({ + mutationFn: async (payload: CreateZoneRecordPayload) => { + // Step 1: Validate syntax via GET before creating + await validateZoneRecord(serviceName, payload.subDomain ?? ''); + + // Step 2: Create the record + const record = await createZoneRecord(serviceName, payload); + + // Step 3: Refresh the zone to apply changes + await refreshZone(serviceName); + + return record; + }, + onSuccess: () => { + // Invalidate the zone records query to refresh the datagrid + queryClient.invalidateQueries({ + queryKey: ['get', 'domain', 'zone', 'records', serviceName], + }); + clearNotifications(); + addSuccess(t('zone_page_form_add_record_success'), true); + }, + onError: (error: ApiError | Error) => { + clearNotifications(); + if (error.message === 'CNAME_ALREADY_EXISTS') { + addError(t('zone_page_form_cname_already_exists'), true); + } else { + const apiMessage = + (error as ApiError)?.response?.data?.message ?? error?.message ?? ''; + addError( + t('zone_page_form_add_record_error', { error: apiMessage }), + true, + ); + } + }, + }); + + return { + addRecord: mutate, + isAddingRecord: isPending, + }; +}; diff --git a/packages/manager/apps/web-domains/src/zone/hooks/useGetDomainZoneRecords/useGetDomainZoneRecords.ts b/packages/manager/apps/web-domains/src/zone/hooks/useGetDomainZoneRecords/useGetDomainZoneRecords.ts new file mode 100644 index 000000000000..46c756bb987b --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/hooks/useGetDomainZoneRecords/useGetDomainZoneRecords.ts @@ -0,0 +1,101 @@ +import { getDomainZoneRecords } from '@/zone/datas/api'; +import { DomainZoneRecordsResponse, PaginatedZoneRecords, ZoneRecord } from '@/zone/types/zoneRecords.types'; +import { useInfiniteQuery, InfiniteData } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; + +type UseGetDomainZoneRecordsData = { + records: ZoneRecord[]; + fieldsTypes: string[]; + paginatedZone: PaginatedZoneRecords; +}; + +export const useGetDomainZoneRecords = (serviceName: string) => { + const [allPages, setAllPages] = useState(false); + + const query = useInfiniteQuery< + DomainZoneRecordsResponse, + Error, + UseGetDomainZoneRecordsData, + string[], + number + >({ + queryKey: ['get', 'domain', 'zone', 'records', serviceName], + initialPageParam: 0, + queryFn: ({ pageParam }) => getDomainZoneRecords(serviceName, pageParam, 100), + getNextPageParam: (lastPage, allPages, lastPageParam) => { + const paginatedZone = lastPage.paginatedZone; + if (!paginatedZone) { + return null; + } + + const totalCount = paginatedZone.count; + const pagination = paginatedZone.pagination; + const currentRecordsCount = paginatedZone.records?.results?.length ?? 0; + + const allFetchedRecords = allPages.flatMap( + (page) => page.paginatedZone?.records?.results ?? [] + ); + const totalFetched = allFetchedRecords.length; + + if (totalFetched >= totalCount) { + return null; + } + + if (pagination && pagination.length > 0) { + const currentIndex = pagination.indexOf(lastPageParam as number); + if (currentIndex >= 0 && currentIndex < pagination.length - 1) { + return pagination[currentIndex + 1]; + } + } + + if (currentRecordsCount > 0) { + const nextOffset = (lastPageParam as number) + currentRecordsCount; + if (nextOffset < totalCount) { + return nextOffset; + } + } + + return null; + }, + select: (data: InfiniteData): UseGetDomainZoneRecordsData => { + const firstPage = data.pages[0]; + const allRecords = data.pages.flatMap( + (page) => page.paginatedZone?.records?.results ?? [] + ); + + return { + records: allRecords, + fieldsTypes: firstPage?.fieldsTypes ?? [], + paginatedZone: firstPage?.paginatedZone ?? { + count: 0, + pagination: [], + records: { results: [] }, + }, + }; + }, + }); + + const fetchAllPages = useCallback(() => { + if (!allPages) { + setAllPages(true); + } + }, [allPages]); + + const refetch = useCallback(() => { + setAllPages(false); + return query.refetch(); + }, [query.refetch]); + + useEffect(() => { + if (allPages && query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage(); + } + }, [allPages, query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); + + return { + ...query, + data: query.data, + fetchAllPages, + refetch, + }; +}; diff --git a/packages/manager/apps/web-domains/src/zone/hooks/useHistoryColumns.spec.tsx b/packages/manager/apps/web-domains/src/zone/hooks/useHistoryColumns.spec.tsx new file mode 100644 index 000000000000..c276aaeb8b27 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/hooks/useHistoryColumns.spec.tsx @@ -0,0 +1,151 @@ +import '@/common/setupTests'; +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, render, fireEvent } from '@testing-library/react'; +import { useTranslation } from 'react-i18next'; +import { wrapper } from '@/common/utils/test.provider'; +import { useHistoryColumns } from '@/zone/hooks/useHistoryColumns'; +import type { TZoneHistoryWithDate } from '@/zone/types/history.types'; + +const mockItem: TZoneHistoryWithDate = { + id: 'history-1', + date: '2024-01-15T10:00:00Z', + creationDate: '2024-01-15T10:00:00Z', + zoneFileUrl: 'https://api.example.com/zone/file/1', +}; + +vi.mock('@/zone/hooks/data/history.hooks', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@/zone/hooks/data/history.hooks') + >(); + return { + ...actual, + useDownloadZoneFile: vi.fn(() => ({ + mutate: vi.fn(), + isPending: false, + })), + }; +}); + +vi.mock('@ovh-ux/manager-react-components', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@ovh-ux/manager-react-components') + >(); + return { + ...actual, + useFormatDate: vi.fn(() => ({ date }: { date: string }) => date), + }; +}); + +describe('useHistoryColumns', () => { + const { t } = useTranslation('zone'); + + it('returns 4 columns', () => { + const { result } = renderHook(() => + useHistoryColumns({ + t, + onView: vi.fn(), + onRestore: vi.fn(), + zoneName: 'example.com', + }), + ); + expect(result.current).toHaveLength(4); + }); + + it('includes creationDate column', () => { + const { result } = renderHook(() => + useHistoryColumns({ + t, + onView: vi.fn(), + onRestore: vi.fn(), + zoneName: 'example.com', + }), + ); + const ids = result.current.map((col) => col.id); + expect(ids).toContain('creationDate'); + }); + + it('includes view column', () => { + const { result } = renderHook(() => + useHistoryColumns({ + t, + onView: vi.fn(), + onRestore: vi.fn(), + zoneName: 'example.com', + }), + ); + const ids = result.current.map((col) => col.id); + expect(ids).toContain('view'); + }); + + it('includes download column', () => { + const { result } = renderHook(() => + useHistoryColumns({ + t, + onView: vi.fn(), + onRestore: vi.fn(), + zoneName: 'example.com', + }), + ); + const ids = result.current.map((col) => col.id); + expect(ids).toContain('download'); + }); + + it('includes restore column', () => { + const { result } = renderHook(() => + useHistoryColumns({ + t, + onView: vi.fn(), + onRestore: vi.fn(), + zoneName: 'example.com', + }), + ); + const ids = result.current.map((col) => col.id); + expect(ids).toContain('restore'); + }); + + it('creationDate column is sortable', () => { + const { result } = renderHook(() => + useHistoryColumns({ + t, + onView: vi.fn(), + onRestore: vi.fn(), + zoneName: 'example.com', + }), + ); + const creationDateCol = result.current.find( + (col) => col.id === 'creationDate', + ); + expect(creationDateCol?.isSortable).toBe(true); + }); + + it('view column is not sortable', () => { + const { result } = renderHook(() => + useHistoryColumns({ + t, + onView: vi.fn(), + onRestore: vi.fn(), + zoneName: 'example.com', + }), + ); + const viewCol = result.current.find((col) => col.id === 'view'); + expect(viewCol?.isSortable).toBe(false); + }); + + it('passes item to onView when view cell is clicked', () => { + const onView = vi.fn(); + const { result } = renderHook(() => + useHistoryColumns({ + t, + onView, + onRestore: vi.fn(), + zoneName: 'example.com', + }), + ); + const viewCol = result.current.find((col) => col.id === 'view'); + // Render the cell and click + const CellComponent = () => viewCol?.cell(mockItem); + const { getByRole } = render(, { wrapper }); + fireEvent.click(getByRole('button')); + expect(onView).toHaveBeenCalledWith(mockItem); + }); +}); diff --git a/packages/manager/apps/web-domains/src/zone/hooks/useHistoryColumns.tsx b/packages/manager/apps/web-domains/src/zone/hooks/useHistoryColumns.tsx new file mode 100644 index 000000000000..4efc70669555 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/hooks/useHistoryColumns.tsx @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import { DatagridColumn } from '@ovh-ux/manager-react-components'; +import { TFunction } from 'i18next'; +import { + CreationDateCell, + DownloadZoneCell, + RestoreZoneCell, + ViewZoneCell, +} from '@/zone/components/HistoryCells'; +import { TZoneHistoryWithDate } from '@/zone/types/history.types'; + +interface UseHistoryColumnsProps { + readonly t: TFunction; + readonly onView: (item: TZoneHistoryWithDate) => void; + readonly onRestore: (item: TZoneHistoryWithDate) => void; + readonly zoneName: string; + readonly activeZoneId?: string; + readonly canRestore?: boolean; +} + +export const useHistoryColumns = ({ + t, + onView, + onRestore, + zoneName, + activeZoneId, + canRestore, +}: UseHistoryColumnsProps): DatagridColumn[] => { + return useMemo( + () => [ + { + id: 'creationDate', + label: t('zone_history_creation_date'), + cell: (item: TZoneHistoryWithDate) => ( + + ), + isSortable: true, + }, + { + id: 'view', + label: t('zone_history_view_label'), + cell: (item: TZoneHistoryWithDate) => ( + + ), + isSortable: false, + }, + { + id: 'download', + label: t('zone_history_download_label'), + cell: (item: TZoneHistoryWithDate) => ( + + ), + isSortable: false, + }, + { + id: 'restore', + label: t('zone_history_restore_label'), + cell: (item: TZoneHistoryWithDate) => ( + + ), + isSortable: false, + }, + ], + [onRestore, onView, zoneName, activeZoneId, canRestore], + ); +}; diff --git a/packages/manager/apps/web-domains/src/zone/hooks/useIsDesktop.spec.ts b/packages/manager/apps/web-domains/src/zone/hooks/useIsDesktop.spec.ts new file mode 100644 index 000000000000..307eb9798142 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/hooks/useIsDesktop.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useIsDesktop } from '@/zone/hooks/useIsDesktop'; + +describe('useIsDesktop', () => { + let mockMatchMedia: ReturnType; + + beforeEach(() => { + mockMatchMedia = vi.fn(); + Object.defineProperty(globalThis, 'matchMedia', { + writable: true, + value: mockMatchMedia, + }); + }); + + it('returns true when viewport matches (min-width: 48em)', () => { + mockMatchMedia.mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const { result } = renderHook(() => useIsDesktop()); + expect(result.current).toBe(true); + }); + + it('returns false when viewport does not match (min-width: 48em)', () => { + mockMatchMedia.mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const { result } = renderHook(() => useIsDesktop()); + expect(result.current).toBe(false); + }); + + it('subscribes to media query changes', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + mockMatchMedia.mockReturnValue({ + matches: true, + addEventListener, + removeEventListener, + }); + const { unmount } = renderHook(() => useIsDesktop()); + expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + unmount(); + expect(removeEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + }); +}); diff --git a/packages/manager/apps/web-domains/src/zone/hooks/useIsDesktop.ts b/packages/manager/apps/web-domains/src/zone/hooks/useIsDesktop.ts new file mode 100644 index 000000000000..c0a0c889ce4e --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/hooks/useIsDesktop.ts @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from 'react'; + +const MD_QUERY = '(min-width: 48em)'; + +function subscribe(cb: () => void) { + const mql = globalThis.matchMedia(MD_QUERY); + mql.addEventListener('change', cb); + return () => mql.removeEventListener('change', cb); +} + +function getSnapshot() { + return globalThis.matchMedia(MD_QUERY).matches; +} + +function getServerSnapshot() { + return true; +} + +export function useIsDesktop() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/packages/manager/apps/web-domains/src/zone/hooks/useUpdateZoneRecord/useUpdateZoneRecord.ts b/packages/manager/apps/web-domains/src/zone/hooks/useUpdateZoneRecord/useUpdateZoneRecord.ts new file mode 100644 index 000000000000..85e01be4c76d --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/hooks/useUpdateZoneRecord/useUpdateZoneRecord.ts @@ -0,0 +1,60 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNotifications } from '@ovh-ux/manager-react-components'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { + updateZoneRecord, + refreshZone, + validateZoneRecord, + type UpdateZoneRecordPayload, +} from '@/zone/datas/api'; + +export const useUpdateZoneRecord = (serviceName: string) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(['zone']); + const { addSuccess, addError, clearNotifications } = useNotifications(); + + const { mutate, isPending } = useMutation({ + mutationFn: async ( + params: UpdateZoneRecordPayload & { + recordId: string; + }, + ) => { + const { recordId, ...payload } = params; + + // Step 1: Validate syntax via GET before updating + await validateZoneRecord(serviceName, payload.subDomain ?? '', recordId); + + // Step 2: Update the record + await updateZoneRecord(serviceName, recordId, payload); + + // Step 3: Refresh the zone to apply changes + await refreshZone(serviceName); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['get', 'domain', 'zone', 'records', serviceName], + }); + clearNotifications(); + addSuccess(t('zone_page_form_modify_record_success'), true); + }, + onError: (error: ApiError | Error) => { + clearNotifications(); + if (error.message === 'CNAME_ALREADY_EXISTS') { + addError(t('zone_page_form_cname_already_exists'), true); + } else { + const apiMessage = + (error as ApiError)?.response?.data?.message ?? error?.message ?? ''; + addError( + t('zone_page_form_modify_record_error', { error: apiMessage }), + true, + ); + } + }, + }); + + return { + updateRecord: mutate, + isUpdatingRecord: isPending, + }; +}; diff --git a/packages/manager/apps/web-domains/src/zone/pages/Layout.tsx b/packages/manager/apps/web-domains/src/zone/pages/Layout.tsx new file mode 100644 index 000000000000..2644d924590c --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/pages/Layout.tsx @@ -0,0 +1,30 @@ +import { useContext, useEffect } from 'react'; + +import { Outlet, useLocation, useMatches, } from 'react-router-dom'; + +import { ShellContext, useOvhTracking, useRouteSynchro } from '@ovh-ux/manager-react-shell-client'; +import { defineCurrentPage } from '@ovh-ux/request-tagger'; + + +export default function Layout() { + const location = useLocation(); + const { shell } = useContext(ShellContext); + const matches = useMatches(); + const { trackCurrentPage } = useOvhTracking(); + useRouteSynchro(); + + useEffect(() => { + const match = matches.slice(-1); + defineCurrentPage(`app.web-domains/zone-${match[0]?.id}`); + trackCurrentPage(); + }, [location, matches, trackCurrentPage]); + + + useEffect(() => { + shell.ux.hidePreloader(); + }, [shell.ux]); + + return ( + + ); +} diff --git a/packages/manager/apps/web-domains/src/zone/pages/zone/Zone.page.spec.tsx b/packages/manager/apps/web-domains/src/zone/pages/zone/Zone.page.spec.tsx new file mode 100644 index 000000000000..edb1b880bf18 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/pages/zone/Zone.page.spec.tsx @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach, Mock } from 'vitest'; +import { render } from '@testing-library/react'; +import '@/common/setupTests'; +import ZonePage from "../zone/Zone.page"; +import { wrapper } from '@/common/utils/test.provider'; +import { useGetDomainResource, useGetDomainZone } from '@/domain/hooks/data/query'; +import { useGetDomainZoneRecords } from '@/zone/hooks/useGetDomainZoneRecords/useGetDomainZoneRecords'; +import { serviceInfoDetail } from '@/domain/__mocks__/serviceInfoDetail'; +import { domainZoneMock } from '@/domain/__mocks__/dnsDetails'; +import { fieldsTypesMock, paginatedZoneRecordsMock, zoneRecordsMock } from '@/zone/__mocks__/records'; +import { useAuthorizationIam } from '@ovh-ux/manager-react-components'; +import { useGetIAMResource } from '@/common/hooks/iam/useGetIAMResource'; + +vi.mock('@/domain/hooks/data/query', () => ({ + useGetDomainResource: vi.fn(), + useGetDomainZone: vi.fn(), +})); + +vi.mock('@/zone/hooks/useGetDomainZoneRecords/useGetDomainZoneRecords', () => ({ + useGetDomainZoneRecords: vi.fn(), +})); + +vi.mock('@/common/hooks/iam/useGetIAMResource', () => ({ + useGetIAMResource: vi.fn(), +})); + +vi.mock('@ovh-ux/manager-react-components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthorizationIam: vi.fn(), + }; +}); + +vi.mock('@/zone/pages/zone/components/ZoneDnsDatagrid', () => ({ + default: () =>
, +})); + +describe('ZonePage', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + }), + }); + + (useGetDomainResource as Mock).mockReturnValue({ + domainResource: serviceInfoDetail, + isFetchingDomainResource: false, + domainResourceError: null, + }); + + (useGetDomainZone as Mock).mockReturnValue({ + domainZone: domainZoneMock, + isFetchingDomainZone: false, + domainZoneError: null, + }); + + (useGetDomainZoneRecords as Mock).mockReturnValue({ + data: { + records: zoneRecordsMock, + fieldsTypes: fieldsTypesMock, + paginatedZone: paginatedZoneRecordsMock, + }, + hasNextPage: false, + fetchNextPage: vi.fn(), + fetchAllPages: vi.fn(), + }); + + (useGetIAMResource as Mock).mockReturnValue({ + data: [{ urn: 'urn:v1:eu:resource:dnsZone:example.com' }], + }); + + (useAuthorizationIam as Mock).mockReturnValue({ + isPending: false, + isAuthorized: true, + isError: false, + isLoading: false, + status: 'success', + data: { + urn: 'urn:v1:eu:resource:dnsZone:example.com', + authorizedActions: [ + 'dnsZone:apiovh:record/get', + 'dnsZone:apiovh:record/create', + 'dnsZone:apiovh:record/delete', + 'dnsZone:apiovh:record/edit', + 'dnsZone:apiovh:soa/edit', + 'dnsZone:apiovh:reset', + 'dnsZone:apiovh:import', + ], + unauthorizedActions: [], + }, + error: undefined, + isLoadingError: false, + isRefetchError: false, + isSuccess: true, + } as unknown as ReturnType); + }); + + it('should render correctly', async () => { + const { getByTestId } = render(, { wrapper }); + + expect(getByTestId('zone-page-description-1')).toBeInTheDocument(); + expect(getByTestId('zone-page-description-1')).toHaveTextContent('zone_page_description'); + + }); +}); diff --git a/packages/manager/apps/web-domains/src/zone/pages/zone/Zone.page.tsx b/packages/manager/apps/web-domains/src/zone/pages/zone/Zone.page.tsx new file mode 100644 index 000000000000..20edfbd6912b --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/pages/zone/Zone.page.tsx @@ -0,0 +1,521 @@ +import BannerStatus from '@/domain/components/BannerStatus/BannerStatus'; +import { urls as domainUrls } from '@/domain/routes/routes.constant'; +import { useGetDomainZoneRecords } from '@/zone/hooks/useGetDomainZoneRecords/useGetDomainZoneRecords'; +import { ZoneRecord } from '@/zone/types/zoneRecords.types'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { + ActionMenu, + DatagridColumn, + Notifications, + useColumnFilters, + useNotifications, +} from '@ovh-ux/muk'; +import { useAuthorizationIam } from '@ovh-ux/manager-react-components'; +import { useGetIAMResource } from '@/common/hooks/iam/useGetIAMResource'; +import { FilterComparator, applyFilters } from '@ovh-ux/manager-core-api'; +import { + Button, + BUTTON_COLOR, + BUTTON_SIZE, + BUTTON_VARIANT, + POPOVER_POSITION, + TEXT_PRESET, + Text, +} from '@ovhcloud/ods-react'; +import { useMemo, useCallback, useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ExpandedState, RowSelectionState } from '@tanstack/react-table'; +import ZoneDnsDatagrid from './components/ZoneDnsDatagrid'; +import UnauthorizedBanner from '@/domain/components/UnauthorizedBanner/UnauthorizedBanner'; +import ResetDrawer from '@/zone/pages/zone/reset/ResetDrawer'; +import QuickAddEntry from '@/zone/pages/zone/components/QuickAddEntry'; +import ZoneBanners from '@/zone/pages/zone/components/ZoneBanners'; +import ModifyTtlModal from '@/zone/pages/zone/modify/ModifyTtl.modal'; +import { + useGetDomainZone, + useGetDomainResource, +} from '@/domain/hooks/data/query'; +import { DeleteEntryModal } from '@/zone/pages/zone/delete/DeleteEntry.modal'; +import { DeleteEntriesModal } from '@/zone/pages/zone/delete/DeleteEntries.modal'; + +function ZonePageInner() { + const { t } = useTranslation(['zone', NAMESPACES.ACTIONS]); + const navigate = useNavigate(); + const { serviceName } = useParams<{ serviceName: string }>(); + const buildUrl = (baseUrl: string) => { + return baseUrl.replace(':serviceName', serviceName || ''); + }; + const { + data, + hasNextPage, + fetchNextPage, + fetchAllPages, + refetch, + } = useGetDomainZoneRecords(serviceName ?? ''); + const [openModal, setOpenModal] = useState< + | 'add-entry' + | 'modify-textual-record' + | 'modify-ttl' + | 'reset' + | 'delete-zone' + | 'delete-entry' + | 'delete-entries' + | null + >(null); + const tabsZone = domainUrls.domainTabZone; + const [searchInput, setSearchInput] = useState(''); + const [showAddEntryDiv, setShowAddEntryDiv] = useState(false); + const [expandedState, setExpandedState] = useState({}); + const [selectedRecord, setSelectedRecord] = useState(null); + const { filters, addFilter, removeFilter } = useColumnFilters(); + const quickAddRef = useRef(null); + + const collapseRow = useCallback((rowId: string) => { + setExpandedState((prev) => { + if (typeof prev === 'boolean') return {}; + const next = { ...prev }; + delete next[rowId]; + return next; + }); + }, []); + + const handleQuickAddSuccess = useCallback(() => { + setShowAddEntryDiv(false); + }, []); + + const handleQuickAddCancel = useCallback(() => { + setShowAddEntryDiv(false); + }, []); + + const handleToggleAddEntry = useCallback(() => { + setExpandedState({}); + setShowAddEntryDiv((prev) => !prev); + }, []); + + const handleAddFilter = useCallback( + (filterProps: Parameters[0]) => { + addFilter(filterProps); + }, + [addFilter], + ); + + const handleRemoveFilter = useCallback( + (filter: Parameters[0]) => { + removeFilter(filter); + }, + [removeFilter], + ); + + const { + domainZone, + isFetchingDomainZone, + domainZoneError, + } = useGetDomainZone(serviceName ?? '', true); + + const { domainResource } = useGetDomainResource(serviceName ?? ''); + + const { + data: dnsZoneIAMResources, + isPending: isIamResourcePending, + } = useGetIAMResource(serviceName ?? '', 'dnsZone'); + const urn = dnsZoneIAMResources?.[0]?.urn; + + // Single IAM call for all zone actions + const { isPending: isIamPending, data: iamResponse } = useAuthorizationIam( + [ + 'dnsZone:apiovh:record/get', + 'dnsZone:apiovh:record/create', + 'dnsZone:apiovh:record/delete', + 'dnsZone:apiovh:record/edit', + 'dnsZone:apiovh:soa/edit', + 'dnsZone:apiovh:reset', + 'dnsZone:apiovh:import', + ], + urn ?? '', + ); + + const authorizedActions = iamResponse?.authorizedActions ?? []; + const isActionAuthorized = useCallback( + (action: string) => + !isIamResourcePending && + !isIamPending && + authorizedActions.includes(action), + [isIamResourcePending, isIamPending, authorizedActions], + ); + + const canReadRecords = isActionAuthorized('dnsZone:apiovh:record/get'); + const canModifyRecords = + isActionAuthorized('dnsZone:apiovh:record/create') && + isActionAuthorized('dnsZone:apiovh:record/delete') && + isActionAuthorized('dnsZone:apiovh:record/edit'); + const canModifyTtl = isActionAuthorized('dnsZone:apiovh:soa/edit'); + const canReset = isActionAuthorized('dnsZone:apiovh:reset'); + const canImportZone = isActionAuthorized('dnsZone:apiovh:import'); + + const { addInfo, clearNotifications } = useNotifications(); + useEffect(() => { + if (isFetchingDomainZone) return; + clearNotifications(); + if (domainZoneError) { + addInfo(t('zone_page_message_no_zone'), false); + } + }, [isFetchingDomainZone, domainZoneError]); + + useEffect(() => { + const hasFilters = filters.length > 0; + const hasSearch = searchInput.trim().length > 0; + + if ((hasFilters || hasSearch) && hasNextPage) { + fetchAllPages(); + } + }, [filters, searchInput, hasNextPage, fetchAllPages]); + + // Display the form between the topbar and the datagrid when the "Add Entry" button is clicked + useEffect(() => { + const topbarContainer = document.querySelector( + '[data-testid="topbar-container"]', + ); + const quickAddDiv = quickAddRef.current; + + if (topbarContainer && quickAddDiv && topbarContainer.parentNode) { + const nextSibling = topbarContainer.nextSibling; + if (nextSibling !== quickAddDiv) { + topbarContainer.parentNode.insertBefore(quickAddDiv, nextSibling); + } + } + }, [showAddEntryDiv]); + + const records = useMemo(() => { + return data?.records ?? []; + }, [data]); + + const availableFieldTypes = useMemo(() => { + return data?.fieldsTypes ?? []; + }, [data]); + + const filteredRecords = useMemo(() => { + let result = records; + + if (filters.length > 0) { + result = applyFilters(result, filters); + } + + if (searchInput.trim()) { + const searchLower = searchInput.toLowerCase(); + result = result.filter((record) => { + const subDomain = record.subDomain?.toLowerCase() || ''; + const ttl = record.ttl?.toString() || ''; + const zoneToDisplay = record.zoneToDisplay?.toLowerCase() || ''; + const fieldType = record.fieldType?.toLowerCase() || ''; + const targetToDisplay = record.targetToDisplay?.toLowerCase() || ''; + return ( + subDomain.includes(searchLower) || + ttl.includes(searchLower) || + zoneToDisplay.includes(searchLower) || + fieldType.includes(searchLower) || + targetToDisplay.includes(searchLower) + ); + }); + } + + return [...result].sort((a, b) => { + const domainA = a.subDomain + ? `${a.subDomain}.${a.zoneToDisplay}` + : a.zoneToDisplay ?? ''; + const domainB = b.subDomain + ? `${b.subDomain}.${b.zoneToDisplay}` + : b.zoneToDisplay ?? ''; + return domainA.localeCompare(domainB); + }); + }, [records, filters, searchInput]); + const actionItems = [ + { + id: 1, + label: t('zone_page_modify_textual'), + onClick: () => navigate(buildUrl(`${tabsZone}/modify-textual-record`)), + isDisabled: !canImportZone, + }, + { + id: 2, + label: t('zone_page_modify_default_ttl'), + onClick: () => setOpenModal('modify-ttl'), + isDisabled: !canModifyTtl, + }, + { + id: 3, + label: t('zone_page_view_history'), + onClick: () => navigate(buildUrl(`${tabsZone}/history`)), + }, + { + id: 4, + label: t('zone_page_reset'), + onClick: () => setOpenModal('reset'), + isDisabled: !canReset, + }, + ]; + + const [rowSelection, setRowSelection] = useState({}); + + const selectedRecordIds = useMemo(() => { + return Object.keys(rowSelection).filter((index) => rowSelection[index]); + }, [rowSelection]); + const hasSelectedRows = selectedRecordIds.length > 0; + + const closeModal = useCallback(() => { + setOpenModal(null); + setSelectedRecord(null); + }, []); + + const zoneModals = ( + <> + {openModal === 'modify-ttl' && ( + + )} + {openModal === 'reset' && [ +
closeModal()} + />, + , + ]} + {openModal === 'delete-entries' && ( + { + closeModal(); + setRowSelection({}); + }} + onRefetch={refetch} + /> + )} + {openModal === 'delete-entry' && ( + + )} + + ); + + const actionItemsDatagrid = (record: ZoneRecord) => [ + { + id: 1, + label: t('zone_page_modify_entry'), + isDisabled: !canModifyRecords, + onClick: () => { + setShowAddEntryDiv(false); + setExpandedState((prev) => { + const prevRecord = prev as Record; + const wasExpanded = !!prevRecord[record.id]; + return wasExpanded ? {} : { [record.id]: true }; + }); + }, + }, + { + id: 2, + label: t('zone_page_delete_entry'), + color: BUTTON_COLOR.critical, + isDisabled: !canModifyRecords, + onClick: () => { + setSelectedRecord(record); + setOpenModal('delete-entry'); + }, + }, + ]; + const columns: DatagridColumn[] = useMemo( + () => [ + { + id: 'subDomain', + header: t('zone_page_subdomain'), + isSearchable: true, + cell: ({ row }) =>
{row.original.subDomain || '@'}
, + }, + { + id: 'fieldType', + accessorKey: 'fieldType', + header: t('zone_page_type'), + label: t('zone_page_type'), + isFilterable: true, + comparator: [FilterComparator.IsEqual, FilterComparator.IsDifferent], + filterOptions: availableFieldTypes + .map((fieldType) => ({ + label: fieldType, + value: fieldType, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + }, + { + id: 'targetToDisplay', + accessorKey: 'targetToDisplay', + header: t('zone_page_target'), + }, + { + id: 'ttl', + accessorKey: 'ttl', + header: t('zone_page_ttl'), + cell: ({ row }) => + row.original.ttl ? row.original.ttl : t('zone_page_ttl_default'), + }, + { + id: 'actions', + cell: ({ row }) => ( + + ), + size: 48, + header: '', + label: '', + }, + ], + [t, availableFieldTypes, openModal, canModifyRecords], + ); + + const handleDeleteClick = useCallback(() => { + setOpenModal('delete-entries'); + }, []); + + return ( + <> + {zoneModals} + + + {!isIamPending && !canReadRecords && } + {!isIamPending && canReadRecords && ( + + )} + {!isFetchingDomainZone && domainZone && canReadRecords && ( + <> +
+
+ + {t('zone_page_description')} + +
+ + {t('zone_page_description_2')} + +
+
+ +
+ + {canModifyRecords && ( + + )} + + {hasSelectedRows && canModifyRecords && ( + + )} +
+ } + data={filteredRecords} + hasNextPage={hasNextPage} + onFetchNextPage={fetchNextPage} + onFetchAllPages={fetchAllPages} + totalCount={data?.paginatedZone?.count ?? 0} + search={{ + onSearch: setSearchInput, + searchInput, + setSearchInput, + }} + filters={{ + add: handleAddFilter, + filters, + remove: handleRemoveFilter, + }} + rowSelection={{ + rowSelection, + setRowSelection, + }} + expandable={{ + expanded: expandedState, + setExpanded: setExpandedState, + }} + renderSubComponent={(row) => ( + collapseRow(row.id)} + onCancel={() => collapseRow(row.id)} + /> + )} + /> + + )} + + {!isFetchingDomainZone && !domainZone && ( +
+
+ +
+
+ )} + + ); +} + +export default function ZonePage() { + const { serviceName } = useParams<{ serviceName: string }>(); + return ; +} diff --git a/packages/manager/apps/web-domains/src/zone/pages/zone/add/Steps/mailType/Dkim.tsx b/packages/manager/apps/web-domains/src/zone/pages/zone/add/Steps/mailType/Dkim.tsx new file mode 100644 index 000000000000..709f9833e794 --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/pages/zone/add/Steps/mailType/Dkim.tsx @@ -0,0 +1,19 @@ +import { useFormContext } from "react-hook-form"; +import { SubDomainField } from "../../components/SubDomainAndTtl.component"; +import { DkimFormContent } from "./DkimForm.component"; +import type { AddEntrySchemaType } from "@/zone/utils/formSchema.utils"; + +export default function Dkim() { + const { control } = useFormContext(); + + return ( +
+ + +
+ ); +} diff --git a/packages/manager/apps/web-domains/src/zone/pages/zone/add/Steps/mailType/DkimForm.component.tsx b/packages/manager/apps/web-domains/src/zone/pages/zone/add/Steps/mailType/DkimForm.component.tsx new file mode 100644 index 000000000000..603b36fcc76d --- /dev/null +++ b/packages/manager/apps/web-domains/src/zone/pages/zone/add/Steps/mailType/DkimForm.component.tsx @@ -0,0 +1,317 @@ +import { + Checkbox, + CheckboxControl, + CheckboxLabel, + FormField, + FormFieldError, + FormFieldLabel, + Input, + Radio, + RadioControl, + RadioGroup, + RadioLabel, + Text, + TEXT_PRESET, + Textarea, +} from "@ovhcloud/ods-react"; +import { NAMESPACES } from "@ovh-ux/manager-common-translations"; +import { Controller, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import type { AddEntrySchemaType } from "@/zone/utils/formSchema.utils"; + +export function DkimFormContent() { + const { t } = useTranslation(["zone", NAMESPACES.FORM]); + const { control, watch, setValue } = useFormContext(); + const dkimPRevoke = watch("dkimPRevoke"); + + return ( +
+ + ( + field.onChange(detail.checked)} + > + + + + {t("zone_page_add_entry_modal_step_2_dkim_label_version")} + + + + )} + /> + + + + + {t("zone_page_add_entry_modal_step_2_dkim_label_granularity")} + + ( + <> + field.onChange(e.target?.value ?? "")} + onBlur={field.onBlur} + invalid={!!error} + /> + {error?.message} + + )} + /> + + + + + {t("zone_page_add_entry_modal_step_2_dkim_label_algorithm_hash")} + +
+ ( + field.onChange(detail.checked)} + > + + + {t("zone_page_add_entry_modal_step_2_dkim_hash_1")} + + + )} + /> + ( + field.onChange(detail.checked)} + > + + + {t("zone_page_add_entry_modal_step_2_dkim_hash_256")} + + + )} + /> +
+
+ + +
+ + {t("zone_page_add_entry_modal_step_2_dkim_label_algorithm_key")} + + ( + field.onChange(detail.checked)} + aria-label={t("zone_page_add_entry_modal_step_2_dkim_label_algorithm_key")} + > + + + )} + /> +
+
+ + + + {t("zone_page_add_entry_modal_step_2_dkim_label_notes")} + + ( + <> + field.onChange(e.target?.value ?? "")} + onBlur={field.onBlur} + invalid={!!error} + /> + {error?.message} + + )} + /> + + + + + {t("zone_page_add_entry_modal_step_2_dkim_label_publickey")} * + + ( + <> +