diff --git a/packages/manager/apps/zimbra/package.json b/packages/manager/apps/zimbra/package.json index 6de9ad384454..662b76834d4b 100644 --- a/packages/manager/apps/zimbra/package.json +++ b/packages/manager/apps/zimbra/package.json @@ -24,6 +24,7 @@ "@ovh-ux/manager-config": "^8.0.0", "@ovh-ux/manager-core-api": "^0.9.0", "@ovh-ux/manager-core-utils": "^0.3.0", + "@ovh-ux/manager-module-order": "^0.7.1", "@ovh-ux/manager-react-components": "^1.41.1", "@ovh-ux/manager-react-core-application": "^0.11.1", "@ovh-ux/manager-react-shell-client": "^0.8.1", @@ -39,6 +40,7 @@ "element-internals-polyfill": "^1.3.10", "i18next": "^23.8.2", "i18next-http-backend": "2.5.0", + "jsurl": "^0.1.5", "react": "^18.2.0", "react-dom": "18.2.0", "react-i18next": "^14.0.5", diff --git a/packages/manager/apps/zimbra/public/translations/accounts/Messages_fr_FR.json b/packages/manager/apps/zimbra/public/translations/accounts/Messages_fr_FR.json index 857183b5e60c..a09cfc37a7bd 100644 --- a/packages/manager/apps/zimbra/public/translations/accounts/Messages_fr_FR.json +++ b/packages/manager/apps/zimbra/public/translations/accounts/Messages_fr_FR.json @@ -13,5 +13,6 @@ "zimbra_account_datagrid_tooltip_modification": "Modifier", "zimbra_account_datagrid_tooltip_delete": "Supprimer", "zimbra_account_account_add": "Créer un compte", + "zimbra_account_account_order": "Commander un compte", "zimbra_domains_tooltip_need_domain": "Veuillez d'abord configurer un domaine" } diff --git a/packages/manager/apps/zimbra/public/translations/accounts/order/Messages_fr_FR.json b/packages/manager/apps/zimbra/public/translations/accounts/order/Messages_fr_FR.json new file mode 100644 index 000000000000..52e84961acb7 --- /dev/null +++ b/packages/manager/apps/zimbra/public/translations/accounts/order/Messages_fr_FR.json @@ -0,0 +1,17 @@ +{ + "zimbra_account_order": "Commander un compte", + "zimbra_account_order_cta_back": "Retour vers mes comptes email", + "zimbra_account_order_title": "Commander vos comptes Zimbra", + "zimbra_account_order_subtitle": "Choisissez vos comptes Zimbra", + "zimbra_account_order_subtitle_commitment": "Durée de l'engagement", + "zimbra_account_order_legal_checkbox": "J’accepte qu’OVH mette en place le service immédiatement après validation de ma commande, et je renonce donc expressément à mon droit d’annulation au titre des dispositions de l’article L.121-21-8 du code de la consommation.", + "zimbra_account_order_no_product_error_message": "Il y a eu un problème lors du chargement du produit lié à votre commande, veuillez nous excuser et réessayer ultérieurement.", + "zimbra_account_order_commitment_month": "mois", + "zimbra_account_order_commitment_months": "mois", + "zimbra_account_order_commitment_available_soon": "Disponible prochainement", + "zimbra_account_order_cta_confirm": "Payer", + "zimbra_account_order_cta_done": "Retour", + "zimbra_account_order_initiated_title": "Commande de vos comptes Zimbra initiée !", + "zimbra_account_order_initiated_subtitle": "Si vous n'avez pas pu finaliser votre commande, merci de la compléter en cliquant sur le lien suivant :", + "zimbra_account_order_initiated_info": "Nous vous informerons de la disponibilité de vos comptes par e-mail." +} diff --git a/packages/manager/apps/zimbra/public/translations/dashboard/Messages_fr_FR.json b/packages/manager/apps/zimbra/public/translations/dashboard/Messages_fr_FR.json index e52b1af6a220..aab62cc9af26 100644 --- a/packages/manager/apps/zimbra/public/translations/dashboard/Messages_fr_FR.json +++ b/packages/manager/apps/zimbra/public/translations/dashboard/Messages_fr_FR.json @@ -9,6 +9,7 @@ "zimbra_dashboard_email_accounts_add": "Créer un compte email", "zimbra_dashboard_email_accounts_edit": "Modifier le compte", "zimbra_dashboard_email_accounts_settings": "Paramètres du compte", + "zimbra_dashboard_email_accounts_order": "Commander des comptes email", "zimbra_dashboard_email_accounts_alias": "Alias", "zimbra_dashboard_email_accounts_alias_add": "Créer un alias", "zimbra_dashboard_email_accounts_alias_delete": "Supprimer un alias", diff --git a/packages/manager/apps/zimbra/src/api/_mock_/index.ts b/packages/manager/apps/zimbra/src/api/_mock_/index.ts index 39c0455f42f9..f10804bf1c9c 100644 --- a/packages/manager/apps/zimbra/src/api/_mock_/index.ts +++ b/packages/manager/apps/zimbra/src/api/_mock_/index.ts @@ -5,3 +5,4 @@ export * from './domain'; export * from './account'; export * from './alias'; export * from './task'; +export * from './order'; diff --git a/packages/manager/apps/zimbra/src/api/_mock_/order.ts b/packages/manager/apps/zimbra/src/api/_mock_/order.ts new file mode 100644 index 000000000000..5eeef9f2e3d9 --- /dev/null +++ b/packages/manager/apps/zimbra/src/api/_mock_/order.ts @@ -0,0 +1,91 @@ +import { + CurrencyCode, + IntervalUnitType, + OvhSubsidiary, +} from '@ovh-ux/manager-react-components'; +import { order } from '@/api/order'; + +export const orderCatalogMock: order.publicOrder.Catalog = { + catalogId: 2464, + locale: { + currencyCode: CurrencyCode.EUR, + subsidiary: OvhSubsidiary.FR, + taxRate: 20, + }, + plans: [ + { + planCode: 'zimbra-account-pp-starter', + invoiceName: 'Zimbra account', + addonFamilies: [], + product: 'zimbra-account', + pricingType: order.cart.GenericProductPricingTypeEnum.RENTAL, + consumptionConfiguration: null, + pricings: [ + { + phase: 0, + capacities: [ + order.cart.GenericProductPricingCapacitiesEnum.INSTALLATION, + order.cart.GenericProductPricingCapacitiesEnum.RENEW, + ], + commitment: 0, + description: 'monthly pricing', + interval: 1, + intervalUnit: IntervalUnitType.month, + quantity: { + min: 1, + max: null, + }, + repeat: { + min: 1, + max: null, + }, + price: 0, + tax: 0, + mode: 'default', + strategy: order.cart.GenericProductPricingStrategyEnum.VOLUME, + mustBeCompleted: false, + type: order.cart.GenericProductPricingTypeEnum.RENTAL, + promotions: [], + engagementConfiguration: null, + }, + ], + configurations: [], + family: null, + blobs: { + commercial: { + name: 'Zimbra Starter', + price: { + precision: 2, + interval: 'P1M', + unit: 'M/account', + }, + }, + meta: { + configurations: [ + { + name: 'domain_choices', + values: [ + { + value: 'new_or_transfer', + }, + { + value: 'use_mine', + }, + ], + }, + ], + }, + }, + }, + ], + products: [ + { + name: 'zimbra-account', + description: 'A zimbra account', + blobs: null, + configurations: [], + }, + ], + addons: [], + planFamilies: [], +}; diff --git a/packages/manager/apps/zimbra/src/api/order/api.ts b/packages/manager/apps/zimbra/src/api/order/api.ts new file mode 100644 index 000000000000..020b1b82504e --- /dev/null +++ b/packages/manager/apps/zimbra/src/api/order/api.ts @@ -0,0 +1,18 @@ +import { v6 } from '@ovh-ux/manager-core-api'; +import { OvhSubsidiary } from '@ovh-ux/manager-react-components'; +import { order } from './type'; + +// GET + +export const getOrderCatalog = async ({ + ovhSubsidiary, + productName, +}: { + ovhSubsidiary: OvhSubsidiary; + productName: string; +}) => { + const { data } = await v6.get( + `/order/catalog/public/${productName}?ovhSubsidiary=${ovhSubsidiary}`, + ); + return data; +}; diff --git a/packages/manager/apps/zimbra/src/api/order/index.ts b/packages/manager/apps/zimbra/src/api/order/index.ts new file mode 100644 index 000000000000..c12dd221e566 --- /dev/null +++ b/packages/manager/apps/zimbra/src/api/order/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export * from './key'; +export * from './type'; +export * from './utils'; diff --git a/packages/manager/apps/zimbra/src/api/order/key.ts b/packages/manager/apps/zimbra/src/api/order/key.ts new file mode 100644 index 000000000000..eeabb48df3b0 --- /dev/null +++ b/packages/manager/apps/zimbra/src/api/order/key.ts @@ -0,0 +1,9 @@ +import { OvhSubsidiary } from '@ovh-ux/manager-react-components'; + +export const getOrderCatalogQueryKey = ({ + ovhSubsidiary, + productName, +}: { + ovhSubsidiary: OvhSubsidiary; + productName: string; +}) => [`get/order/catalog/${productName}/${ovhSubsidiary}`]; diff --git a/packages/manager/apps/zimbra/src/api/order/type.ts b/packages/manager/apps/zimbra/src/api/order/type.ts new file mode 100644 index 000000000000..fe49fdf1f29c --- /dev/null +++ b/packages/manager/apps/zimbra/src/api/order/type.ts @@ -0,0 +1,661 @@ +// TODO: Dupilicate of packages/manager/apps/pci-databases-analytics/src/types/catalog.ts +// share it across projects and remove this +import { + IntervalUnitType, + OvhSubsidiary, + CurrencyCode, +} from '@ovh-ux/manager-react-components'; + +export enum ZimbraPlanCodes { + ZIMBRA_ACCOUNT_PP_STARTER = 'zimbra-account-pp-starter', +} + +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace order { + export namespace cart { + /** Type of a pricing */ + export enum GenericProductPricingTypeEnum { + CONSUMPTION = 'consumption', + PURCHASE = 'purchase', + RENTAL = 'rental', + } + + /** Capacity of a pricing (type) */ + export enum GenericProductPricingCapacitiesEnum { + CONSUMPTION = 'consumption', + DETACH = 'detach', + DOWNGRADE = 'downgrade', + DYNAMIC = 'dynamic', + INSTALLATION = 'installation', + RENEW = 'renew', + UPGRADE = 'upgrade', + } + /** Strategy of a Pricing */ + export enum GenericProductPricingStrategyEnum { + STAIRSTEP = 'stairstep', + TIERED = 'tiered', + VOLUME = 'volume', + } + } + export namespace publicOrder { + export namespace EngagementConfiguration { + /** Strategy applicable at the end of the Engagement */ + export enum EndStrategyEnum { + CANCEL_SERVICE = 'CANCEL_SERVICE', + REACTIVATE_ENGAGEMENT = 'REACTIVATE_ENGAGEMENT', + STOP_ENGAGEMENT_FALLBACK_DEFAULT_PRICE = 'STOP_ENGAGEMENT_FALLBACK_DEFAULT_PRICE', + STOP_ENGAGEMENT_KEEP_PRICE = 'STOP_ENGAGEMENT_KEEP_PRICE', + } + /** Engagement's type, either fully pre-paid (upfront) or periodically paid up to engagement duration (periodic) */ + export enum TypeEnum { + PERIODIC = 'periodic', + UPFRONT = 'upfront', + } + } + /** Describes a Catalog inside a Subsidiary */ + export interface Catalog { + /** List of addons of the catalog */ + addons: order.publicOrder.Plan[]; + /** Identifier of the catalog */ + catalogId: number; + /** Subsidiary specific information */ + locale: order.publicOrder.Locale; + /** List of plan families of the catalog */ + planFamilies: order.publicOrder.PlanFamily[]; + /** List of main plans of the catalog */ + plans: order.publicOrder.Plan[]; + /** List of products of the catalog */ + products: order.publicOrder.Product[]; + } + + /** Describes a PlanFamily for a Catalog */ + export interface PlanFamily { + /** Family name */ + name: string; + } + /** Describes specifics for a given Subsidiary */ + export interface Locale { + /** Currency used by the Subsidiary */ + currencyCode: CurrencyCode; + /** Current Subsidiary */ + subsidiary: OvhSubsidiary; + /** Default VAT rate used by the Subsidiary */ + taxRate: number; + } + /** Describes a Product attached to a Commercial offer */ + export interface Product { + /** Product blobs */ + blobs?: order.publicOrder.ProductBlob; + /** List of possible Configurations for this Commercial offer */ + configurations?: order.publicOrder.Configuration[]; + /** Description of the Product */ + description: string; + /** Identifier of the Product */ + name: string; + } + /** Describes a Commercial offer inside a Catalog */ + export interface Plan { + /** Addon families for this offer */ + addonFamilies: order.publicOrder.AddonFamily[]; + /** Blobs */ + blobs?: order.publicOrder.ProductBlob; + /** List of possible Configurations for this Commercial offer */ + configurations: order.publicOrder.Configuration[]; + /** Configuration when pricing type is consumption */ + consumptionConfiguration?: order.publicOrder.ConsumptionConfiguration; + /** Name of the family this Commercial offer belongs to */ + family?: string; + /** Commercial offer description */ + invoiceName: string; + /** Commercial offer identifier */ + planCode: string; + /** Type of Pricing used by this Commercial offer */ + pricingType: order.cart.GenericProductPricingTypeEnum; + /** List of possible Pricings for this Commercial offer */ + pricings: order.publicOrder.Pricing[]; + /** Identifier of the Product linked to this Commercial offer */ + product: string; + } + /** Describes an Addon family for a Commercial offer */ + export interface AddonFamily { + /** List of Commercial offers that can be ordered as an Addon of the current Commerical offer for the current Family */ + addons?: string[]; + /** Default Commercial offer that can be ordered as an Addon of the current Commercial offer for the current Family */ + default?: string; + /** Whether this Addon family is exclusive and can be ordered only once for the main Commercial offer */ + exclusive?: boolean; + /** Whether this Addon family is mandatory */ + mandatory?: boolean; + /** Family name */ + name: string; + } + /** Describes a Blob */ + export interface ProductBlob { + /** Commercial information for Dedicated Server Product */ + commercial?: order.publicOrder.ProductBlobCommercial; + /** Marketing information for VPS Product */ + marketing?: order.publicOrder.ProductBlobMarketing; + /** Meta blobs for VPS Product */ + meta?: order.publicOrder.ProductBlobMeta; + /** Tags */ + tags?: string[]; + /** Technical information for Dedicated Server Product */ + technical?: order.publicOrder.ProductBlobTechnical; + /** Value for meta blobs */ + value?: string; + } + /** Describes a Commercial blob */ + export interface ProductBlobCommercial { + /** Brick */ + brick?: string; + /** Brick subtype */ + brickSubtype?: string; + /** Connection */ + connection?: order.publicOrder.ProductBlobConnection; + /** Features */ + features?: order.publicOrder.ProductBlobCommercialFeatures[]; + /** Line */ + line?: string; + /** Name */ + name?: string; + /** Price */ + price?: order.publicOrder.ProductBlobCommercialPrice; + /** Range */ + range?: string; + } + /** Describes Features for a commercial blob */ + export interface ProductBlobCommercialFeatures { + /** Name */ + name?: string; + /** Value */ + value?: string; + } + /** Describes a Price for a commercial blob */ + export interface ProductBlobCommercialPrice { + /** Display */ + display?: order.publicOrder.ProductBlobCommercialPriceDisplay; + /** Interval */ + interval?: string; + /** Precision */ + precision?: number; + /** Unit */ + unit?: string; + } + /** Describes a Display a price */ + export interface ProductBlobCommercialPriceDisplay { + /** Value */ + value: string; + } + /** Describes a Connection for a blob for a Dedicated Server */ + export interface ProductBlobConnection { + /** Clients */ + clients: order.publicOrder.ProductBlobConnectionClients; + /** Total */ + total: number; + } + /** Describes Clients for a Connection for a blob for a Dedicated Server */ + export interface ProductBlobConnectionClients { + /** Concurrency */ + concurrency: number; + /** Number */ + number: number; + } + /** Describes a Marketing blob */ + export interface ProductBlobMarketing { + /** Marketing content information for VPS Product */ + content: order.publicOrder.ProductBlobMarketingContent[]; + } + /** Describes a Content for a Marketing blob */ + export interface ProductBlobMarketingContent { + /** Key */ + key: string; + /** Value */ + value: string; + } + /** Describes a Meta blob */ + export interface ProductBlobMeta { + /** Configurations */ + configurations: order.publicOrder.ProductBlobMetaConfigurations[]; + } + /** Describes a Configuration for a meta blob */ + export interface ProductBlobMetaConfigurations { + /** Name */ + name: string; + /** Values */ + values: order.publicOrder.ProductBlobMetaConfigurationsValues[]; + } + /** Describes a Values configuration for a meta blob */ + export interface ProductBlobMetaConfigurationsValues { + /** Blobs */ + blobs?: order.publicOrder.ProductBlob; + /** Value */ + value: string; + } + /** Describes a Technical Blob */ + export interface ProductBlobTechnical { + /** Network informations */ + bandwidth?: order.publicOrder.ProductBlobTechnicalNetwork; + /** Connection */ + connection?: order.publicOrder.ProductBlobConnection; + /** Connection per seconds */ + connectionPerSeconds?: order.publicOrder.ProductBlobTechnicalPerSeconds; + /** CPU informations */ + cpu?: order.publicOrder.ProductBlobTechnicalCPU; + /** Datacenter */ + datacenter?: order.publicOrder.ProductBlobTechnicalDatacenter; + /** Ephemeral local storage */ + ephemeralLocalStorage?: order.publicOrder.ProductBlobTechnicalEphemeralStorage; + /** GPU informations */ + gpu?: order.publicOrder.ProductBlobTechnicalGPU; + /** License informations */ + license?: order.publicOrder.ProductBlobTechnicalLicense; + /** Memory informations */ + memory?: order.publicOrder.ProductBlobTechnicalMemory; + /** Name */ + name?: string; + /** Nodes */ + nodes?: order.publicOrder.ProductBlobTechnicalNodes; + /** NVME */ + nvme?: order.publicOrder.ProductBlobTechnicalNvme; + /** OS */ + os?: order.publicOrder.ProductBlobTechnicalOS; + /** Connection per seconds */ + requestPerSeconds?: order.publicOrder.ProductBlobTechnicalPerSeconds; + /** Hardware informations */ + server?: order.publicOrder.ProductBlobTechnicalServer; + /** Disks informations */ + storage?: order.publicOrder.ProductBlobTechnicalStorage; + /** Throughput */ + throughput?: order.publicOrder.ProductBlobTechnicalThroughput; + /** Virtualization */ + virtualization?: order.publicOrder.ProductBlobTechnicalVirtualization; + /** Volume */ + volume?: order.publicOrder.ProductBlobTechnicalVolume; + /** vRack informations */ + vrack?: order.publicOrder.ProductBlobTechnicalNetwork; + } + /** Describes a CPU for a technical blob */ + export interface ProductBlobTechnicalCPU { + /** CPU Boost */ + boost?: number; + /** CPU Brand */ + brand?: string; + /** Number of cores */ + cores?: number; + /** Customizable */ + customizable?: boolean; + /** Frequency of CPU in GHz */ + frequency?: number; + /** Displayable name */ + model?: string; + /** Number of CPU */ + number?: number; + /** CPU score */ + score?: number; + /** Number of threads */ + threads?: number; + } + /** Describes a Datacenter for a technical Blob */ + export interface ProductBlobTechnicalDatacenter { + /** City */ + city?: string; + /** Country */ + country?: string; + /** Country code */ + countryCode?: OvhSubsidiary; + /** Name */ + name?: string; + /** Region */ + region?: string; + } + /** Describes a Disk for a technical blob */ + export interface ProductBlobTechnicalDisk { + /** Disk capacity in Gb */ + capacity: number; + /** Disk interface */ + interface?: string; + /** Iops */ + iops?: number; + /** Number of disks */ + number?: number; + /** Size unit */ + sizeUnit?: string; + /** Disk specs */ + specs?: string; + /** Disk technology */ + technology?: string; + /** Usage informations */ + usage?: string; + } + /** Describes an Ephemeral Storage for technical blob */ + export interface ProductBlobTechnicalEphemeralStorage { + /** Disk properties */ + disks?: order.publicOrder.ProductBlobTechnicalDisk[]; + } + /** Describes a Frame for a technical blob */ + export interface ProductBlobTechnicalFrame { + /** Dual power supply */ + dualPowerSupply: boolean; + /** Frame model */ + model: string; + /** Frame size */ + size: string; + } + /** Describes a GPU for a technical blob */ + export interface ProductBlobTechnicalGPU { + /** GPU brand */ + brand?: string; + /** GPU memory size */ + memory: order.publicOrder.ProductBlobTechnicalMemory; + /** GPU model */ + model?: string; + /** GPU number */ + number?: number; + /** GPU performance */ + performance?: number; + } + /** Describes a License for a technical Blob */ + export interface ProductBlobTechnicalLicense { + /** Application */ + application?: string; + /** Cores informations */ + cores?: order.publicOrder.ProductBlobTechnicalLicenseCores; + /** CPU */ + cpu?: order.publicOrder.ProductBlobTechnicalCPU; + /** Network informations */ + distribution?: string; + /** Edition informations */ + edition?: string; + /** Family */ + family?: string; + /** Feature */ + feature?: string; + /** Flavor informations */ + flavor?: string; + /** Images informations */ + images?: string[]; + /** Number of account */ + nbOfAccount?: number; + /** Package */ + package?: string; + /** Version informations */ + version?: string; + } + /** Describes license cores for a technical blob */ + export interface ProductBlobTechnicalLicenseCores { + /** Number of cores */ + number: number; + /** Total of cores */ + total?: number; + } + /** Describes a Memory technical Blob */ + export interface ProductBlobTechnicalMemory { + /** Customizable */ + customizable?: boolean; + /** ECC */ + ecc?: boolean; + /** RAM Frequency */ + frequency?: number; + /** Interface */ + interface?: string; + /** RAM Type (DDRx...) */ + ramType?: string; + /** Size of the RAM in Gb */ + size: number; + /** Size unit */ + sizeUnit?: string; + } + /** Describes a Network technical Blob */ + export interface ProductBlobTechnicalNetwork { + /** Network burst */ + burst?: number; + /** Network capacity */ + capacity?: number; + /** Guaranteed Network */ + guaranteed?: boolean; + /** Network interfaces */ + interfaces?: number; + /** Is max? */ + isMax?: boolean; + /** Network level */ + level?: number; + /** Network limit */ + limit?: number; + /** Shared */ + shared?: boolean; + /** Traffic */ + traffic?: number; + /** Unlimited */ + unlimited?: boolean; + } + /** Describes a Node for technical blob */ + export interface ProductBlobTechnicalNodes { + /** Number of nodes */ + number: number; + } + /** Describes a NVME for technical blob */ + export interface ProductBlobTechnicalNvme { + /** Disk properties */ + disks?: order.publicOrder.ProductBlobTechnicalDisk[]; + } + /** Describes an OS for a technical blob */ + export interface ProductBlobTechnicalOS { + /** Distribution */ + distribution?: string; + /** Edition */ + edition?: string; + /** Family */ + family?: string; + /** Version */ + version?: string; + } + /** Describes a connection or request per seconds for a technical blob */ + export interface ProductBlobTechnicalPerSeconds { + /** Total */ + total: number; + /** Unit */ + unit?: string; + } + /** Describes a Raid for a technical blob */ + export interface ProductBlobTechnicalRaid { + /** Card size */ + cardModel?: string; + /** Card size */ + cardSize?: string; + /** Type */ + type: string; + } + /** Describes some technicals informations for a technical blob */ + export interface ProductBlobTechnicalServer { + /** CPU properties */ + cpu: order.publicOrder.ProductBlobTechnicalCPU; + /** Frame properties */ + frame: order.publicOrder.ProductBlobTechnicalFrame; + /** Network */ + network?: order.publicOrder.ProductBlobTechnicalNetwork; + /** Dedicated server series */ + range: string; + /** Services properties */ + services: order.publicOrder.ProductBlobTechnicalServices; + } + /** Describes some technicals informations */ + export interface ProductBlobTechnicalServices { + /** Anti DDOS */ + antiddos: string; + /** Included backup */ + includedBackup: number; + /** SLA */ + sla: number; + } + /** Describes a Storage technical Blob */ + export interface ProductBlobTechnicalStorage { + /** Disk properties */ + disks?: order.publicOrder.ProductBlobTechnicalDisk[]; + /** Hot Swap */ + hotSwap?: boolean; + /** Raid */ + raid?: string; + /** Raid details */ + raidDetails?: order.publicOrder.ProductBlobTechnicalRaid; + } + /** Describes a Throughput for a technical blob */ + export interface ProductBlobTechnicalThroughput { + /** Level */ + level: number; + } + /** Describes a Virtualization for a Technical Blob */ + export interface ProductBlobTechnicalVirtualization { + /** Hypervisor */ + hypervisor?: string; + } + /** Describes a Volume for a technichal blob */ + export interface ProductBlobTechnicalVolume { + /** Capacity */ + capacity: order.publicOrder.ProductBlobTechnicalVolumeCapacity; + /** CPU informations */ + iops: order.publicOrder.ProductBlobTechnicalVolumeIops; + } + /** Describes a Capacity for a Volume for a technichal blob */ + export interface ProductBlobTechnicalVolumeCapacity { + /** Max */ + max: number; + } + /** Describes a Iops for a Volume for a technichal blob */ + export interface ProductBlobTechnicalVolumeIops { + /** Guaranteed */ + guaranteed: boolean; + /** Level */ + level: number; + } + /** Describes the Configuration for a Commercial offer */ + export interface Configuration { + /** Whether the value of this Configuration is custom */ + isCustom: boolean; + /** Whether this Configuration is mandatory */ + isMandatory: boolean; + /** Identifier of the Configuration */ + name: string; + /** Possible values for this Configuration, if not custom */ + values?: string[]; + } + /** Describes consumption configuration for a Plan */ + export interface ConsumptionConfiguration { + /** Consumption billing strategy */ + billingStrategy: order.publicOrder.BillingStrategyEnum; + /** Consumption ping end policy used at end of usage */ + pingEndPolicy?: order.publicOrder.PingEndPolicyEnum; + /** Consumption prorata unit */ + prorataUnit: order.publicOrder.ProrataUnitEnum; + } + /** Enum values for Billing Strategy */ + export enum BillingStrategyEnum { + CUSTOM = 'custom', + DIFF = 'diff', + MAX = 'max', + MAX_RETAIN = 'max_retain', + PING = 'ping', + SUM = 'sum', + } + /** Enum values for Ping End Policy */ + export enum PingEndPolicyEnum { + FULL = 'full', + PRORATA = 'prorata', + } + /** Enum values for Prorata Unit */ + export enum ProrataUnitEnum { + DAY = 'day', + HOUR = 'hour', + MONTH = 'month', + } + /** Describes a Pricing for a Commercial offer */ + export interface Pricing { + /** Capacities of the Pricing, describes what the Pricing can be used for */ + capacities: order.cart.GenericProductPricingCapacitiesEnum[]; + /** Engagement period */ + commitment: number; + /** Pricing description */ + description: string; + /** Engagement Configuration */ + engagementConfiguration?: order.publicOrder.EngagementConfiguration; + /** Length of the interval */ + interval: number; + /** Unit of the interval */ + intervalUnit: IntervalUnitType; + /** Pricing mode */ + mode: string; + /** Pricing must be completed */ + mustBeCompleted: boolean; + /** Phase for the Pricing */ + phase: number; + /** Price, in micro-cents */ + price: number; + /** Promotions */ + promotions?: order.publicOrder.Promotion[]; + /** Describes how many times the Commercial offer can be added to the Cart */ + quantity: order.publicOrder.PricingMinMax; + /** Describes how many times the interval can be repeated */ + repeat: order.publicOrder.PricingMinMax; + /** Pricing strategy */ + strategy: order.cart.GenericProductPricingStrategyEnum; + /** Tax that can be applied, in micro-cents */ + tax: number; + /** Pricing type */ + type: order.cart.GenericProductPricingTypeEnum; + } + /** Describes minimal and maximal values for a Pricing */ + export interface PricingMinMax { + /** Maximal value */ + max?: number; + /** Minimal value */ + min: number; + } + /** Configuration of an engagement triggered by a given pricing */ + export interface EngagementConfiguration { + /** Default action executed once the engagement is fully consumed */ + defaultEndAction: order.publicOrder.EngagementConfiguration.EndStrategyEnum; + /** Engagement's duration */ + duration: string; + /** Engagement type, either fully pre-paid (upfront) or periodically paid up to engagement duration (periodic) */ + type: order.publicOrder.EngagementConfiguration.TypeEnum; + } + /** Describes a Promotion inside a Catalog */ + export interface Promotion { + /** Promotion description */ + description: string; + /** Promotion discount */ + discount: order.publicOrder.PromotionDiscountTotal; + /** Promotion duration */ + duration?: number; + /** Promotion end date using rfc3339 */ + endDate?: string; + /** Is the global quantity of the promotion limited? */ + isGlobalQuantityLimited: boolean; + /** Promotion name */ + name: string; + /** Promotion quantity */ + quantity?: number; + /** Promotion start date using rfc3339 */ + startDate: string; + /** Promotion total */ + total: order.publicOrder.PromotionDiscountTotal; + /** Promotion type */ + type: order.ReductionTypeEnum; + /** Promotion value */ + value: number; + } + /** Describes a Promotion discount or total inside a Catalog */ + export interface PromotionDiscountTotal { + /** Tax */ + tax: number; + /** Value */ + value: number; + } + } + + /** Type of reduction */ + export enum ReductionTypeEnum { + FIXED_AMOUNT = 'fixed_amount', + FORCED_AMOUNT = 'forced_amount', + PERCENTAGE = 'percentage', + } +} diff --git a/packages/manager/apps/zimbra/src/api/order/utils.ts b/packages/manager/apps/zimbra/src/api/order/utils.ts new file mode 100644 index 000000000000..4d850b33d05f --- /dev/null +++ b/packages/manager/apps/zimbra/src/api/order/utils.ts @@ -0,0 +1,37 @@ +import JSURL from 'jsurl'; +import { ZimbraPlanCodes } from './type'; + +export type OrderProduct = { + planCode: string; + quantity: number; + platformId: string; +}; + +export const formatOrderProduct = ({ + planCode, + quantity, + platformId, +}: OrderProduct): Record => { + return { + planCode, + productId: 'zimbra', + quantity, + pricingMode: 'default', + option: [], + configuration: [{ label: 'existing_platform_id', value: platformId }], + }; +}; + +export const generateOrderURL = ({ + baseURL, + products, +}: { + baseURL: string; + products: OrderProduct[]; +}) => { + return `${baseURL}?products=${JSURL.stringify( + products.map(formatOrderProduct), + )}`; +}; + +export const whitelistedPlanCodes = [ZimbraPlanCodes.ZIMBRA_ACCOUNT_PP_STARTER]; diff --git a/packages/manager/apps/zimbra/src/hooks/__test__/useOrderCatalog.spec.tsx b/packages/manager/apps/zimbra/src/hooks/__test__/useOrderCatalog.spec.tsx new file mode 100644 index 000000000000..a61297c56289 --- /dev/null +++ b/packages/manager/apps/zimbra/src/hooks/__test__/useOrderCatalog.spec.tsx @@ -0,0 +1,22 @@ +import { describe, expect } from 'vitest'; +import '@testing-library/jest-dom'; +import { renderHook, waitFor } from '@testing-library/react'; +import { OvhSubsidiary } from '@ovh-ux/manager-react-components'; +import { orderCatalogMock } from '@/api/_mock_'; +import { useOrderCatalog } from '../useOrderCatalog'; +import { wrapper } from '@/utils/test.provider'; + +describe('useOrderCatalog', () => { + it('should return an order catalog for a product', async () => { + const { result } = renderHook( + () => useOrderCatalog({ ovhSubsidiary: OvhSubsidiary.FR }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(orderCatalogMock); + }); +}); diff --git a/packages/manager/apps/zimbra/src/hooks/index.ts b/packages/manager/apps/zimbra/src/hooks/index.ts index f42c7fc916e6..9b9ef08cb837 100644 --- a/packages/manager/apps/zimbra/src/hooks/index.ts +++ b/packages/manager/apps/zimbra/src/hooks/index.ts @@ -11,4 +11,5 @@ export * from './useOverridePage'; export * from './usePlatform'; export * from './useAccount'; export * from './useTasks'; +export * from './useOrderCatalog'; export * from './types'; diff --git a/packages/manager/apps/zimbra/src/hooks/useOrderCatalog.ts b/packages/manager/apps/zimbra/src/hooks/useOrderCatalog.ts new file mode 100644 index 000000000000..a4b29751dee1 --- /dev/null +++ b/packages/manager/apps/zimbra/src/hooks/useOrderCatalog.ts @@ -0,0 +1,22 @@ +import { + useQuery, + UseQueryOptions, + UseQueryResult, +} from '@tanstack/react-query'; +import { OvhSubsidiary } from '@ovh-ux/manager-react-components'; +import { getOrderCatalog, getOrderCatalogQueryKey, order } from '@/api/order'; + +type Props = Omit & { + productName?: string; + ovhSubsidiary: OvhSubsidiary; +}; + +export const useOrderCatalog = (props: Props) => { + const { productName = 'zimbra', ovhSubsidiary, ...options } = props; + + return useQuery({ + ...options, + queryKey: getOrderCatalogQueryKey({ ovhSubsidiary, productName }), + queryFn: () => getOrderCatalog({ ovhSubsidiary, productName }), + }) as UseQueryResult; +}; diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccountSettings.page.tsx b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccountSettings.page.tsx index e8c66551d79a..79316490cd4b 100644 --- a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccountSettings.page.tsx +++ b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccountSettings.page.tsx @@ -37,11 +37,12 @@ import { putZimbraPlatformAccount, } from '@/api/account'; import { DomainType } from '@/api/domain'; -import { formInputRegex } from './account.constants'; import { + ACCOUNT_REGEX, checkValidityField, checkValidityForm, FormTypeInterface, + PASSWORD_REGEX, } from '@/utils'; import queryClient from '@/queryClient'; @@ -75,6 +76,7 @@ export default function EmailAccountSettings({ touched: false, hasError: false, required: true, + validate: ACCOUNT_REGEX, }, domain: { value: '', @@ -98,6 +100,7 @@ export default function EmailAccountSettings({ touched: false, hasError: false, required: !editEmailAccountId, + validate: PASSWORD_REGEX, }, }, ...(editEmailAccountId && { @@ -135,7 +138,7 @@ export default function EmailAccountSettings({ ...form[name], value, touched: true, - hasError: !checkValidityField(name, value, formInputRegex, form), + hasError: !checkValidityField(name, value, form), }; setForm((oldForm) => ({ ...oldForm, ...newForm })); setIsFormValid(checkValidityForm(form)); diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccounts.tsx b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccounts.tsx index 3cb652eb7029..c67af473843a 100644 --- a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccounts.tsx +++ b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccounts.tsx @@ -47,6 +47,7 @@ import { convertOctets, DATAGRID_REFRESH_INTERVAL, DATAGRID_REFRESH_ON_MOUNT, + FEATURE_FLAGS, } from '@/utils'; import { IAM_ACTIONS } from '@/utils/iamAction.constants'; import Loading from '@/components/Loading/Loading'; @@ -163,6 +164,7 @@ export default function EmailAccounts() { const webmailUrl = guidesConstants.GUIDES_LIST.webmail.url; const hrefAddEmailAccount = useGenerateUrl('./add', 'href'); + const hrefOrderEmailAccount = useGenerateUrl('./order', 'href'); return (
@@ -217,35 +219,16 @@ export default function EmailAccounts() {
- {(data?.length > 0 || dataDomains?.length > 0) && ( - - - - - {t('zimbra_account_account_add')} - - )} - {dataDomains?.length === 0 && ( - - + {(data?.length > 0 || dataDomains?.length > 0) && ( + {t('zimbra_account_account_add')} - - - + )} + {dataDomains?.length === 0 && ( + + - {t('zimbra_domains_tooltip_need_domain')} - - - - )} + + + + {t('zimbra_account_account_add')} + + + + {t('zimbra_domains_tooltip_need_domain')} + + + + )} + {FEATURE_FLAGS.ORDER && ( + + {t('zimbra_account_account_order')} + + )} + {isLoading ? ( ) : ( diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccountsOrder.page.tsx b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccountsOrder.page.tsx new file mode 100644 index 000000000000..08325ac6a0ce --- /dev/null +++ b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/EmailAccountsOrder.page.tsx @@ -0,0 +1,469 @@ +import { + Links, + LinkType, + Price, + useNotifications, + OvhSubsidiary, + IntervalUnitType, +} from '@ovh-ux/manager-react-components'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { + ODS_THEME_COLOR_HUE, + ODS_THEME_COLOR_INTENT, + ODS_THEME_TYPOGRAPHY_LEVEL, + ODS_THEME_TYPOGRAPHY_SIZE, +} from '@ovhcloud/ods-common-theming'; +import { + OsdsText, + OsdsButton, + OsdsCheckbox, + OsdsCheckboxButton, + OsdsQuantity, + OsdsInput, + OsdsIcon, + OsdsFormField, + OsdsRadioGroup, + OsdsRadio, + OsdsRadioButton, + OsdsLink, + OsdsTile, +} from '@ovhcloud/ods-components/react'; +import { + ODS_BUTTON_SIZE, + ODS_CHECKBOX_BUTTON_SIZE, + ODS_ICON_NAME, + ODS_ICON_SIZE, + ODS_INPUT_TYPE, + ODS_LINK_REFERRER_POLICY, + ODS_RADIO_BUTTON_SIZE, +} from '@ovhcloud/ods-components'; +import { OdsHTMLAnchorElementTarget } from '@ovhcloud/ods-common-core'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { getExpressOrderURL } from '@ovh-ux/manager-module-order'; +import Loading from '@/components/Loading/Loading'; +import { useOrderCatalog } from '@/hooks/useOrderCatalog'; +import { + order, + ZimbraPlanCodes, + generateOrderURL, + whitelistedPlanCodes, +} from '@/api/order'; +import { + checkValidityField, + checkValidityForm, + FormTypeInterface, +} from '@/utils'; +import { usePlatform } from '@/hooks'; + +type OrderGeneratedTileProps = { + orderURL: string; +}; + +function OrderGeneratedTile({ orderURL }: Readonly) { + const { t } = useTranslation('accounts/order'); + const navigate = useNavigate(); + const goBack = () => { + navigate('..'); + }; + + return ( + <> + + +
+ + {t('zimbra_account_order_initiated_title')} + + + {t('zimbra_account_order_initiated_subtitle')} + + + {orderURL} + + + + + + {t('zimbra_account_order_initiated_info')} + +
+
+
+ + {t('zimbra_account_order_cta_done')} + + + ); +} + +type OrderCatalogFormProps = { + catalog: order.publicOrder.Catalog; + locale: string; + orderBaseURL: string; +}; + +function OrderCatalogForm({ + catalog, + locale, + orderBaseURL, +}: Readonly) { + const { t } = useTranslation('accounts/order'); + const { platformId } = usePlatform(); + const [orderURL, setOrderURL] = useState(''); + const plans = useMemo(() => { + return (catalog?.plans || []) + .filter((plan) => + whitelistedPlanCodes.includes(plan.planCode as ZimbraPlanCodes), + ) + .map((plan) => { + return { + ...plan, + monthly: plan.pricings.find( + (pricing) => + pricing.interval === 1 && + pricing.intervalUnit === IntervalUnitType.month, + ), + }; + }); + }, [catalog]); + + const [isFormValid, setIsFormValid] = useState(false); + const [form, setForm] = useState({ + consent: { + value: '', + touched: false, + required: true, + validate: /checked/, + }, + commitment: { + value: '1', + touched: false, + required: true, + }, + ...plans.reduce((prev, curr) => { + return { + ...prev, + [curr.planCode]: { + value: '0', + required: false, + touched: false, + validate: (value) => Number(value) >= 0, + }, + }; + }, {}), + }); + + const handleFormChange = (name: string, value: string) => { + const newForm: FormTypeInterface = form; + newForm[name] = { + required: true, + ...form[name], + value, + touched: true, + hasError: !checkValidityField(name, value, form), + }; + const atLeastOneProductSelected = Object.entries(newForm) + .filter(([key]) => whitelistedPlanCodes.includes(key as ZimbraPlanCodes)) + .some(([, item]) => Number(item.value) > 0); + setForm((oldForm) => ({ ...oldForm, ...newForm })); + setIsFormValid(checkValidityForm(form) && atLeastOneProductSelected); + }; + + const handleConfirm = () => { + const products = Object.entries(form) + .filter(([key]) => whitelistedPlanCodes.includes(key as ZimbraPlanCodes)) + .map(([key, { value }]) => { + return { + planCode: key, + quantity: Number(value) || 1, + platformId, + }; + }); + setOrderURL(generateOrderURL({ baseURL: orderBaseURL, products })); + }; + + useEffect(() => { + if (orderURL) { + window.open(orderURL, '_blank', 'noopener,noreferrer'); + } + }, [orderURL]); + + if (!catalog) { + return <>; + } + + if (orderURL) { + return ; + } + + return ( +
+ + {t('zimbra_account_order_subtitle')} + + {plans.flatMap(({ planCode, blobs, monthly }, index) => { + const quantity = Number(form[planCode].value); + return ( +
+ + {blobs?.commercial?.name} + + + + + + { + const { name, value } = detail; + handleFormChange(name, value); + }} + /> + + + + + +
+ ); + })} +
+ + {t('zimbra_account_order_subtitle_commitment')} + + + + + + + + {`1 ${t('zimbra_account_order_commitment_month')}`} + + + + + + + + + {`12 ${t('zimbra_account_order_commitment_months')}`} + + + {t('zimbra_account_order_commitment_available_soon')} + + + + + + +
+ + + handleFormChange( + 'consent', + form.consent.value === 'checked' ? '' : 'checked', + ) + } + > + + + + {t('zimbra_account_order_legal_checkbox')} + + + + + + + {t('zimbra_account_order_cta_confirm')} + +
+ ); +} + +export default function EmailAccountsOrder() { + const { addError } = useNotifications(); + const { t } = useTranslation('accounts/order'); + const context = useContext(ShellContext); + const locale = context.environment.getUserLocale(); + const ovhSubsidiary = context.environment.getUser() + .ovhSubsidiary as OvhSubsidiary; + const region = context.environment.getRegion(); + const orderBaseURL = getExpressOrderURL(region, ovhSubsidiary); + + const navigate = useNavigate(); + + const goBack = () => { + navigate('..'); + }; + + const { data: catalog, isLoading, isError, error } = useOrderCatalog({ + productName: 'zimbra', + ovhSubsidiary, + }); + + useEffect(() => { + if (isError && error) { + addError( + + {t('zimbra_account_order_no_product_error_message')} + , + true, + ); + } + }, [isError, error]); + + return ( +
+ + + {t('zimbra_account_order_title')} + + {isLoading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/ModalAddAlias.component.tsx b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/ModalAddAlias.component.tsx index f04309640671..f183b71fd19a 100644 --- a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/ModalAddAlias.component.tsx +++ b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/ModalAddAlias.component.tsx @@ -24,8 +24,8 @@ import { getZimbraPlatformAliasQueryKey, postZimbraPlatformAlias, } from '@/api/alias'; -import { formInputRegex } from './account.constants'; import { + ACCOUNT_REGEX, checkValidityField, checkValidityForm, FormTypeInterface, @@ -49,6 +49,7 @@ export default function ModalAddAndEditOrganization() { hasError: false, required: true, touched: false, + validate: ACCOUNT_REGEX, }, domain: { value: '', @@ -129,7 +130,7 @@ export default function ModalAddAndEditOrganization() { ...form[name], value, touched: true, - hasError: !checkValidityField(name, value, formInputRegex, form), + hasError: !checkValidityField(name, value, form), }; setForm((oldForm) => ({ ...oldForm, ...newForm })); setIsFormValid(checkValidityForm(form)); diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/__test__/EmailAccountsOrder.spec.tsx b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/__test__/EmailAccountsOrder.spec.tsx new file mode 100644 index 000000000000..d27f1aa78b1d --- /dev/null +++ b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/__test__/EmailAccountsOrder.spec.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import 'element-internals-polyfill'; +import '@testing-library/jest-dom'; +import { describe, expect, vi } from 'vitest'; +import { fireEvent, render, waitFor, act } from '@/utils/test.provider'; +import EmailAccountsOrder from '../EmailAccountsOrder.page'; +import emailAccountOrderTranslation from '@/public/translations/accounts/order/Messages_fr_FR.json'; + +describe('email account order page', () => { + it('should render page correctly', async () => { + const { getByTestId, queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId('spinner')).toBeNull(); + }); + + expect(getByTestId('page-title')).toHaveTextContent( + emailAccountOrderTranslation.zimbra_account_order_title, + ); + }); + + it('should have a correct form validation and call window open on confirm', async () => { + const { getByTestId, queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId('spinner')).toBeNull(); + }); + + const windowOpen = vi.spyOn(window, 'open'); + const button = getByTestId('order-account-confirm-btn'); + const consent = getByTestId('checkbox-consent'); + const quantityPlus = getByTestId('quantity-plus'); + const quantityMinus = getByTestId('quantity-minus'); + + expect(button).toBeDisabled(); + + await act(() => { + // on + fireEvent.click(consent); + }); + + expect(button).toBeDisabled(); + + await act(() => { + // off + fireEvent.click(consent); + // quantity 1 + fireEvent.click(quantityPlus); + }); + + expect(button).toBeDisabled(); + + await act(() => { + // on + fireEvent.click(consent); + // quantity 0 + fireEvent.click(quantityMinus); + }); + + expect(button).toBeDisabled(); + + await act(() => { + // quantity 1 + fireEvent.click(quantityPlus); + }); + + expect(button).toBeEnabled(); + + await act(() => { + fireEvent.click(button); + }); + + expect(windowOpen).toHaveBeenCalledOnce(); + + const orderGeneratedTile = getByTestId('order-generated-tile'); + + expect(orderGeneratedTile).toBeVisible(); + }); +}); diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/account.constants.ts b/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/account.constants.ts deleted file mode 100644 index 8eb4e82b4c13..000000000000 --- a/packages/manager/apps/zimbra/src/pages/dashboard/EmailAccounts/account.constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { FormInputRegexInterface } from '@/utils'; - -export const formInputRegex: FormInputRegexInterface = { - alias: /^(?:[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)(?:(?:[.|+])(?:[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*))*$/, - account: /^(?:[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)(?:(?:[.|+])(?:[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*))*$/, - password: /^(?=.*[\d!@#$€%^&*()\\[\]{}\-_+=~`|:;"'<>,./?])(?=.*[A-Z])(?=(.*)).{10,64}$/, -}; diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/MailingLists/MailingListSettings.page.tsx b/packages/manager/apps/zimbra/src/pages/dashboard/MailingLists/MailingListSettings.page.tsx index ded5e55e2a32..c39484b35da2 100644 --- a/packages/manager/apps/zimbra/src/pages/dashboard/MailingLists/MailingListSettings.page.tsx +++ b/packages/manager/apps/zimbra/src/pages/dashboard/MailingLists/MailingListSettings.page.tsx @@ -48,11 +48,12 @@ import { getZimbraPlatformMailingListsQueryKey, } from '@/api/mailinglist'; import { DomainType } from '@/api/domain'; -import { formInputRegex } from './mailingList.constants'; import { + ACCOUNT_REGEX, checkValidityField, checkValidityForm, FormTypeInterface, + OWNER_REGEX, } from '@/utils'; import queryClient from '@/queryClient'; @@ -119,6 +120,7 @@ export default function MailingListSettings({ value: '', touched: false, required: true, + validate: ACCOUNT_REGEX, }, domain: { value: '', @@ -134,6 +136,7 @@ export default function MailingListSettings({ value: '', touched: false, required: true, + validate: OWNER_REGEX, }, language: { value: '', @@ -202,7 +205,7 @@ export default function MailingListSettings({ ...form[name], value, touched: true, - hasError: !checkValidityField(name, value, formInputRegex, form), + hasError: !checkValidityField(name, value, form), }; setForm((oldForm) => ({ ...oldForm, ...newForm })); setIsFormValid(checkValidityForm(form)); @@ -429,6 +432,7 @@ export default function MailingListSettings({ { +describe('mailing lists add and edit page', async () => { const editMailingListId = mailingListsMock[0].id; it('should be in add mode if no editMailingListId param', async () => { @@ -37,13 +35,13 @@ describe('mailing lists add and edit page', () => { const inputAccount = getByTestId('input-account'); const selectDomain = getByTestId('select-domain'); const inputOwner = getByTestId('input-owner'); - const replyTo = getByTestId('radio-group-reply-to'); + const replyToList = getByTestId('list'); const selectLanguage = getByTestId('select-language'); - const moderationOption = getByTestId('radio-group-moderation-option'); + const moderationOption = getByTestId('all'); - expect(button).not.toBeEnabled(); + expect(button).toBeDisabled(); - act(() => { + await act(() => { inputAccount.odsInputBlur.emit({ name: 'account', value: '' }); inputOwner.odsInputBlur.emit({ name: 'owner', value: '' }); }); @@ -52,7 +50,7 @@ describe('mailing lists add and edit page', () => { expect(inputOwner).toHaveAttribute('color', 'error'); expect(button).not.toBeEnabled(); - act(() => { + await act(() => { inputAccount.odsValueChange.emit({ name: 'account', value: 'account' }); selectDomain.odsValueChange.emit({ name: 'domain', value: 'domain' }); inputOwner.odsValueChange.emit({ @@ -60,27 +58,20 @@ describe('mailing lists add and edit page', () => { value: 'testowner', }); selectLanguage.odsValueChange.emit({ name: 'language', value: 'FR' }); - replyTo.odsValueChange.emit({ - name: 'defaultReplyTo', - value: ReplyToChoices.LIST, - }); - moderationOption.odsValueChange.emit({ - name: 'moderationOption', - value: ModerationChoices.ALL, - }); + fireEvent.click(replyToList); + fireEvent.click(moderationOption); }); expect(inputAccount).toHaveAttribute('color', 'default'); expect(inputOwner).toHaveAttribute('color', 'default'); expect(button).toBeEnabled(); - act(() => { + await act(() => { inputOwner.odsValueChange.emit({ name: 'owner', value: 't', }); }); - expect(button).not.toBeEnabled(); }); diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/MailingLists/mailingList.constants.ts b/packages/manager/apps/zimbra/src/pages/dashboard/MailingLists/mailingList.constants.ts deleted file mode 100644 index ec21810871c8..000000000000 --- a/packages/manager/apps/zimbra/src/pages/dashboard/MailingLists/mailingList.constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { FormInputRegexInterface } from '@/utils'; - -export const formInputRegex: FormInputRegexInterface = { - account: /^(?:[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)(?:(?:[.|+])(?:[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*))*$/, - owner: /^[A-Za-z0-9]{2,20}$/, -}; diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/Organizations/ModalAddAndEditOrganization.page.tsx b/packages/manager/apps/zimbra/src/pages/dashboard/Organizations/ModalAddAndEditOrganization.page.tsx index 90e2ee471642..777ce74b1ebf 100644 --- a/packages/manager/apps/zimbra/src/pages/dashboard/Organizations/ModalAddAndEditOrganization.page.tsx +++ b/packages/manager/apps/zimbra/src/pages/dashboard/Organizations/ModalAddAndEditOrganization.page.tsx @@ -51,23 +51,20 @@ export default function ModalAddAndEditOrganization() { const goBackUrl = useGenerateUrl('..', 'path'); const onClose = () => navigate(goBackUrl); - const formInputRegex: FormInputRegexInterface = { - name: /^.+$/, - label: /^.{1,12}$/, - }; - const [form, setForm] = useState({ name: { value: '', touched: false, hasError: false, required: true, + validate: /^.+$/, }, label: { value: '', touched: false, hasError: false, required: true, + validate: /^.{1,12}$/, }, }); @@ -144,7 +141,7 @@ export default function ModalAddAndEditOrganization() { ...form[name], value, touched: true, - hasError: !checkValidityField(name, value, formInputRegex, form), + hasError: !checkValidityField(name, value, form), }; setForm((oldForm) => ({ ...oldForm, ...newForm })); setIsFormValid(checkValidityForm(form)); diff --git a/packages/manager/apps/zimbra/src/pages/dashboard/Redirections/ModalAddAndEditRedirections.page.tsx b/packages/manager/apps/zimbra/src/pages/dashboard/Redirections/ModalAddAndEditRedirections.page.tsx index 089e94627ce6..3980f248b70e 100644 --- a/packages/manager/apps/zimbra/src/pages/dashboard/Redirections/ModalAddAndEditRedirections.page.tsx +++ b/packages/manager/apps/zimbra/src/pages/dashboard/Redirections/ModalAddAndEditRedirections.page.tsx @@ -24,7 +24,6 @@ import Modal from '@/components/Modals/Modal'; import { useAccount, useDomains, useGenerateUrl } from '@/hooks'; import { FormTypeInterface, - FormInputRegexInterface, checkValidityField, checkValidityForm, ACCOUNT_REGEX, @@ -57,17 +56,13 @@ export default function ModalAddAndEditRedirections() { enabled: !!editEmailAccountId, }); - const formInputRegex: FormInputRegexInterface = { - account: ACCOUNT_REGEX, - to: EMAIL_REGEX, - }; - const [form, setForm] = useState({ account: { value: '', touched: false, hasError: false, required: !editEmailAccountId, + validate: ACCOUNT_REGEX, }, domain: { value: '', @@ -80,6 +75,7 @@ export default function ModalAddAndEditRedirections() { touched: false, hasError: false, required: true, + validate: EMAIL_REGEX, }, checked: { value: '', @@ -95,7 +91,7 @@ export default function ModalAddAndEditRedirections() { value, touched: true, required: form[name].required, - hasError: !checkValidityField(name, value, formInputRegex, form), + hasError: !checkValidityField(name, value, form), }; setForm((oldForm) => ({ ...oldForm, ...newForm })); setIsFormValid(checkValidityForm(form)); diff --git a/packages/manager/apps/zimbra/src/routes/routes.tsx b/packages/manager/apps/zimbra/src/routes/routes.tsx index 0ea36f3af209..90939ffc7430 100644 --- a/packages/manager/apps/zimbra/src/routes/routes.tsx +++ b/packages/manager/apps/zimbra/src/routes/routes.tsx @@ -192,6 +192,15 @@ export const Routes: any = [ ), ), }, + { + path: 'order', + ...lazyRouteConfig(() => + import( + '@/pages/dashboard/EmailAccounts/EmailAccountsOrder.page' + ), + ), + handle: { isOverridePage: true }, + }, ], }, { diff --git a/packages/manager/apps/zimbra/src/utils/form.ts b/packages/manager/apps/zimbra/src/utils/form.ts index e62b67c69c29..8979477120a0 100644 --- a/packages/manager/apps/zimbra/src/utils/form.ts +++ b/packages/manager/apps/zimbra/src/utils/form.ts @@ -3,6 +3,7 @@ export type FieldType = { touched: boolean; hasError?: boolean; required?: boolean; + validate?: ((value: string) => boolean) | RegExp; }; export interface FormTypeInterface { @@ -16,13 +17,29 @@ export interface FormInputRegexInterface { export const checkValidityField = ( name: string, value: string, - formInputRegex: FormInputRegexInterface, form: FormTypeInterface, ) => { - return formInputRegex[name] - ? formInputRegex[name].test(value) || - (!form[name].required && form[name].value === '') - : true; + const field = form[name]; + + if (!field) { + throw new Error( + `checkValidityField field is not defined for name "${name}"`, + ); + } + + if (!field.required && !value) { + return true; + } + + if (typeof field.validate === 'function') { + return field.validate(value); + } + + if (field.validate instanceof RegExp) { + return field.validate.test(String(value)); + } + + return !field.required || !!value; }; export const checkValidityForm = (form: FormTypeInterface) => { @@ -36,3 +53,7 @@ export const checkValidityForm = (form: FormTypeInterface) => { export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; export const ACCOUNT_REGEX = /^(?:[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)(?:(?:[.|+])(?:[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*))*$/; + +export const PASSWORD_REGEX = /^(?=.*[\d!@#$€%^&*()\\[\]{}\-_+=~`|:;"'<>,./?])(?=.*[A-Z])(?=(.*)).{10,64}$/; + +export const OWNER_REGEX = /^[A-Za-z0-9]{2,20}$/; diff --git a/packages/manager/apps/zimbra/src/utils/test.provider.tsx b/packages/manager/apps/zimbra/src/utils/test.provider.tsx index 73a0afa2c379..e314dd6a48d4 100644 --- a/packages/manager/apps/zimbra/src/utils/test.provider.tsx +++ b/packages/manager/apps/zimbra/src/utils/test.provider.tsx @@ -23,6 +23,7 @@ import accountAliasTranslation from '@/public/translations/accounts/alias/Messag import accountAliasAddTranslation from '@/public/translations/accounts/alias/add/Messages_fr_FR.json'; import accountAliasDeleteTranslation from '@/public/translations/accounts/alias/delete/Messages_fr_FR.json'; import accountDeleteTranslation from '@/public/translations/accounts/delete/Messages_fr_FR.json'; +import accountOrderTranslation from '@/public/translations/accounts/order/Messages_fr_FR.json'; import mailingListsTranslation from '@/public/translations/mailinglists/Messages_fr_FR.json'; import mailingListsAddAndEditTranslation from '@/public/translations/mailinglists/addAndEdit/Messages_fr_FR.json'; import redirectionsTranslation from '@/public/translations/redirections/Messages_fr_FR.json'; @@ -52,6 +53,7 @@ i18n.use(initReactI18next).init({ 'accounts/alias/add': accountAliasAddTranslation, 'accounts/alias/delete': accountAliasDeleteTranslation, 'accounts/delete': accountDeleteTranslation, + 'accounts/order': accountOrderTranslation, mailinglists: mailingListsTranslation, 'mailinglists/addAndEdit': mailingListsAddAndEditTranslation, redirections: redirectionsTranslation, @@ -69,6 +71,8 @@ export const getShellContext = () => { getUser: () => ({ ovhSubsidiary: 'FR', }), + getRegion: () => 'EU', + getUserLocale: () => 'fr_FR', }, shell: { routing: { diff --git a/packages/manager/apps/zimbra/src/utils/test.setup.tsx b/packages/manager/apps/zimbra/src/utils/test.setup.tsx index 24fac8758bd0..715edee6eb70 100644 --- a/packages/manager/apps/zimbra/src/utils/test.setup.tsx +++ b/packages/manager/apps/zimbra/src/utils/test.setup.tsx @@ -8,9 +8,11 @@ import { taskMocks, aliasMock, domainZone, + orderCatalogMock, } from '@/api/_mock_'; import { AccountType } from '@/api/account'; import { DomainType } from '@/api/domain'; +import {} from '@/api/_mock_/order'; const mocksAxios = vi.hoisted(() => ({ get: vi.fn(), @@ -163,6 +165,15 @@ vi.mock('@/api/task', async (importActual) => { }; }); +vi.mock('@/api/order', async (importActual) => { + return { + ...(await importActual()), + getOrderCatalog: vi.fn(() => { + return Promise.resolve(orderCatalogMock); + }), + }; +}); + vi.mock('@ovh-ux/manager-react-components', async (importOriginal) => { return { ...(await importOriginal<