From d3f2cf59d0edb3f53dfc75c78fd7af5c6b29aa28 Mon Sep 17 00:00:00 2001 From: Louis BENSI Date: Thu, 20 Aug 2026 16:20:47 +0200 Subject: [PATCH] feat(web-ongoing-operations): add designated agent foa validation ref: #D2I-6259 Signed-off-by: Louis BENSI Co-Authored-By: Claude Opus 5 (1M context) --- .../dashboard/Messages_de_DE.json | 11 +- .../dashboard/Messages_en_GB.json | 11 +- .../dashboard/Messages_es_ES.json | 11 +- .../dashboard/Messages_fr_CA.json | 11 +- .../dashboard/Messages_fr_FR.json | 11 +- .../dashboard/Messages_it_IT.json | 11 +- .../dashboard/Messages_pl_PL.json | 11 +- .../dashboard/Messages_pt_PT.json | 11 +- .../Dashboard/DashboardPage.spec.tsx | 6 + .../components/Foa/FoaActions.component.tsx | 156 +++++++++++++ .../OngoingOperationDatagridActions.spec.tsx | 123 ++++++++++ .../OngoingOperationDatagridActions.tsx | 71 ++++++ .../web-ongoing-operations/src/constants.ts | 3 + .../src/data/api/foa.spec.ts | 87 +++++++ .../src/data/api/foa.ts | 60 +++++ .../src/enum/foa.enum.ts | 4 + .../src/hooks/data/query.tsx | 51 ++++ .../useOngoingOperationDatagridColumns.tsx | 54 +---- .../pages/dashboard/allDom/AllDom.spec.tsx | 6 + .../src/pages/dashboard/dns/Dns.spec.tsx | 6 + .../pages/dashboard/domain/Domain.spec.tsx | 6 + .../src/pages/foa/FoaValidation.spec.tsx | 219 ++++++++++++++++++ .../src/pages/foa/FoaValidation.tsx | 67 ++++++ .../src/routes/routes.constant.ts | 1 + .../src/routes/routes.tsx | 14 ++ .../web-ongoing-operations/src/setupTests.tsx | 20 +- .../web-ongoing-operations/src/types/index.ts | 27 +++ .../src/utils/foa.utils.spec.ts | 57 +++++ .../src/utils/foa.utils.ts | 26 +++ 29 files changed, 1091 insertions(+), 61 deletions(-) create mode 100644 packages/manager/apps/web-ongoing-operations/src/components/Foa/FoaActions.component.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.spec.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/data/api/foa.spec.ts create mode 100644 packages/manager/apps/web-ongoing-operations/src/data/api/foa.ts create mode 100644 packages/manager/apps/web-ongoing-operations/src/enum/foa.enum.ts create mode 100644 packages/manager/apps/web-ongoing-operations/src/pages/foa/FoaValidation.spec.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/pages/foa/FoaValidation.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.spec.ts create mode 100644 packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.ts diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_de_DE.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_de_DE.json index 688c67330ed1..5543ee2be064 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_de_DE.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_de_DE.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "Steuernummer", "domain_operations_update_identificationnumber": "Identifikationsnummer", "domain_operations_update_nicowner_click_whoiscontactbilling": "Ändern Sie die Informationen des Rechnungskontakts, indem Sie hier klicken", - "domain_operations_update_nicowner_click_whoiscontacttech": "Ändern Sie die Informationen des technischen Kontakts, indem Sie hier klicken" + "domain_operations_update_nicowner_click_whoiscontacttech": "Ändern Sie die Informationen des technischen Kontakts, indem Sie hier klicken", + "domain_operations_foa_cta": "Als benannter Vertreter bestätigen", + "domain_operations_foa_title": "Bestätigung als benannter Vertreter für die Domain {{t0}}", + "domain_operations_foa_description": "Dieser Inhaberwechsel wartet auf die Beantwortung der an die Inhaber gesendeten Autorisierungsformulare (FOA). Als benannter Vertreter können Sie diesen Wechsel in ihrem Namen akzeptieren oder ablehnen. Es werden nur die noch unbeantworteten Formulare validiert.", + "domain_operations_foa_certification": "Ich bestätige, dass ich ordnungsgemäß bevollmächtigt bin, als benannter Vertreter zu handeln und im Namen des aktuellen und des neuen Inhabers zu antworten.", + "domain_operations_foa_accept": "Akzeptieren", + "domain_operations_foa_reject": "Ablehnen", + "domain_operations_foa_accept_success": "Der Inhaberwechsel wurde als benannter Vertreter akzeptiert.", + "domain_operations_foa_reject_success": "Der Inhaberwechsel wurde als benannter Vertreter abgelehnt.", + "domain_operations_foa_error": "Bestätigung als benannter Vertreter fehlgeschlagen" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_en_GB.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_en_GB.json index b8b46d3d6579..1df6a6aab7a9 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_en_GB.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_en_GB.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "Tax code ", "domain_operations_update_identificationnumber": "Identification number", "domain_operations_update_nicowner_click_whoiscontactbilling": "Change the billing contact information by clicking here", - "domain_operations_update_nicowner_click_whoiscontacttech": "Change the technical contact information by clicking here" + "domain_operations_update_nicowner_click_whoiscontacttech": "Change the technical contact information by clicking here", + "domain_operations_foa_cta": "Validate as Designated Agent", + "domain_operations_foa_title": "Designated Agent validation for the {{t0}} domain name", + "domain_operations_foa_description": "This change of registrant is waiting for the Forms of Authorization (FOA) sent to the holders to be answered. As the Designated Agent, you can accept or reject this change on their behalf. Only the forms still awaiting an answer will be validated.", + "domain_operations_foa_certification": "I certify that I am duly authorised to act as the Designated Agent and to answer on behalf of the current and the new registrant.", + "domain_operations_foa_accept": "Accept", + "domain_operations_foa_reject": "Reject", + "domain_operations_foa_accept_success": "The change of registrant has been accepted as Designated Agent.", + "domain_operations_foa_reject_success": "The change of registrant has been rejected as Designated Agent.", + "domain_operations_foa_error": "Designated Agent validation failed" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_es_ES.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_es_ES.json index 3aaab61e5097..ec064e746c0d 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_es_ES.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_es_ES.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "NIF", "domain_operations_update_identificationnumber": "Número de identificación", "domain_operations_update_nicowner_click_whoiscontactbilling": "Cambiad la información del contacto de facturación haciendo clic aquí", - "domain_operations_update_nicowner_click_whoiscontacttech": "Cambiad la información del contacto técnico haciendo clic aquí" + "domain_operations_update_nicowner_click_whoiscontacttech": "Cambiad la información del contacto técnico haciendo clic aquí", + "domain_operations_foa_cta": "Validar como agente designado", + "domain_operations_foa_title": "Validación como agente designado para el dominio {{t0}}", + "domain_operations_foa_description": "Este cambio de titular está a la espera de la respuesta a los formularios de autorización (FOA) enviados a los titulares. Como agente designado, puede aceptar o rechazar este cambio en su nombre. Solo se validarán los formularios que sigan sin respuesta.", + "domain_operations_foa_certification": "Certifico que estoy debidamente autorizado/a para actuar como agente designado y responder en nombre del titular actual y del nuevo titular.", + "domain_operations_foa_accept": "Aceptar", + "domain_operations_foa_reject": "Rechazar", + "domain_operations_foa_accept_success": "El cambio de titular se ha aceptado como agente designado.", + "domain_operations_foa_reject_success": "El cambio de titular se ha rechazado como agente designado.", + "domain_operations_foa_error": "No se pudo realizar la validación como agente designado" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_CA.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_CA.json index cfbaad12cecd..3aab50848adf 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_CA.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_CA.json @@ -153,5 +153,14 @@ "domain_operations_update_nicbilling_click": "Changez les informations du contact facturation en cliquant ici", "domain_operations_accelerate_success": "L'opération a été accélérée avec succès.", "domain_operations_cancel_success": "L'opération a été annulée avec succès.", - "domain_operations_relaunch_success": "L'opération a été relancée avec succès." + "domain_operations_relaunch_success": "L'opération a été relancée avec succès.", + "domain_operations_foa_cta": "Valider en tant qu'agent désigné", + "domain_operations_foa_title": "Validation en tant qu'agent désigné pour le nom de domaine {{t0}}", + "domain_operations_foa_description": "Ce changement de titulaire est en attente de la validation des formulaires d'autorisation (FOA) envoyés aux titulaires. En tant qu'agent désigné, vous pouvez accepter ou rejeter ce changement en leur nom. Seuls les formulaires encore sans réponse seront validés.", + "domain_operations_foa_certification": "Je certifie que je suis dûment autorisé(e) à agir en tant qu'agent désigné et à répondre au nom du titulaire actuel et du nouveau titulaire.", + "domain_operations_foa_accept": "Accepter", + "domain_operations_foa_reject": "Rejeter", + "domain_operations_foa_accept_success": "Le changement de titulaire a été accepté en tant qu'agent désigné.", + "domain_operations_foa_reject_success": "Le changement de titulaire a été rejeté en tant qu'agent désigné.", + "domain_operations_foa_error": "Échec de la validation en tant qu'agent désigné" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_FR.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_FR.json index cfbaad12cecd..3aab50848adf 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_FR.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_FR.json @@ -153,5 +153,14 @@ "domain_operations_update_nicbilling_click": "Changez les informations du contact facturation en cliquant ici", "domain_operations_accelerate_success": "L'opération a été accélérée avec succès.", "domain_operations_cancel_success": "L'opération a été annulée avec succès.", - "domain_operations_relaunch_success": "L'opération a été relancée avec succès." + "domain_operations_relaunch_success": "L'opération a été relancée avec succès.", + "domain_operations_foa_cta": "Valider en tant qu'agent désigné", + "domain_operations_foa_title": "Validation en tant qu'agent désigné pour le nom de domaine {{t0}}", + "domain_operations_foa_description": "Ce changement de titulaire est en attente de la validation des formulaires d'autorisation (FOA) envoyés aux titulaires. En tant qu'agent désigné, vous pouvez accepter ou rejeter ce changement en leur nom. Seuls les formulaires encore sans réponse seront validés.", + "domain_operations_foa_certification": "Je certifie que je suis dûment autorisé(e) à agir en tant qu'agent désigné et à répondre au nom du titulaire actuel et du nouveau titulaire.", + "domain_operations_foa_accept": "Accepter", + "domain_operations_foa_reject": "Rejeter", + "domain_operations_foa_accept_success": "Le changement de titulaire a été accepté en tant qu'agent désigné.", + "domain_operations_foa_reject_success": "Le changement de titulaire a été rejeté en tant qu'agent désigné.", + "domain_operations_foa_error": "Échec de la validation en tant qu'agent désigné" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_it_IT.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_it_IT.json index a01b668961c9..3a3d41b76c63 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_it_IT.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_it_IT.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "Codice fiscale", "domain_operations_update_identificationnumber": "Numero di identificazione", "domain_operations_update_nicowner_click_whoiscontactbilling": "Modifica le informazioni del contatto di fatturazione cliccando qui", - "domain_operations_update_nicowner_click_whoiscontacttech": "Modifica le informazioni del contatto tecnico cliccando qui" + "domain_operations_update_nicowner_click_whoiscontacttech": "Modifica le informazioni del contatto tecnico cliccando qui", + "domain_operations_foa_cta": "Convalida come agente designato", + "domain_operations_foa_title": "Convalida come agente designato per il nome di dominio {{t0}}", + "domain_operations_foa_description": "Questo cambio di intestatario è in attesa della risposta ai moduli di autorizzazione (FOA) inviati agli intestatari. In qualità di agente designato, puoi accettare o rifiutare questo cambio per loro conto. Verranno convalidati solo i moduli ancora senza risposta.", + "domain_operations_foa_certification": "Certifico di essere debitamente autorizzato/a ad agire come agente designato e a rispondere per conto dell'intestatario attuale e del nuovo intestatario.", + "domain_operations_foa_accept": "Accetta", + "domain_operations_foa_reject": "Rifiuta", + "domain_operations_foa_accept_success": "Il cambio di intestatario è stato accettato in qualità di agente designato.", + "domain_operations_foa_reject_success": "Il cambio di intestatario è stato rifiutato in qualità di agente designato.", + "domain_operations_foa_error": "Convalida come agente designato non riuscita" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pl_PL.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pl_PL.json index f88425438710..713b40b50e96 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pl_PL.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pl_PL.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "NIP", "domain_operations_update_identificationnumber": "Numer identyfikacyjny", "domain_operations_update_nicowner_click_whoiscontactbilling": "Zmień dane kontaktu do faktur, klikając tutaj", - "domain_operations_update_nicowner_click_whoiscontacttech": "Zmień dane kontaktu technicznego, klikając tutaj" + "domain_operations_update_nicowner_click_whoiscontacttech": "Zmień dane kontaktu technicznego, klikając tutaj", + "domain_operations_foa_cta": "Zatwierdź jako wyznaczony agent", + "domain_operations_foa_title": "Zatwierdzenie jako wyznaczony agent dla domeny {{t0}}", + "domain_operations_foa_description": "Ta zmiana abonenta oczekuje na odpowiedź na formularze autoryzacji (FOA) wysłane do abonentów. Jako wyznaczony agent możesz zaakceptować lub odrzucić tę zmianę w ich imieniu. Zatwierdzone zostaną tylko formularze, na które nie udzielono jeszcze odpowiedzi.", + "domain_operations_foa_certification": "Oświadczam, że jestem należycie upoważniony/a do działania jako wyznaczony agent oraz do udzielania odpowiedzi w imieniu obecnego i nowego abonenta.", + "domain_operations_foa_accept": "Zaakceptuj", + "domain_operations_foa_reject": "Odrzuć", + "domain_operations_foa_accept_success": "Zmiana abonenta została zaakceptowana przez wyznaczonego agenta.", + "domain_operations_foa_reject_success": "Zmiana abonenta została odrzucona przez wyznaczonego agenta.", + "domain_operations_foa_error": "Wystąpił błąd podczas zatwierdzania jako wyznaczony agent" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pt_PT.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pt_PT.json index 4e1cb455cc9d..47830c2c447c 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pt_PT.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pt_PT.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "Número de identificação fiscal", "domain_operations_update_identificationnumber": "Número de identificação", "domain_operations_update_nicowner_click_whoiscontactbilling": "Altere as informações do contacto de faturação clicando aqui", - "domain_operations_update_nicowner_click_whoiscontacttech": "Altere as informações do contacto técnico clicando aqui" + "domain_operations_update_nicowner_click_whoiscontacttech": "Altere as informações do contacto técnico clicando aqui", + "domain_operations_foa_cta": "Validar como agente designado", + "domain_operations_foa_title": "Validação como agente designado para o nome de domínio {{t0}}", + "domain_operations_foa_description": "Esta mudança de titular está a aguardar a resposta aos formulários de autorização (FOA) enviados aos titulares. Como agente designado, pode aceitar ou rejeitar esta mudança em nome deles. Apenas os formulários ainda sem resposta serão validados.", + "domain_operations_foa_certification": "Certifico que estou devidamente autorizado/a a agir como agente designado e a responder em nome do titular atual e do novo titular.", + "domain_operations_foa_accept": "Aceitar", + "domain_operations_foa_reject": "Rejeitar", + "domain_operations_foa_accept_success": "A mudança de titular foi aceite na qualidade de agente designado.", + "domain_operations_foa_reject_success": "A mudança de titular foi rejeitada na qualidade de agente designado.", + "domain_operations_foa_error": "Ocorreu uma falha ao validar como agente designado" } diff --git a/packages/manager/apps/web-ongoing-operations/src/components/Dashboard/DashboardPage.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/components/Dashboard/DashboardPage.spec.tsx index ea0aee083961..f9ae98aa0e1b 100644 --- a/packages/manager/apps/web-ongoing-operations/src/components/Dashboard/DashboardPage.spec.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/components/Dashboard/DashboardPage.spec.tsx @@ -19,6 +19,12 @@ vi.mock('react-router-dom', () => ({ vi.mock('@/hooks/data/query', () => ({ useGetDomainInformation: vi.fn(), + usePendingFoas: vi.fn(() => ({ + taskId: null, + foas: [], + pendingFoas: [], + isLoading: false, + })), })); describe('Datagrid template', () => { diff --git a/packages/manager/apps/web-ongoing-operations/src/components/Foa/FoaActions.component.tsx b/packages/manager/apps/web-ongoing-operations/src/components/Foa/FoaActions.component.tsx new file mode 100644 index 000000000000..4edb7cb1b1c3 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/components/Foa/FoaActions.component.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import pLimit from 'p-limit'; +import { + Button, + BUTTON_VARIANT, + Checkbox, + CheckboxCheckedChangeDetail, + CheckboxControl, + CheckboxLabel, + Text, + TEXT_PRESET, +} from '@ovhcloud/ods-react'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { useNotifications } from '@ovh-ux/manager-react-components'; +import { validateFoa } from '@/data/api/foa'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { isPendingFoa } from '@/utils/foa.utils'; +import { urls } from '@/routes/routes.constant'; +import { useTrackNavigation } from '@/hooks/tracking/useTrackDatagridNavivationLink'; +import { TFoa } from '@/types'; + +interface FoaActionsProps { + readonly domainName: string; + readonly taskId: string; + readonly foas: TFoa[]; + readonly product: string; +} + +export default function FoaActions({ + domainName, + taskId, + foas, + product, +}: FoaActionsProps) { + const { t } = useTranslation(['dashboard', NAMESPACES.ACTIONS]); + const { trackPageNavivationButton } = useTrackNavigation(); + const { addError, addSuccess, clearNotifications } = useNotifications(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [isCertified, setIsCertified] = useState(false); + + const backToListing = () => { + const url = `${urls.root}${product}`; + trackPageNavivationButton(url); + navigate(url); + }; + + const { + mutate: validateFoas, + isPending, + variables: submittedChoice, + } = useMutation({ + mutationFn: async (choice: FoaChoiceEnum) => { + clearNotifications(); + const validateLimit = pLimit(1); + // Answered FOAs are re-checked here so a holder answer landed in the + // meantime is never overwritten by the designated agent + const results = await Promise.allSettled( + foas + .filter(isPendingFoa) + .map((foa) => + validateLimit(() => + validateFoa(domainName, taskId, foa.id, choice), + ), + ), + ); + // A 409 means the FOA has just been finalized : idempotent no-op + const [failure] = results.filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected' && + (result.reason as ApiError)?.response?.status !== 409, + ); + if (failure) { + throw failure.reason; + } + }, + onSuccess: async (_data, choice) => { + await queryClient.invalidateQueries({ + queryKey: ['me', 'task'], + }); + clearNotifications(); + addSuccess( + + {t( + choice === FoaChoiceEnum.Accept + ? 'domain_operations_foa_accept_success' + : 'domain_operations_foa_reject_success', + )} + , + ); + backToListing(); + // Not awaited : the answered FOAs are refreshed for the listing once + // this page is left, so the entry point disappears from the row + queryClient.invalidateQueries({ + queryKey: ['foa'], + }); + }, + onError: async () => { + // A 400 or a 404 both mean the local FOA data cannot be trusted anymore + await queryClient.invalidateQueries({ + queryKey: ['foa'], + }); + addError({t('domain_operations_foa_error')}); + }, + }); + + return ( +
+ + setIsCertified(detail.checked === true) + } + > + + + {t('domain_operations_foa_certification')} + + + +
+ + + +
+
+ ); +} diff --git a/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.spec.tsx new file mode 100644 index 000000000000..ed2bdb312354 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.spec.tsx @@ -0,0 +1,123 @@ +import { navigateMock } from '@/setupTests'; +import React from 'react'; +import { Mock, describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import OngoingOperationDatagridActions from '@/components/OngoingOperationDatagrid/OngoingOperationDatagridActions'; +import { usePendingFoas } from '@/hooks/data/query'; +import { wrapper } from '@/utils/test.provider'; +import { TFoa, TOngoingOperations } from '@/types'; + +const tradeTaskId = 'f0a1c2d3-0000-4a1b-9b7e-000000000001'; + +const tradeOperation: TOngoingOperations = { + id: 42, + domain: 'change-of-registrant.ovh', + status: 'todo', + function: 'DomainTrade', + todoDate: '2026-08-10T09:12:00+02:00', + creationDate: '2026-08-10T09:12:00+02:00', + lastUpdate: '2026-08-12T14:40:00+02:00', + canCancel: false, + canRelaunch: false, + canAccelerate: false, +}; + +/** The two FOAs of a trade, the second one already answered by its holder */ +const foas: TFoa[] = [ + { id: 'foa-current-holder', currentState: { STATUS: 'WAITING' } }, + { + id: 'foa-new-holder', + currentState: { STATUS: 'ANSWERED', CHOICE: 'ACCEPT' }, + }, +]; + +vi.mock('@/hooks/data/query', () => ({ + usePendingFoas: vi.fn(), +})); + +const mockPendingFoas = ({ + taskId = tradeTaskId, + pendingFoas = foas.slice(0, 1), +}: { taskId?: string | null; pendingFoas?: TFoa[] } = {}) => { + (usePendingFoas as Mock).mockReturnValue({ + taskId, + foas, + pendingFoas, + isLoading: false, + }); +}; + +const renderActions = (operation: TOngoingOperations = tradeOperation) => { + const { container } = render( + , + { wrapper }, + ); + // the menu items are ods-button custom elements, only labelled by attribute + return { + trigger: container.querySelector( + '[data-testid="navigation-action-trigger-action"]', + ), + foaItem: container.querySelector( + 'ods-button[label="domain_operations_foa_cta"]', + ), + }; +}; + +describe('OngoingOperationDatagridActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('offers the designated agent validation on a trade with a pending foa', () => { + mockPendingFoas(); + const { trigger, foaItem } = renderActions(); + + expect(foaItem?.className).not.toContain('hidden'); + expect(trigger).toHaveAttribute('is-disabled', 'false'); + }); + + it('navigates to the certification page of the operation', () => { + mockPendingFoas(); + const { foaItem } = renderActions(); + + fireEvent.click(foaItem as Element); + + expect(navigateMock).toHaveBeenCalledWith(`/foa/${tradeOperation.id}`); + }); + + it('hides the entry point when every foa has already been answered', () => { + mockPendingFoas({ pendingFoas: [] }); + const { trigger, foaItem } = renderActions(); + + expect(foaItem?.className).toContain('hidden'); + expect(trigger).toHaveAttribute('is-disabled', 'true'); + }); + + it('hides the entry point when the task carries no foa', () => { + mockPendingFoas({ taskId: null, pendingFoas: [] }); + const { foaItem } = renderActions(); + + expect(foaItem?.className).toContain('hidden'); + }); + + it('keeps the menu usable on a trade whose other actions are available', () => { + mockPendingFoas({ pendingFoas: [] }); + const { trigger } = renderActions({ + ...tradeOperation, + canCancel: true, + }); + + expect(trigger).toHaveAttribute('is-disabled', 'false'); + }); + + it('does not look for foas on an operation that is not a trade', () => { + mockPendingFoas({ taskId: null, pendingFoas: [] }); + const { foaItem } = renderActions({ + ...tradeOperation, + function: 'DomainDnsUpdate', + }); + + expect(usePendingFoas).toHaveBeenCalledWith(tradeOperation.domain, false); + expect(foaItem?.className).toContain('hidden'); + }); +}); diff --git a/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.tsx b/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.tsx new file mode 100644 index 000000000000..3077cd4032bd --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActionMenu, useNotifications } from '@ovh-ux/manager-react-components'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { ODS_BUTTON_VARIANT } from '@ovhcloud/ods-components'; +import { OngoingOperationDatagridActionsProps } from '@/types'; +import { DomainOperationsEnum } from '@/constants'; +import { usePendingFoas } from '@/hooks/data/query'; +import { useTrackNavigation } from '@/hooks/tracking/useTrackDatagridNavivationLink'; + +export default function OngoingOperationDatagridActions({ + props, +}: Readonly) { + const { t } = useTranslation('dashboard'); + const { trackPageNavivationTile } = useTrackNavigation(); + const { clearNotifications } = useNotifications(); + const navigate = useNavigate(); + const location = useLocation(); + + const isTrade = props.function === DomainOperationsEnum.DomainTrade; + // Only a change of registrant carries FOAs, so no other row fetches them + const { taskId, pendingFoas } = usePendingFoas(props.domain ?? '', isTrade); + + const canUpdate = props.canAccelerate || props.canRelaunch || props.canCancel; + const canValidateFoa = isTrade && !!taskId && pendingFoas.length > 0; + + return ( + { + const url = `${location.pathname}/update/${props.id}`; + trackPageNavivationTile(url); + navigate(url); + clearNotifications(); + }, + }, + { + id: 2, + label: t('domain_operations_tab_popover_progress'), + className: `${props.function !== + DomainOperationsEnum.DomainIncomingTransfer && + 'hidden'} menu-item-button`, + onClick: () => { + const url = `/tracking/${props.id}`; + trackPageNavivationTile(url); + navigate(url); + }, + }, + { + id: 3, + label: t('domain_operations_foa_cta'), + className: `${!canValidateFoa && 'hidden'} menu-item-button`, + onClick: () => { + const url = `${location.pathname}/foa/${props.id}`; + trackPageNavivationTile(url); + navigate(url); + clearNotifications(); + }, + }, + ]} + /> + ); +} diff --git a/packages/manager/apps/web-ongoing-operations/src/constants.ts b/packages/manager/apps/web-ongoing-operations/src/constants.ts index 3faf9668bf88..4235030e7ab5 100644 --- a/packages/manager/apps/web-ongoing-operations/src/constants.ts +++ b/packages/manager/apps/web-ongoing-operations/src/constants.ts @@ -123,6 +123,9 @@ export const editableArgument: Record = { default: z.string(), }; +/** APIv2 status of a trade task that may still carry answerable FOAs. */ +export const foaScheduledTaskStatus = 'SCHEDULED'; + export const iamGetAllDomAction = 'domain:apiovh:alldom/get'; export const allDomFeatureAvailibility = 'web-domains:alldoms'; export const domainFeatureAvailibility = 'web-domains:domain'; diff --git a/packages/manager/apps/web-ongoing-operations/src/data/api/foa.spec.ts b/packages/manager/apps/web-ongoing-operations/src/data/api/foa.spec.ts new file mode 100644 index 000000000000..48125a946125 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/data/api/foa.spec.ts @@ -0,0 +1,87 @@ +import '@/setupTests'; +import { Mock, describe, it, expect, vi, beforeEach } from 'vitest'; +import { v2 } from '@ovh-ux/manager-core-api'; +import { + getScheduledTradeTasks, + getTaskFoas, + validateFoa, +} from '@/data/api/foa'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { TDomainTaskV2, TFoa } from '@/types'; + +const domainName = 'change-of-registrant.ovh'; +const taskId = 'f0a1c2d3-0000-4a1b-9b7e-000000000001'; + +const scheduledTasks: TDomainTaskV2[] = [ + { + id: taskId, + type: 'DomainTrade', + status: 'SCHEDULED', + createdAt: '2026-08-10T09:12:00+02:00', + }, +]; + +const foas: TFoa[] = [ + { id: 'foa-current-holder', currentState: { STATUS: 'WAITING' } }, + { + id: 'foa-new-holder', + currentState: { STATUS: 'ANSWERED', CHOICE: 'ACCEPT' }, + }, +]; + +describe('foa api', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('filters the task listing on the scheduled trades', async () => { + (v2.get as Mock).mockResolvedValue({ data: scheduledTasks }); + + await expect(getScheduledTradeTasks(domainName)).resolves.toEqual( + scheduledTasks, + ); + expect(v2.get).toHaveBeenCalledWith( + `domain/name/${domainName}/task?type=DomainTrade&status=SCHEDULED`, + ); + }); + + it('returns the foa list of a task', async () => { + (v2.get as Mock).mockResolvedValue({ data: foas }); + + await expect(getTaskFoas(domainName, taskId)).resolves.toEqual( + foas, + ); + expect(v2.get).toHaveBeenCalledWith( + `domain/name/${domainName}/task/${taskId}/foa`, + ); + }); + + it('maps a 404 on the foa listing to an empty list', async () => { + (v2.get as Mock).mockRejectedValue({ response: { status: 404 } }); + + await expect(getTaskFoas(domainName, taskId)).resolves.toEqual([]); + }); + + it('rethrows any other error of the foa listing', async () => { + (v2.get as Mock).mockRejectedValue({ response: { status: 500 } }); + + await expect(getTaskFoas(domainName, taskId)).rejects.toMatchObject({ + response: { status: 500 }, + }); + }); + + it('posts the choice on the validate call', async () => { + (v2.post as Mock).mockResolvedValue({ data: undefined }); + + await validateFoa( + domainName, + taskId, + 'foa-current-holder', + FoaChoiceEnum.Reject, + ); + expect(v2.post).toHaveBeenCalledWith( + `domain/name/${domainName}/task/${taskId}/foa/foa-current-holder/validate`, + { choice: FoaChoiceEnum.Reject }, + ); + }); +}); diff --git a/packages/manager/apps/web-ongoing-operations/src/data/api/foa.ts b/packages/manager/apps/web-ongoing-operations/src/data/api/foa.ts new file mode 100644 index 000000000000..d3882031f1d1 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/data/api/foa.ts @@ -0,0 +1,60 @@ +import { ApiError, v2 } from '@ovh-ux/manager-core-api'; +import { DomainOperationsEnum, foaScheduledTaskStatus } from '@/constants'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { TDomainTaskV2, TFoa } from '@/types'; + +const getTaskPath = (domainName: string) => + `domain/name/${encodeURIComponent(domainName)}/task`; + +/** + * Get the scheduled change of registrant tasks of a domain : the FOAs a + * designated agent may answer are carried by those tasks only + */ +export const getScheduledTradeTasks = async ( + domainName: string, +): Promise => { + const { data } = await v2.get( + `${getTaskPath(domainName)}?type=${ + DomainOperationsEnum.DomainTrade + }&status=${foaScheduledTaskStatus}`, + ); + return data; +}; + +/** + * Get the FOAs of a task : a 404 means the task carries no FOA, an expected + * case surfaced as an empty list rather than as an error + */ +export const getTaskFoas = async ( + domainName: string, + taskId: string, +): Promise => { + try { + const { data } = await v2.get( + `${getTaskPath(domainName)}/${encodeURIComponent(taskId)}/foa`, + ); + return data; + } catch (error) { + if ((error as ApiError)?.response?.status === 404) { + return []; + } + throw error; + } +}; + +/** + * Record the designated agent answer on a single FOA + */ +export const validateFoa = async ( + domainName: string, + taskId: string, + foaId: string, + choice: FoaChoiceEnum, +): Promise => { + await v2.post( + `${getTaskPath(domainName)}/${encodeURIComponent( + taskId, + )}/foa/${encodeURIComponent(foaId)}/validate`, + { choice }, + ); +}; diff --git a/packages/manager/apps/web-ongoing-operations/src/enum/foa.enum.ts b/packages/manager/apps/web-ongoing-operations/src/enum/foa.enum.ts new file mode 100644 index 000000000000..d2e4055316e5 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/enum/foa.enum.ts @@ -0,0 +1,4 @@ +export enum FoaChoiceEnum { + Accept = 'ACCEPT', + Reject = 'REJECT', +} diff --git a/packages/manager/apps/web-ongoing-operations/src/hooks/data/query.tsx b/packages/manager/apps/web-ongoing-operations/src/hooks/data/query.tsx index 373337e38078..55c3fc8be978 100644 --- a/packages/manager/apps/web-ongoing-operations/src/hooks/data/query.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/hooks/data/query.tsx @@ -7,11 +7,15 @@ import { } from '@/data/api/web-ongoing-operations'; import { TArgument, + TDomainTaskV2, + TFoa, TOngoingOperations, TServiceInfo, TTracking, } from '@/types'; import { getOperationTrackingStatus } from '@/data/api/tracking'; +import { getScheduledTradeTasks, getTaskFoas } from '@/data/api/foa'; +import { getMostRecentTask, isPendingFoa } from '@/utils/foa.utils'; export const useTracking = (id: number) => { return useQuery({ @@ -48,3 +52,50 @@ export const useGetDomainInformation = (serviceName: string) => { retry: 0, }); }; + +export const useScheduledTradeTask = (domainName: string, enabled = true) => { + return useQuery({ + queryKey: ['foa', 'task', domainName], + queryFn: async () => + getMostRecentTask(await getScheduledTradeTasks(domainName)), + enabled: !!domainName && enabled, + }); +}; + +export const useTaskFoas = ( + domainName: string, + taskId: string | null, + enabled = true, +) => { + return useQuery({ + queryKey: ['foa', domainName, taskId], + // '' is a sentinel : the query stays disabled while there is no task id + queryFn: () => getTaskFoas(domainName, taskId ?? ''), + enabled: !!domainName && !!taskId && enabled, + }); +}; + +/** + * Resolve the scheduled change of registrant task of a domain, then the FOAs + * of that task still waiting for an answer : the designated agent validation + * is offered only when at least one of them is pending + */ +export const usePendingFoas = (domainName: string, enabled = true) => { + const { data: task, isLoading: taskLoading } = useScheduledTradeTask( + domainName, + enabled, + ); + const taskId = task?.id ?? null; + const { data: foas = [], isLoading: foasLoading } = useTaskFoas( + domainName, + taskId, + enabled, + ); + + return { + taskId, + foas, + pendingFoas: foas.filter(isPendingFoa), + isLoading: taskLoading || (!!taskId && foasLoading), + }; +}; diff --git a/packages/manager/apps/web-ongoing-operations/src/hooks/useOngoingOperationDatagridColumns.tsx b/packages/manager/apps/web-ongoing-operations/src/hooks/useOngoingOperationDatagridColumns.tsx index b11e8ca119e5..4476c04ac32a 100644 --- a/packages/manager/apps/web-ongoing-operations/src/hooks/useOngoingOperationDatagridColumns.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/hooks/useOngoingOperationDatagridColumns.tsx @@ -1,43 +1,30 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { - ActionMenu, DataGridTextCell, - useNotifications, useFormatDate, } from '@ovh-ux/manager-react-components'; -import { useLocation, useNavigate } from 'react-router-dom'; import { TOngoingOperations } from 'src/types'; import { FilterCategories } from '@ovh-ux/manager-core-api'; -import { ODS_BUTTON_VARIANT } from '@ovhcloud/ods-components'; import { NAMESPACES } from '@ovh-ux/manager-common-translations'; import { ParentEnum } from '@/enum/parent.enum'; import { removeQuotes } from '@/utils/utils'; import OngoingOperationDatagridDomain from '@/components/OngoingOperationDatagrid/OngoingOperationDatagridDomain'; import OngoingOperationDatagridBadge from '@/components/OngoingOperationDatagrid/OngoingOperationDatagridBadge'; +import OngoingOperationDatagridActions from '@/components/OngoingOperationDatagrid/OngoingOperationDatagridActions'; import { DNS_OPERATIONS_TABLE_HEADER_DOMAIN } from '@/pages/dashboard/Dashboard'; import { StatusEnum } from '@/enum/status.enum'; -import { - DomainOperations, - DNSOperations, - DomainOperationsEnum, - AlldomOperations, -} from '@/constants'; -import { useTrackNavigation } from './tracking/useTrackDatagridNavivationLink'; +import { DomainOperations, DNSOperations, AlldomOperations } from '@/constants'; export const useOngoingOperationDatagridColumns = ( searchableColumnID: string, parent: ParentEnum, ) => { - const { trackPageNavivationTile } = useTrackNavigation(); const { t } = useTranslation([ 'dashboard', NAMESPACES.FORM, NAMESPACES.DASHBOARD, ]); - const { clearNotifications } = useNotifications(); - const navigate = useNavigate(); - const location = useLocation(); const formatDate = useFormatDate(); const getOperationsFilter = (type: ParentEnum) => { @@ -131,42 +118,7 @@ export const useOngoingOperationDatagridColumns = ( }, { cell: (props: TOngoingOperations) => ( - { - const url = `${location.pathname}/update/${props.id}`; - trackPageNavivationTile(url); - navigate(url); - clearNotifications(); - }, - }, - { - id: 2, - label: t('domain_operations_tab_popover_progress'), - className: `${props.function !== - DomainOperationsEnum.DomainIncomingTransfer && - 'hidden'} menu-item-button`, - onClick: () => { - const url = `/tracking/${props.id}`; - trackPageNavivationTile(url); - navigate(url); - }, - }, - ]} - /> + ), id: 'actions', label: '', diff --git a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/allDom/AllDom.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/allDom/AllDom.spec.tsx index 914cae6fe82b..20f278ae1352 100644 --- a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/allDom/AllDom.spec.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/allDom/AllDom.spec.tsx @@ -27,6 +27,12 @@ vi.mock('@/data/api/web-ongoing-operations', () => ({ vi.mock('@/hooks/data/query', () => ({ useGetDomainInformation: vi.fn(), + usePendingFoas: vi.fn(() => ({ + taskId: null, + foas: [], + pendingFoas: [], + isLoading: false, + })), })); vi.mock('@/hooks/iam/iam', () => ({ diff --git a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/dns/Dns.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/dns/Dns.spec.tsx index 4c888cea7747..1b53685bc944 100644 --- a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/dns/Dns.spec.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/dns/Dns.spec.tsx @@ -31,6 +31,12 @@ vi.mock('@/data/api/web-ongoing-operations', () => ({ vi.mock('@/hooks/data/query', () => ({ useGetDomainInformation: vi.fn(), + usePendingFoas: vi.fn(() => ({ + taskId: null, + foas: [], + pendingFoas: [], + isLoading: false, + })), })); describe('Dns datagrid', () => { diff --git a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/domain/Domain.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/domain/Domain.spec.tsx index bd6d8807ac3c..a3792c605bff 100644 --- a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/domain/Domain.spec.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/domain/Domain.spec.tsx @@ -73,6 +73,12 @@ describe('Domain datagrid', () => { data: null, }; }), + usePendingFoas: vi.fn(() => ({ + taskId: null, + foas: [], + pendingFoas: [], + isLoading: false, + })), })); const { container } = render(, { wrapper }); diff --git a/packages/manager/apps/web-ongoing-operations/src/pages/foa/FoaValidation.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/pages/foa/FoaValidation.spec.tsx new file mode 100644 index 000000000000..71d5204f2744 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/pages/foa/FoaValidation.spec.tsx @@ -0,0 +1,219 @@ +import { navigateMock } from '@/setupTests'; +import React from 'react'; +import { Mock, describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import FoaValidation from '@/pages/foa/FoaValidation'; +import { useDomain, usePendingFoas } from '@/hooks/data/query'; +import { validateFoa } from '@/data/api/foa'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { isPendingFoa } from '@/utils/foa.utils'; +import { wrapper } from '@/utils/test.provider'; +import { domain } from '@/__mocks__/domain'; +import { TFoa, TOngoingOperations } from '@/types'; + +const domainName = 'change-of-registrant.ovh'; +const taskId = 'f0a1c2d3-0000-4a1b-9b7e-000000000001'; + +const tradeOperation: TOngoingOperations = { + id: 42, + domain: domainName, + status: 'todo', + function: 'DomainTrade', + todoDate: '2026-08-10T09:12:00+02:00', + creationDate: '2026-08-10T09:12:00+02:00', + lastUpdate: '2026-08-12T14:40:00+02:00', + canCancel: false, + canRelaunch: false, + canAccelerate: false, +}; + +/** The two FOAs of a trade, the second one already answered by its holder */ +const answeredFoas: TFoa[] = [ + { id: 'foa-current-holder', currentState: { STATUS: 'WAITING' } }, + { + id: 'foa-new-holder', + currentState: { STATUS: 'ANSWERED', CHOICE: 'ACCEPT' }, + }, +]; + +const pendingFoas: TFoa[] = [ + { id: 'foa-current-holder', currentState: { STATUS: 'WAITING' } }, + { id: 'foa-new-holder', currentState: {} }, +]; + +vi.mock('@/hooks/data/query', () => ({ + useDomain: vi.fn(), + usePendingFoas: vi.fn(), +})); + +vi.mock('@/data/api/foa', () => ({ + validateFoa: vi.fn(), +})); + +const mockQueries = ({ + operation = tradeOperation, + foas = answeredFoas, +}: { + operation?: unknown; + foas?: TFoa[]; +} = {}) => { + (useDomain as Mock).mockReturnValue({ data: operation, isLoading: false }); + (usePendingFoas as Mock).mockReturnValue({ + taskId: taskId, + foas, + pendingFoas: foas.filter(isPendingFoa), + isLoading: false, + }); +}; + +const getCheckbox = () => screen.getByRole('checkbox'); +const getButton = (name: string) => + screen.getByRole('button', { name }) as HTMLButtonElement; + +describe('FoaValidation page', () => { + beforeEach(() => { + vi.clearAllMocks(); + (validateFoa as Mock).mockResolvedValue(undefined); + }); + + it('keeps the validation buttons disabled until the certification is ticked', async () => { + mockQueries(); + const { container } = render(, { wrapper }); + + expect(screen.getByText('domain_operations_foa_title')).toBeInTheDocument(); + expect( + screen.getByText('domain_operations_foa_certification'), + ).toBeInTheDocument(); + expect(getButton('domain_operations_foa_accept').disabled).toBe(true); + expect(getButton('domain_operations_foa_reject').disabled).toBe(true); + expect(getButton(`${NAMESPACES.ACTIONS}:cancel`).disabled).toBe(false); + + fireEvent.click(getCheckbox()); + + await waitFor(() => { + expect(getButton('domain_operations_foa_accept').disabled).toBe(false); + }); + expect(getButton('domain_operations_foa_reject').disabled).toBe(false); + + await expect(container).toBeAccessible({ + rules: { + 'heading-order': { enabled: false }, + }, + }); + }); + + it('lays the actions out like the v8 modal, primary action last', () => { + mockQueries(); + render(, { wrapper }); + + const order = screen + .getAllByRole('button') + .map((button) => button.getAttribute('name')) + .filter((name): name is string => name !== null); + + expect(order).toEqual(['cancel', 'reject', 'accept']); + }); + + it('validates every still pending foa of the task on accept', async () => { + mockQueries({ foas: pendingFoas }); + render(, { wrapper }); + + fireEvent.click(getCheckbox()); + await waitFor(() => { + expect(getButton('domain_operations_foa_accept').disabled).toBe(false); + }); + fireEvent.click(getButton('domain_operations_foa_accept')); + + await waitFor(() => { + expect(validateFoa).toHaveBeenCalledTimes(2); + }); + expect(validateFoa).toHaveBeenNthCalledWith( + 1, + domainName, + taskId, + 'foa-current-holder', + FoaChoiceEnum.Accept, + ); + expect(validateFoa).toHaveBeenNthCalledWith( + 2, + domainName, + taskId, + 'foa-new-holder', + FoaChoiceEnum.Accept, + ); + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith('/domain'); + }); + }); + + it('skips the foas already answered and rejects with the reject choice', async () => { + mockQueries(); + render(, { wrapper }); + + fireEvent.click(getCheckbox()); + await waitFor(() => { + expect(getButton('domain_operations_foa_reject').disabled).toBe(false); + }); + fireEvent.click(getButton('domain_operations_foa_reject')); + + await waitFor(() => { + expect(validateFoa).toHaveBeenCalledTimes(1); + }); + expect(validateFoa).toHaveBeenCalledWith( + domainName, + taskId, + 'foa-current-holder', + FoaChoiceEnum.Reject, + ); + }); + + it('treats an already finalized foa as an idempotent success', async () => { + mockQueries(); + (validateFoa as Mock).mockRejectedValue({ response: { status: 409 } }); + render(, { wrapper }); + + fireEvent.click(getCheckbox()); + await waitFor(() => { + expect(getButton('domain_operations_foa_accept').disabled).toBe(false); + }); + fireEvent.click(getButton('domain_operations_foa_accept')); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith('/domain'); + }); + }); + + it('keeps the user on the page when the validation fails', async () => { + mockQueries(); + (validateFoa as Mock).mockRejectedValue({ response: { status: 400 } }); + render(, { wrapper }); + + fireEvent.click(getCheckbox()); + await waitFor(() => { + expect(getButton('domain_operations_foa_accept').disabled).toBe(false); + }); + fireEvent.click(getButton('domain_operations_foa_accept')); + + await waitFor(() => { + expect( + screen.getByText('domain_operations_foa_error'), + ).toBeInTheDocument(); + }); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('is not reachable when the operation is not a change of registrant', () => { + mockQueries({ operation: domain[0] }); + render(, { wrapper }); + + expect(screen.getByText('404 - route not found')).toBeInTheDocument(); + }); + + it('is not reachable when every foa has already been answered', () => { + mockQueries({ foas: [{ id: 'foa-1', currentState: { CHOICE: 'ACCEPT' } }] }); + render(, { wrapper }); + + expect(screen.getByText('404 - route not found')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/apps/web-ongoing-operations/src/pages/foa/FoaValidation.tsx b/packages/manager/apps/web-ongoing-operations/src/pages/foa/FoaValidation.tsx new file mode 100644 index 000000000000..4e688306b34d --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/pages/foa/FoaValidation.tsx @@ -0,0 +1,67 @@ +import { + BaseLayout, + Notifications, + useNotifications, +} from '@ovh-ux/manager-react-components'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { toUnicode } from 'punycode'; +import { Text, TEXT_PRESET } from '@ovhcloud/ods-react'; +import SubHeader from '@/components/SubHeader/SubHeader'; +import Loading from '@/components/Loading/Loading'; +import FoaActions from '@/components/Foa/FoaActions.component'; +import { DomainOperationsEnum } from '@/constants'; +import { useDomain, usePendingFoas } from '@/hooks/data/query'; +import NotFound from '@/pages/404'; + +export default function FoaValidation() { + const { t } = useTranslation('dashboard'); + const { id, product } = useParams<{ id: string; product: string }>(); + const paramId = Number(id); + const { notifications } = useNotifications(); + + const { data: operation, isLoading: operationLoading } = useDomain(paramId); + const domainName = operation?.domain ?? ''; + const isTrade = operation?.function === DomainOperationsEnum.DomainTrade; + const { taskId, foas, pendingFoas, isLoading: foasLoading } = usePendingFoas( + domainName, + isTrade, + ); + + if (operationLoading || foasLoading) { + return ; + } + + // No scheduled trade task, no FOA (404) or every FOA already answered : + // there is nothing a designated agent can validate here + if (!operation || !isTrade || !taskId || pendingFoas.length === 0) { + return ; + } + + return ( + : undefined} + > + +
+ + {t('domain_operations_foa_description')} + + +
+
+ ); +} diff --git a/packages/manager/apps/web-ongoing-operations/src/routes/routes.constant.ts b/packages/manager/apps/web-ongoing-operations/src/routes/routes.constant.ts index 7e1da70e5e00..cdc1609503a8 100644 --- a/packages/manager/apps/web-ongoing-operations/src/routes/routes.constant.ts +++ b/packages/manager/apps/web-ongoing-operations/src/routes/routes.constant.ts @@ -5,5 +5,6 @@ export const urls = { allDom: 'alldom', track: 'tracking/:id', update: ':product/update/:id', + foa: ':product/foa/:id', error404: '404', }; diff --git a/packages/manager/apps/web-ongoing-operations/src/routes/routes.tsx b/packages/manager/apps/web-ongoing-operations/src/routes/routes.tsx index 35ef3327b5b7..13f12fc0ee2c 100644 --- a/packages/manager/apps/web-ongoing-operations/src/routes/routes.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/routes/routes.tsx @@ -14,6 +14,9 @@ const DashboardDomainPage = React.lazy(() => const DashboardDnsPage = React.lazy(() => import('@/pages/dashboard/dns/Dns')); const TrackPage = React.lazy(() => import('@/pages/tracking/Tracking')); const ActionPage = React.lazy(() => import('@/pages/update/Update')); +const FoaValidationPage = React.lazy(() => + import('@/pages/foa/FoaValidation'), +); export default ( + } /> ); diff --git a/packages/manager/apps/web-ongoing-operations/src/setupTests.tsx b/packages/manager/apps/web-ongoing-operations/src/setupTests.tsx index 4c4725c0ac57..e8ae4afd9471 100644 --- a/packages/manager/apps/web-ongoing-operations/src/setupTests.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/setupTests.tsx @@ -30,6 +30,12 @@ vi.mock('@ovh-ux/manager-core-api', async () => { put: vi.fn(), delete: vi.fn(), }, + v2: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, }; }); @@ -66,9 +72,18 @@ const mocks = vi.hoisted(() => ({ }, }, }, + navigate: vi.fn(), })); -vi.mock('@ovh-ux/manager-react-shell-client', () => ({ +/** Stable useNavigate spy, so a redirection can be asserted from a spec */ +export const navigateMock = mocks.navigate; + +vi.mock('@ovh-ux/manager-react-shell-client', async (importOriginal) => ({ + // the enums (PageLocation, ButtonType, PageType) are kept as is : the app + // tracking helpers read them at call time + ...(await importOriginal< + typeof import('@ovh-ux/manager-react-shell-client') + >()), ShellContext: React.createContext({ shell: mocks.shell, }), @@ -110,7 +125,7 @@ vi.mock('@/hooks/nichandle/useNichandle', () => ({ })); vi.mock('react-router-dom', () => ({ - useNavigate: () => vi.fn(() => null), + useNavigate: () => mocks.navigate, useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]), Navigate: vi.fn(() => null), useLocation: vi.fn(() => ({ @@ -124,6 +139,7 @@ vi.mock('react-router-dom', () => ({ return { serviceName: 'foobar', id: '1', + product: 'domain', }; }, NavLink: ({ ...params }: NavLinkProps) => params.children, diff --git a/packages/manager/apps/web-ongoing-operations/src/types/index.ts b/packages/manager/apps/web-ongoing-operations/src/types/index.ts index feb92d4678e9..eb53cd9cf8ae 100644 --- a/packages/manager/apps/web-ongoing-operations/src/types/index.ts +++ b/packages/manager/apps/web-ongoing-operations/src/types/index.ts @@ -70,6 +70,10 @@ export interface OngoingOperationDatagridDomainProps { props: TOngoingOperations; } +export interface OngoingOperationDatagridActionsProps { + props: TOngoingOperations; +} + export interface UploadedArgumentFiles { argument: TArgument; files: File[]; @@ -81,3 +85,26 @@ export interface UpdateMeDocumentComponentProps { React.SetStateAction >; } + +/** + * Domain task as returned by APIv2 /domain/name/{domainName}/task — its id is + * a UUID, unlike the numeric id of the APIv6 /me/task/domain operations. + */ +export interface TDomainTaskV2 { + id: string; + type: string; + status: string; + createdAt?: string; + updatedAt?: string; +} + +export type TFoaCurrentState = { CHOICE?: string } & Record; + +/** + * FOA (Form of Authorization) attached to a DomainTrade task. + * A FOA is still pending while its currentState carries no CHOICE property. + */ +export interface TFoa { + id: string; + currentState?: TFoaCurrentState; +} diff --git a/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.spec.ts b/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.spec.ts new file mode 100644 index 000000000000..05f1d95f26fb --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { getMostRecentTask, isPendingFoa } from '@/utils/foa.utils'; +import { TDomainTaskV2 } from '@/types'; + +const taskId = 'f0a1c2d3-0000-4a1b-9b7e-000000000001'; + +const scheduledTasks: TDomainTaskV2[] = [ + { + id: 'f0a1c2d3-0000-4a1b-9b7e-000000000000', + type: 'DomainTrade', + status: 'SCHEDULED', + createdAt: '2026-08-01T09:12:00+02:00', + }, + { + id: taskId, + type: 'DomainTrade', + status: 'SCHEDULED', + createdAt: '2026-08-10T09:12:00+02:00', + }, +]; + +describe('isPendingFoa', () => { + it('is pending while the currentState carries no CHOICE', () => { + expect(isPendingFoa({ id: 'foa-1' })).toBe(true); + expect(isPendingFoa({ id: 'foa-2', currentState: {} })).toBe(true); + expect( + isPendingFoa({ id: 'foa-3', currentState: { STATUS: 'WAITING' } }), + ).toBe(true); + }); + + it('is not pending anymore once a CHOICE is recorded', () => { + expect( + isPendingFoa({ id: 'foa-1', currentState: { CHOICE: 'ACCEPT' } }), + ).toBe(false); + expect( + isPendingFoa({ id: 'foa-2', currentState: { CHOICE: 'REJECT' } }), + ).toBe(false); + }); +}); + +describe('getMostRecentTask', () => { + it('returns the most recent task of the list', () => { + expect(getMostRecentTask(scheduledTasks)?.id).toBe(taskId); + }); + + it('returns null when the domain has no task', () => { + expect(getMostRecentTask([])).toBeNull(); + }); + + it('does not reorder tasks sent without any date', () => { + const tasks = [ + { id: 'first', type: 'DomainTrade', status: 'SCHEDULED' }, + { id: 'second', type: 'DomainTrade', status: 'SCHEDULED' }, + ]; + expect(getMostRecentTask(tasks)?.id).toBe('first'); + }); +}); diff --git a/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.ts b/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.ts new file mode 100644 index 000000000000..1e6719cc3a6c --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.ts @@ -0,0 +1,26 @@ +import { TDomainTaskV2, TFoa } from '@/types'; + +/** + * A FOA is still pending while the holder has not answered it, ie while its + * currentState carries no CHOICE property. + */ +export const isPendingFoa = (foa: TFoa): boolean => + !foa.currentState || !('CHOICE' in foa.currentState); + +/** Sortable timestamp of an APIv2 task, 0 when the API sends no date. */ +const getTaskTimestamp = (task: TDomainTaskV2): number => { + const date = task.createdAt ?? task.updatedAt; + const timestamp = date ? new Date(date).getTime() : Number.NaN; + return Number.isNaN(timestamp) ? 0 : timestamp; +}; + +/** + * Most recent task of an APIv2 task list. There should only be one scheduled + * trade per domain, but the API returns a list. + */ +export const getMostRecentTask = ( + tasks: TDomainTaskV2[], +): TDomainTaskV2 | null => + [...(tasks ?? [])].sort( + (a, b) => getTaskTimestamp(b) - getTaskTimestamp(a), + )[0] ?? null;