From 51ccb6baf1da08d0bf95f61cad572b0c87df9db5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?BITARD=20Micha=C3=ABl?= <michael.bitard@beta.gouv.fr>
Date: Tue, 22 Apr 2025 13:19:14 +0000
Subject: [PATCH] =?UTF-8?q?fix(lien=20des=20titres):=20le=20bouton=20de=20?=
 =?UTF-8?q?lien=20des=20titres=20apparait=20m=C3=AAme=20quand=20il=20n'y?=
 =?UTF-8?q?=20a=20pas=20de=20lien=20(pub/pnm-public/camino!1697)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/api/src/api/rest/demarches.ts        |  13 +-
 .../src/api/rest/titres.test.integration.ts   |  79 ++++++--
 packages/api/src/api/rest/titres.ts           |  41 ++--
 .../validations/titre-links-validate.ts       |  11 +-
 packages/api/src/tools/fp-tools.ts            |  10 +-
 .../src/permissions/titres-demarches.test.ts  |  30 ++-
 .../src/permissions/titres-demarches.ts       |  16 +-
 packages/common/src/permissions/titres.ts     |   3 +-
 .../substancesLegales.test.ts.snap            |  77 --------
 packages/common/src/typescript-tools.ts       |   2 +
 ...stories_snapshots_AbattisKoticaOctroi.html |   1 +
 .../titre.stories_snapshots_BasseManaMod.html |   1 +
 ...tre.stories_snapshots_BonEspoirOctroi.html |   1 +
 ...ries_snapshots_BonEspoirProlongation2.html |   1 +
 ...re.stories_snapshots_BonEspoirTravaux.html |   1 +
 ...e.stories_snapshots_ChantepieMutation.html |   4 +
 ...tre.stories_snapshots_ChantepieOctroi.html |   4 +
 ...snapshots_ChantepieOctroiAsEntreprise.html |   1 +
 ...stories_snapshots_CriqueAdolpheOctroi.html |   1 +
 .../titre.stories_snapshots_Empty.html        |   1 +
 .../titre.stories_snapshots_Full.html         |   1 +
 .../titre.stories_snapshots_Lenoncourt.html   |   1 +
 ...AvecUnOctroiEnConstructionEtUnTravaux.html |   1 +
 ...treAvecUneSeuleDemarcheEnConstruction.html |   1 +
 .../titre.stories_snapshots_WithDoublon.html  |   1 +
 ...ries_snapshots_WithLinkableTitreAmont.html |   1 +
 ...ories_snapshots_WithLinkableTitreAval.html |   6 +-
 .../titre/titres-link-form.stories.tsx        |  31 ++-
 ...snapshots_AxmWithAlreadySelectedTitre.html |   1 +
 ...xmWithAlreadySelectedTitreNotEditable.html |   1 +
 ...ies_snapshots_AxmWithoutSelectedTitre.html |   8 +
 ...ories_snapshots_DefautCantUpdateLinks.html |   1 +
 ...pshots_FusionWithAlreadySelectedTitre.html |   3 +-
 ..._snapshots_FusionWithoutSelectedTitre.html |   8 +
 .../src/components/titre/titres-link-form.tsx | 184 ++++++++++--------
 35 files changed, 307 insertions(+), 240 deletions(-)
 delete mode 100644 packages/common/src/static/__snapshots__/substancesLegales.test.ts.snap
 create mode 100644 packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithoutSelectedTitre.html
 create mode 100644 packages/ui/src/components/titre/titres-link-form.stories_snapshots_FusionWithoutSelectedTitre.html

diff --git a/packages/api/src/api/rest/demarches.ts b/packages/api/src/api/rest/demarches.ts
index c3de75315..1ba20b73e 100644
--- a/packages/api/src/api/rest/demarches.ts
+++ b/packages/api/src/api/rest/demarches.ts
@@ -25,7 +25,7 @@ import { isDemarcheTypeId, isTravaux } from 'camino-common/src/static/demarchesT
 import { titreGet } from '../../database/queries/titres'
 import { CaminoApiError } from '../../types'
 import { EffectDbQueryAndValidateErrors } from '../../pg-database'
-import { callAndExit } from '../../tools/fp-tools'
+import { callAndExit, filterOrFailFromValidWithError } from '../../tools/fp-tools'
 
 const canReadDemarcheError = 'impossible de savoir si on peut lire la démarche' as const
 
@@ -192,14 +192,9 @@ export const getResultatEnConcurrence: RestNewGetCall<'/rest/demarches/:demarche
   return rootPipe.pipe(
     Effect.bind('etapes', ({ pool, params }) => getEtapesByDemarcheId(pool, params.demarcheId)),
     Effect.bind('demarche', ({ pool, params }) => getDemarcheByIdOrSlug(pool, params.demarcheId)),
-    Effect.tap(({ etapes, demarche, user }) => {
-      const result = canPublishResultatMiseEnConcurrence(user, demarche.titre_type_id, demarche.demarche_type_id, etapes, demarche.demarche_id)
-      if (result.valid) {
-        return Effect.succeed(null)
-      } else {
-        return Effect.fail({ message: 'droits insuffisants' as const, detail: result.error })
-      }
-    }),
+    Effect.tap(({ etapes, demarche, user }) =>
+      filterOrFailFromValidWithError(canPublishResultatMiseEnConcurrence(user, demarche.titre_type_id, demarche.demarche_type_id, etapes, demarche.demarche_id), 'droits insuffisants' as const)
+    ),
     Effect.flatMap(({ pool, params, user }) => getDemarchePivotEnConcurrence(pool, params.demarcheId, user)),
     Effect.mapError(caminoError =>
       Match.value(caminoError.message).pipe(
diff --git a/packages/api/src/api/rest/titres.test.integration.ts b/packages/api/src/api/rest/titres.test.integration.ts
index 8701c1a82..d1193926a 100644
--- a/packages/api/src/api/rest/titres.test.integration.ts
+++ b/packages/api/src/api/rest/titres.test.integration.ts
@@ -141,7 +141,7 @@ async function createTitreWithEtapes(
   etapes: (Omit<ITitreEtape, 'id' | 'titreDemarcheId' | 'concurrence' | 'demarcheIdsConsentement' | 'hasTitreFrom'> & { demarcheIdsConsentement?: DemarcheId[] })[],
   entreprises: any
 ) {
-  const titreId = newTitreId()
+  const titreId = newTitreId(`id-${nomTitre}`)
   await insertTitreGraph({
     id: titreId,
     nom: nomTitre,
@@ -212,7 +212,7 @@ describe('titresLiaisons', () => {
     )
     const titreId = getTitres.body[0].id
 
-    const axmId = newTitreId()
+    const axmId = newTitreId('titreIdTitreLie')
     const axmNom = 'mon axm simple'
     await insertTitreGraph({
       id: axmId,
@@ -222,7 +222,7 @@ describe('titresLiaisons', () => {
       propsTitreEtapesIds: {},
     })
 
-    const tested = await restNewPostCall(
+    let tested = await restNewPostCall(
       dbPool,
       '/rest/titres/:id/titreLiaisons',
       { id: axmId },
@@ -233,15 +233,19 @@ describe('titresLiaisons', () => {
       [titreId]
     )
 
-    expect(tested.statusCode).toBe(200)
-    expect(tested.body.amont).toHaveLength(1)
-    expect(tested.body.aval).toHaveLength(0)
-    expect(tested.body.amont[0]).toStrictEqual({
-      id: titreId,
-      nom: getTitres.body[0].nom,
-    })
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "amont": [
+          {
+            "id": "id-titre1",
+            "nom": "titre1",
+          },
+        ],
+        "aval": [],
+      }
+    `)
 
-    const avalTested = await restNewCall(
+    let avalTested = await restNewCall(
       dbPool,
       '/rest/titres/:id/titreLiaisons',
       { id: titreId },
@@ -251,13 +255,52 @@ describe('titresLiaisons', () => {
       }
     )
 
-    expect(avalTested.statusCode).toBe(200)
-    expect(avalTested.body.amont).toHaveLength(0)
-    expect(avalTested.body.aval).toHaveLength(1)
-    expect(avalTested.body.aval[0]).toStrictEqual({
-      id: axmId,
-      nom: axmNom,
-    })
+    expect(avalTested.body).toMatchInlineSnapshot(`
+      {
+        "amont": [],
+        "aval": [
+          {
+            "id": "titreIdTitreLie",
+            "nom": "mon axm simple",
+          },
+        ],
+      }
+    `)
+
+    // On vérifie qu'on peut bien supprimer les liens
+    tested = await restNewPostCall(
+      dbPool,
+      '/rest/titres/:id/titreLiaisons',
+      { id: axmId },
+      {
+        role: 'admin',
+        administrationId: ADMINISTRATION_IDS['DGTM - GUYANE'],
+      },
+      []
+    )
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "amont": [],
+        "aval": [],
+      }
+    `)
+
+    avalTested = await restNewCall(
+      dbPool,
+      '/rest/titres/:id/titreLiaisons',
+      { id: titreId },
+      {
+        role: 'admin',
+        administrationId: ADMINISTRATION_IDS['DGTM - GUYANE'],
+      }
+    )
+
+    expect(avalTested.body).toMatchInlineSnapshot(`
+      {
+        "amont": [],
+        "aval": [],
+      }
+    `)
   })
 })
 
diff --git a/packages/api/src/api/rest/titres.ts b/packages/api/src/api/rest/titres.ts
index 61a0ded54..c44b988e0 100644
--- a/packages/api/src/api/rest/titres.ts
+++ b/packages/api/src/api/rest/titres.ts
@@ -5,7 +5,7 @@ import { CaminoRequest, CustomResponse } from './express-type'
 import { userSuper } from '../../database/user-super'
 import { isNotNullNorUndefined, isNotNullNorUndefinedNorEmpty, onlyUnique } from 'camino-common/src/typescript-tools'
 import { CaminoApiError } from '../../types'
-import { NotNullableKeys, isNullOrUndefinedOrEmpty } from 'camino-common/src/typescript-tools'
+import { isNullOrUndefinedOrEmpty } from 'camino-common/src/typescript-tools'
 import TitresTitres from '../../database/models/titres--titres'
 import { titreAdministrationsGet } from '../_format/titres'
 import { canDeleteTitre, canEditTitre, canLinkTitres } from 'camino-common/src/permissions/titres'
@@ -31,6 +31,7 @@ import { CaminoMachines, machineFind } from '../../business/rules-demarches/mach
 import { demarcheEnregistrementDemandeDateFind } from 'camino-common/src/demarche'
 import { DBTitre } from '../../database/models/titres'
 import { AdministrationId } from 'camino-common/src/static/administrations'
+import { filterOrFailFromValid } from '../../tools/fp-tools'
 
 const etapesAMasquer = [ETAPES_TYPES.classementSansSuite, ETAPES_TYPES.desistementDuDemandeur, ETAPES_TYPES.demandeDeComplements_RecevabiliteDeLaDemande_]
 
@@ -232,15 +233,16 @@ export const postTitreLiaisons: RestNewPostCall<'/rest/titres/:id/titreLiaisons'
             user
           ),
         catch: e => ({ message: "Impossible d'exécuter la requête dans la base de données" as const, extra: e }),
-      })
-    ),
-    Effect.filterOrFail(
-      (binded): binded is NotNullableKeys<typeof binded> => isNotNullNorUndefined(binded.titre),
-      () => ({ message: droitInsuffisant })
+      }).pipe(
+        Effect.filterOrFail(
+          titre => isNotNullNorUndefined(titre),
+          () => ({ message: droitInsuffisant })
+        )
+      )
     ),
     Effect.bind('administrations', ({ titre }) =>
-      Effect.tryPromise({
-        try: async () => titreAdministrationsGet(titre),
+      Effect.try({
+        try: () => titreAdministrationsGet(titre),
         catch: e => ({ message: "Impossible d'exécuter la requête dans la base de données" as const, extra: e }),
       })
     ),
@@ -252,23 +254,16 @@ export const postTitreLiaisons: RestNewPostCall<'/rest/titres/:id/titreLiaisons'
       ({ titre }) => isNotNullNorUndefined(titre.demarches),
       () => ({ message: demarcheNonChargeesError })
     ),
-    Effect.bind('titresFrom', ({ body, user }) =>
-      Effect.tryPromise({
-        try: async () => titresGet({ ids: [...body] }, { fields: { id: {} } }, user),
-        catch: e => ({ message: "Impossible d'exécuter la requête dans la base de données" as const, extra: e }),
-      })
-    ),
-    Effect.tap(({ titre, titresFrom, body }) => {
-      const result = checkTitreLinks(titre.typeId, body, titresFrom, titre.demarches ?? [])
-
-      if (result.valid) {
-        return Effect.succeed(null)
-      } else {
-        console.warn(result.errors)
-
-        return Effect.fail({ message: result.errors[0], extra: result.errors })
+    Effect.bind('titresFrom', ({ body, user }) => {
+      if (body.length > 0) {
+        return Effect.tryPromise({
+          try: () => titresGet({ ids: [...body] }, { fields: { id: {} } }, user),
+          catch: e => ({ message: "Impossible d'exécuter la requête dans la base de données" as const, extra: e }),
+        })
       }
+      return Effect.succeed([])
     }),
+    Effect.tap(({ titre, titresFrom, body }) => filterOrFailFromValid(checkTitreLinks(titre.typeId, body, titresFrom, titre.demarches ?? []))),
     Effect.tap(({ pool, params, body }) => linkTitres(pool, { linkTo: params.id, linkFrom: body })),
     Effect.bind('amont', ({ params, user }) => titreLinksGet(params.id, 'titreFromId', user)),
     Effect.bind('aval', ({ params, user }) => titreLinksGet(params.id, 'titreToId', user)),
diff --git a/packages/api/src/business/validations/titre-links-validate.ts b/packages/api/src/business/validations/titre-links-validate.ts
index 867632301..5dd14fe26 100644
--- a/packages/api/src/business/validations/titre-links-validate.ts
+++ b/packages/api/src/business/validations/titre-links-validate.ts
@@ -1,19 +1,14 @@
 import { TitreId } from 'camino-common/src/validators/titres'
 import { ITitre, ITitreDemarche } from '../../types'
 import { getLinkConfig } from 'camino-common/src/permissions/titres'
-import { NonEmptyArray, isNonEmptyArray, isNullOrUndefined } from 'camino-common/src/typescript-tools'
+import { CaminoValid, isNonEmptyArray, isNullOrUndefined } from 'camino-common/src/typescript-tools'
 import { TitreTypeId } from 'camino-common/src/static/titresTypes'
 
 const linkImpossible = 'ce titre ne peut pas être lié à d’autres titres' as const
 const oneLinkTitre = 'ce titre peut avoir un seul titre lié' as const
 const droitsInsuffisants = 'droits insuffisants ou titre inexistant' as const
 export type CheckTitreLinksError = typeof linkImpossible | typeof oneLinkTitre | typeof droitsInsuffisants | 'lien incompatible entre ces types de titre'
-export const checkTitreLinks = (
-  titreTypeId: TitreTypeId,
-  titreFromIds: Readonly<TitreId[]>,
-  titresFrom: ITitre[],
-  demarches: ITitreDemarche[]
-): { valid: true } | { valid: false; errors: NonEmptyArray<CheckTitreLinksError> } => {
+export const checkTitreLinks = (titreTypeId: TitreTypeId, titreFromIds: Readonly<TitreId[]>, titresFrom: ITitre[], demarches: ITitreDemarche[]): CaminoValid<CheckTitreLinksError> => {
   const linkConfig = getLinkConfig(
     titreTypeId,
     demarches.map(({ typeId }) => ({ demarche_type_id: typeId }))
@@ -39,5 +34,5 @@ export const checkTitreLinks = (
     return { valid: false, errors }
   }
 
-  return { valid: true }
+  return { valid: true, errors: null }
 }
diff --git a/packages/api/src/tools/fp-tools.ts b/packages/api/src/tools/fp-tools.ts
index 5ced5c460..336794612 100644
--- a/packages/api/src/tools/fp-tools.ts
+++ b/packages/api/src/tools/fp-tools.ts
@@ -1,6 +1,6 @@
-import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools'
+import { CaminoValid, isNotNullNorUndefined } from 'camino-common/src/typescript-tools'
 import { CaminoError, CaminoZodErrorReadableMessage, translateIssue } from 'camino-common/src/zod-tools'
-import { Cause, Effect, Exit, pipe } from 'effect'
+import { Cause, Effect, Exit, Option, pipe } from 'effect'
 import { ZodTypeAny } from 'zod'
 import { fromError, isZodErrorLike } from 'zod-validation-error'
 
@@ -73,3 +73,9 @@ export const shortCircuitError = <T extends string>(value: T, ...stuff: unknown[
   console.debug(`shortCircuit ${value}`, ...stuff)
   return { _tag: value }
 }
+
+export const filterOrFailFromValid = <T extends string>(value: CaminoValid<T>): Effect.Effect<void, CaminoError<T>> =>
+  value.valid ? Effect.succeed(Option.none) : Effect.fail({ message: value.errors[0], detail: value.errors.join(', ') })
+
+export const filterOrFailFromValidWithError = <ErrorMessage extends string>(value: CaminoValid<string>, errorMessage: ErrorMessage): Effect.Effect<void, CaminoError<ErrorMessage>> =>
+  value.valid ? Effect.succeed(Option.none) : Effect.fail({ message: errorMessage, detail: value.errors.join(', ') })
diff --git a/packages/common/src/permissions/titres-demarches.test.ts b/packages/common/src/permissions/titres-demarches.test.ts
index af81bbccd..38749f47d 100644
--- a/packages/common/src/permissions/titres-demarches.test.ts
+++ b/packages/common/src/permissions/titres-demarches.test.ts
@@ -118,11 +118,13 @@ describe('canPublishResultatMiseEnConcurrence', () => {
   test('machine non procédure spécifique', () => {
     expect(canPublishResultatMiseEnConcurrence({ ...testBlankUser, role: 'super' }, 'arm', 'oct', [{ date: toCaminoDate('2020-01-01'), etape_statut_id: 'fai', etape_type_id: 'mfr' }], demarcheId))
       .toMatchInlineSnapshot(`
-      {
-        "error": "Cette démarche n'est pas une procédure spécifique",
-        "valid": false,
-      }
-    `)
+        {
+          "errors": [
+            "Cette démarche n'est pas une procédure spécifique",
+          ],
+          "valid": false,
+        }
+      `)
   })
   test('mise en concurrence non terminée', () => {
     expect(
@@ -135,7 +137,9 @@ describe('canPublishResultatMiseEnConcurrence', () => {
       )
     ).toMatchInlineSnapshot(`
       {
-        "error": "Cette démarche n'a pas terminé sa mise en concurrence",
+        "errors": [
+          "Cette démarche n'a pas terminé sa mise en concurrence",
+        ],
         "valid": false,
       }
     `)
@@ -158,7 +162,9 @@ describe('canPublishResultatMiseEnConcurrence', () => {
       )
     ).toMatchInlineSnapshot(`
       {
-        "error": "L'utilisateur ne dispose pas des droits suffisants",
+        "errors": [
+          "L'utilisateur ne dispose pas des droits suffisants",
+        ],
         "valid": false,
       }
     `)
@@ -167,7 +173,9 @@ describe('canPublishResultatMiseEnConcurrence', () => {
   test("pas d'étapes", () => {
     expect(canPublishResultatMiseEnConcurrence({ ...testBlankUser, role: 'super' }, 'arm', 'oct', [], demarcheId)).toMatchInlineSnapshot(`
       {
-        "error": "Au moins une étape est nécessaire",
+        "errors": [
+          "Au moins une étape est nécessaire",
+        ],
         "valid": false,
       }
     `)
@@ -191,7 +199,7 @@ describe('canPublishResultatMiseEnConcurrence', () => {
       )
     ).toMatchInlineSnapshot(`
       {
-        "error": null,
+        "errors": null,
         "valid": true,
       }
     `)
@@ -221,7 +229,9 @@ describe('canPublishResultatMiseEnConcurrence', () => {
       )
     ).toMatchInlineSnapshot(`
       {
-        "error": "Cette démarche a déja un résultat final de la mise en concurrence",
+        "errors": [
+          "Cette démarche a déja un résultat final de la mise en concurrence",
+        ],
         "valid": false,
       }
     `)
diff --git a/packages/common/src/permissions/titres-demarches.ts b/packages/common/src/permissions/titres-demarches.ts
index 34ae82575..9e1c33234 100644
--- a/packages/common/src/permissions/titres-demarches.ts
+++ b/packages/common/src/permissions/titres-demarches.ts
@@ -8,7 +8,7 @@ import { getEtapesTDE } from '../static/titresTypes_demarchesTypes_etapesTypes/i
 import { DemarcheTypeId } from '../static/demarchesTypes'
 import { canCreateEtape } from './titres-etapes'
 import { TitreGetDemarche } from '../titres'
-import { isNotNullNorUndefinedNorEmpty, isNullOrUndefined } from '../typescript-tools'
+import { CaminoValid, isNotNullNorUndefinedNorEmpty, isNullOrUndefined } from '../typescript-tools'
 import { ETAPE_IS_BROUILLON } from '../etape'
 import { demarcheEnregistrementDemandeDateFind, DemarcheEtape, DemarcheId } from '../demarche'
 import { machineIdFind } from '../machines'
@@ -94,19 +94,19 @@ export const canPublishResultatMiseEnConcurrence = (
   demarche_type_id: DemarcheTypeId,
   etapes: Pick<DemarcheEtape, 'etape_type_id' | 'demarche_id_en_concurrence' | 'date' | 'etape_statut_id'>[],
   id: DemarcheId
-): { valid: true; error: null } | { valid: false; error: string } => {
+): CaminoValid<string> => {
   if (!isSuper(user) && !isAdministrationAdmin(user) && !isAdministrationEditeur(user)) {
-    return { valid: false, error: "L'utilisateur ne dispose pas des droits suffisants" }
+    return { valid: false, errors: ["L'utilisateur ne dispose pas des droits suffisants"] }
   }
 
   const firstEtapeDate = demarcheEnregistrementDemandeDateFind(etapes.map(etape => ({ ...etape, typeId: etape.etape_type_id })))
   if (isNullOrUndefined(firstEtapeDate)) {
-    return { valid: false, error: 'Au moins une étape est nécessaire' }
+    return { valid: false, errors: ['Au moins une étape est nécessaire'] }
   }
   const machineId = machineIdFind(titre_type_id, demarche_type_id, id, firstEtapeDate)
 
   if (machineId !== 'ProcedureSpecifique') {
-    return { valid: false, error: "Cette démarche n'est pas une procédure spécifique" }
+    return { valid: false, errors: ["Cette démarche n'est pas une procédure spécifique"] }
   }
 
   if (
@@ -115,12 +115,12 @@ export const canPublishResultatMiseEnConcurrence = (
         etape_type_id === ETAPES_TYPES.avisDeMiseEnConcurrenceAuJORF && etape_statut_id === EtapesTypesEtapesStatuts.avisDeMiseEnConcurrenceAuJORF.TERMINE.etapeStatutId
     )
   ) {
-    return { valid: false, error: "Cette démarche n'a pas terminé sa mise en concurrence" }
+    return { valid: false, errors: ["Cette démarche n'a pas terminé sa mise en concurrence"] }
   }
 
   if (etapes.some(({ etape_type_id }) => etape_type_id === ETAPES_TYPES.resultatMiseEnConcurrence)) {
-    return { valid: false, error: `Cette démarche a déja un ${EtapesTypes[ETAPES_TYPES.resultatMiseEnConcurrence].nom}` }
+    return { valid: false, errors: [`Cette démarche a déja un ${EtapesTypes[ETAPES_TYPES.resultatMiseEnConcurrence].nom}`] }
   }
 
-  return { valid: true, error: null }
+  return { valid: true, errors: null }
 }
diff --git a/packages/common/src/permissions/titres.ts b/packages/common/src/permissions/titres.ts
index fbf7a9368..12f134873 100644
--- a/packages/common/src/permissions/titres.ts
+++ b/packages/common/src/permissions/titres.ts
@@ -16,7 +16,8 @@ import { EntrepriseId } from '../entreprise'
 import { TITRES_TYPES_TYPES_IDS } from '../static/titresTypesTypes'
 
 export const canSeeTitreLastModifiedDate = (user: User): boolean => isSuper(user) || isAdministration(user)
-export const getLinkConfig = (typeId: TitreTypeId, demarches: { demarche_type_id: DemarcheTypeId }[]): { count: 'single' | 'multiple'; typeId: TitreTypeId } | null => {
+export type LinkConfig = { count: 'single' | 'multiple'; typeId: TitreTypeId }
+export const getLinkConfig = (typeId: TitreTypeId, demarches: { demarche_type_id: DemarcheTypeId }[]): LinkConfig | null => {
   const titreType = TitresTypes[typeId]
 
   if (titreType.typeId === TITRES_TYPES_TYPES_IDS.CONCESSION && demarches.some(({ demarche_type_id }) => demarche_type_id === DEMARCHES_TYPES_IDS.Fusion)) {
diff --git a/packages/common/src/static/__snapshots__/substancesLegales.test.ts.snap b/packages/common/src/static/__snapshots__/substancesLegales.test.ts.snap
deleted file mode 100644
index e6df61632..000000000
--- a/packages/common/src/static/__snapshots__/substancesLegales.test.ts.snap
+++ /dev/null
@@ -1,77 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`substancesFiscalesBySubstanceLegale 1`] = `
-[
-  {
-    "calculFiscalite": {
-      "unite": "mkg",
-    },
-    "description": "contenu dans les minerais",
-    "id": "auru",
-    "nom": "or",
-    "substanceLegaleId": "auru",
-    "uniteId": "mgr",
-  },
-]
-`;
-
-exports[`substancesFiscalesBySubstanceLegale 2`] = `
-[
-  {
-    "description": "bauxite nettes livrées",
-    "id": "aloh",
-    "nom": "bauxite",
-    "substanceLegaleId": "aloh",
-    "uniteId": "mtk",
-  },
-  {
-    "description": "contenu dans les minerais",
-    "id": "cuiv",
-    "nom": "cuivre",
-    "substanceLegaleId": "cuiv",
-    "uniteId": "mtt",
-  },
-  {
-    "description": "contenu dans les minerais",
-    "id": "etai",
-    "nom": "étain",
-    "substanceLegaleId": "etai",
-    "uniteId": "mtt",
-  },
-  {
-    "description": "net livré",
-    "id": "fera",
-    "nom": "pyrite de fer",
-    "substanceLegaleId": "ferx",
-    "uniteId": "mtk",
-  },
-  {
-    "description": "net livré",
-    "id": "ferb",
-    "nom": "minerais de fer",
-    "substanceLegaleId": "ferx",
-    "uniteId": "mtk",
-  },
-  {
-    "description": "contenu dans les minerais",
-    "id": "mang",
-    "nom": "manganèse",
-    "substanceLegaleId": "mang",
-    "uniteId": "mtc",
-  },
-  {
-    "description": "contenu dans les minerais",
-    "id": "plom",
-    "nom": "plomb",
-    "substanceLegaleId": "plom",
-    "uniteId": "mtc",
-  },
-  {
-    "description": "contenu dans les minerais",
-    "id": "zinc",
-    "nom": "zinc",
-    "substanceLegaleId": "zinc",
-    "uniteId": "mtc",
-  },
-]
-`;
diff --git a/packages/common/src/typescript-tools.ts b/packages/common/src/typescript-tools.ts
index 25d913ebb..94a4099e1 100644
--- a/packages/common/src/typescript-tools.ts
+++ b/packages/common/src/typescript-tools.ts
@@ -75,6 +75,8 @@ export const exhaustiveCheck = (param: never): never => {
   throw new Error(`Unreachable case: ${JSON.stringify(param)}`)
 }
 
+export type CaminoValid<T extends string> = { valid: true; errors: null } | { valid: false; errors: NonEmptyArray<T> }
+
 export type NonEmptyArray<T> = [T, ...T[]]
 export const isNonEmptyArray = <T>(arr: T[]): arr is NonEmptyArray<T> => {
   return arr.length > 0
diff --git a/packages/ui/src/components/titre.stories_snapshots_AbattisKoticaOctroi.html b/packages/ui/src/components/titre.stories_snapshots_AbattisKoticaOctroi.html
index 554912ee9..4309bf9ac 100644
--- a/packages/ui/src/components/titre.stories_snapshots_AbattisKoticaOctroi.html
+++ b/packages/ui/src/components/titre.stories_snapshots_AbattisKoticaOctroi.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_BasseManaMod.html b/packages/ui/src/components/titre.stories_snapshots_BasseManaMod.html
index 503bd2710..2ac6e2f49 100644
--- a/packages/ui/src/components/titre.stories_snapshots_BasseManaMod.html
+++ b/packages/ui/src/components/titre.stories_snapshots_BasseManaMod.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_BonEspoirOctroi.html b/packages/ui/src/components/titre.stories_snapshots_BonEspoirOctroi.html
index 8e2db12e7..910a4e7c1 100644
--- a/packages/ui/src/components/titre.stories_snapshots_BonEspoirOctroi.html
+++ b/packages/ui/src/components/titre.stories_snapshots_BonEspoirOctroi.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_BonEspoirProlongation2.html b/packages/ui/src/components/titre.stories_snapshots_BonEspoirProlongation2.html
index 6c3e3960e..fd0a69a20 100644
--- a/packages/ui/src/components/titre.stories_snapshots_BonEspoirProlongation2.html
+++ b/packages/ui/src/components/titre.stories_snapshots_BonEspoirProlongation2.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_BonEspoirTravaux.html b/packages/ui/src/components/titre.stories_snapshots_BonEspoirTravaux.html
index 2f93b97b0..3dbda53e3 100644
--- a/packages/ui/src/components/titre.stories_snapshots_BonEspoirTravaux.html
+++ b/packages/ui/src/components/titre.stories_snapshots_BonEspoirTravaux.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_ChantepieMutation.html b/packages/ui/src/components/titre.stories_snapshots_ChantepieMutation.html
index ae95a59ba..4a19fbb8e 100644
--- a/packages/ui/src/components/titre.stories_snapshots_ChantepieMutation.html
+++ b/packages/ui/src/components/titre.stories_snapshots_ChantepieMutation.html
@@ -27,6 +27,10 @@
         <p>Il manque 2 rapports d'activités. <a href="/mocked-href" title="Remplir les rapports d'activités" class="fr-link" aria-label="Remplir les rapports d'activités">Remplir les rapports d'activités</a></p>
       </div><a href="/mocked-href" title="Consulter les rapports d'activités" class="fr-mt-2w fr-btn fr-btn--secondary" aria-label="Consulter les rapports d'activités">Consulter les rapports d'activités</a>
       <div>
+        <div style="display: flex; gap: 0.5rem; align-items: center;"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Lier un titre <div class="flex flex-center" style="gap: 0.5rem;"><button class="fr-btn fr-btn--tertiary-no-outline fr-btn--md fr-icon-pencil-line" title="modifier les titres liés" aria-label="modifier les titres liés" type="button">
+              <!---->
+            </button></div>
+        </div>
         <!---->
         <!---->
       </div>
diff --git a/packages/ui/src/components/titre.stories_snapshots_ChantepieOctroi.html b/packages/ui/src/components/titre.stories_snapshots_ChantepieOctroi.html
index 44f706b80..9173dcdef 100644
--- a/packages/ui/src/components/titre.stories_snapshots_ChantepieOctroi.html
+++ b/packages/ui/src/components/titre.stories_snapshots_ChantepieOctroi.html
@@ -28,6 +28,10 @@
         <p>Il manque 2 rapports d'activités. <a href="/mocked-href" title="Remplir les rapports d'activités" class="fr-link" aria-label="Remplir les rapports d'activités">Remplir les rapports d'activités</a></p>
       </div><a href="/mocked-href" title="Consulter les rapports d'activités" class="fr-mt-2w fr-btn fr-btn--secondary" aria-label="Consulter les rapports d'activités">Consulter les rapports d'activités</a>
       <div>
+        <div style="display: flex; gap: 0.5rem; align-items: center;"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Lier un titre <div class="flex flex-center" style="gap: 0.5rem;"><button class="fr-btn fr-btn--tertiary-no-outline fr-btn--md fr-icon-pencil-line" title="modifier les titres liés" aria-label="modifier les titres liés" type="button">
+              <!---->
+            </button></div>
+        </div>
         <!---->
         <!---->
       </div>
diff --git a/packages/ui/src/components/titre.stories_snapshots_ChantepieOctroiAsEntreprise.html b/packages/ui/src/components/titre.stories_snapshots_ChantepieOctroiAsEntreprise.html
index fed6d71f8..92eea09c4 100644
--- a/packages/ui/src/components/titre.stories_snapshots_ChantepieOctroiAsEntreprise.html
+++ b/packages/ui/src/components/titre.stories_snapshots_ChantepieOctroiAsEntreprise.html
@@ -30,6 +30,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_CriqueAdolpheOctroi.html b/packages/ui/src/components/titre.stories_snapshots_CriqueAdolpheOctroi.html
index 98b672ca7..6841a78b9 100644
--- a/packages/ui/src/components/titre.stories_snapshots_CriqueAdolpheOctroi.html
+++ b/packages/ui/src/components/titre.stories_snapshots_CriqueAdolpheOctroi.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_Empty.html b/packages/ui/src/components/titre.stories_snapshots_Empty.html
index b57faa16a..e0cd3adf4 100644
--- a/packages/ui/src/components/titre.stories_snapshots_Empty.html
+++ b/packages/ui/src/components/titre.stories_snapshots_Empty.html
@@ -26,6 +26,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_Full.html b/packages/ui/src/components/titre.stories_snapshots_Full.html
index 68081d2f0..69fb57374 100644
--- a/packages/ui/src/components/titre.stories_snapshots_Full.html
+++ b/packages/ui/src/components/titre.stories_snapshots_Full.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <div class="fr-alert fr-alert--warning fr-mt-2w">
diff --git a/packages/ui/src/components/titre.stories_snapshots_Lenoncourt.html b/packages/ui/src/components/titre.stories_snapshots_Lenoncourt.html
index 4b73581da..74b005837 100644
--- a/packages/ui/src/components/titre.stories_snapshots_Lenoncourt.html
+++ b/packages/ui/src/components/titre.stories_snapshots_Lenoncourt.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_TitreAvecUnOctroiEnConstructionEtUnTravaux.html b/packages/ui/src/components/titre.stories_snapshots_TitreAvecUnOctroiEnConstructionEtUnTravaux.html
index e56a8c733..15ac8c0c2 100644
--- a/packages/ui/src/components/titre.stories_snapshots_TitreAvecUnOctroiEnConstructionEtUnTravaux.html
+++ b/packages/ui/src/components/titre.stories_snapshots_TitreAvecUnOctroiEnConstructionEtUnTravaux.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <div class="fr-alert fr-alert--warning fr-mt-2w">
diff --git a/packages/ui/src/components/titre.stories_snapshots_TitreAvecUneSeuleDemarcheEnConstruction.html b/packages/ui/src/components/titre.stories_snapshots_TitreAvecUneSeuleDemarcheEnConstruction.html
index 264059426..e551ae8de 100644
--- a/packages/ui/src/components/titre.stories_snapshots_TitreAvecUneSeuleDemarcheEnConstruction.html
+++ b/packages/ui/src/components/titre.stories_snapshots_TitreAvecUneSeuleDemarcheEnConstruction.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <div class="fr-alert fr-alert--warning fr-mt-2w">
diff --git a/packages/ui/src/components/titre.stories_snapshots_WithDoublon.html b/packages/ui/src/components/titre.stories_snapshots_WithDoublon.html
index e3e2e1c33..bb3aded12 100644
--- a/packages/ui/src/components/titre.stories_snapshots_WithDoublon.html
+++ b/packages/ui/src/components/titre.stories_snapshots_WithDoublon.html
@@ -28,6 +28,7 @@
       <div>
         <!---->
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_WithLinkableTitreAmont.html b/packages/ui/src/components/titre.stories_snapshots_WithLinkableTitreAmont.html
index 069802e97..0fa8b039a 100644
--- a/packages/ui/src/components/titre.stories_snapshots_WithLinkableTitreAmont.html
+++ b/packages/ui/src/components/titre.stories_snapshots_WithLinkableTitreAmont.html
@@ -29,6 +29,7 @@
             </button></div>
         </div>
         <!---->
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre.stories_snapshots_WithLinkableTitreAval.html b/packages/ui/src/components/titre.stories_snapshots_WithLinkableTitreAval.html
index 5364209fe..d0e79253e 100644
--- a/packages/ui/src/components/titre.stories_snapshots_WithLinkableTitreAval.html
+++ b/packages/ui/src/components/titre.stories_snapshots_WithLinkableTitreAval.html
@@ -24,9 +24,13 @@
       <!---->
       <!---->
       <div>
-        <!---->
+        <div style="display: flex; gap: 0.5rem; align-items: center;"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Lier un titre <div class="flex flex-center" style="gap: 0.5rem;"><button class="fr-btn fr-btn--tertiary-no-outline fr-btn--md fr-icon-pencil-line" title="modifier les titres liés" aria-label="modifier les titres liés" type="button">
+              <!---->
+            </button></div>
+        </div>
         <div style="display: flex; gap: 0.5rem; align-items: center;" class="fr-mt-1w"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Titre issu de ce titre :<div class="flex flex-center" style="gap: 0.5rem;"><a href="/mocked-href" title="nom du titre en aval" class="fr-tag fr-tag--sm" aria-label="nom du titre en aval">nom du titre en aval</a></div>
         </div>
+        <!---->
       </div>
     </div>
     <!---->
diff --git a/packages/ui/src/components/titre/titres-link-form.stories.tsx b/packages/ui/src/components/titre/titres-link-form.stories.tsx
index 2b7e20932..483db4834 100644
--- a/packages/ui/src/components/titre/titres-link-form.stories.tsx
+++ b/packages/ui/src/components/titre/titres-link-form.stories.tsx
@@ -54,7 +54,7 @@ const titresFrom: TitreLink[] = [linkableTitres[0]]
 
 const apiClient: Props['apiClient'] = {
   loadLinkableTitres: () => () => Promise.resolve(linkableTitres),
-  loadTitreLinks: () => Promise.resolve({ aval: titresTo, amont: titresFrom }),
+  loadTitreLinks: async () => ({ aval: titresTo, amont: titresFrom }),
   linkTitres: () => new Promise<TitreLinks>(resolve => resolve({ aval: titresTo, amont: titresFrom })),
 }
 
@@ -67,6 +67,15 @@ export const AxmWithAlreadySelectedTitre: StoryFn = () => (
   />
 )
 
+export const AxmWithoutSelectedTitre: StoryFn = () => (
+  <TitresLinkForm
+    user={{ role: 'super', ...testBlankUser }}
+    titre={{ typeId: 'axm', administrations: [], id: titreIdValidator.parse('titreId'), demarches: [] }}
+    apiClient={{ ...apiClient, loadTitreLinks: async () => ({ aval: [], amont: [] }) }}
+    onTitresFromLoaded={() => {}}
+  />
+)
+
 export const AxmWithAlreadySelectedTitreNotEditable: StoryFn = () => (
   <TitresLinkForm
     user={{ role: 'defaut', ...testBlankUser }}
@@ -101,6 +110,26 @@ export const FusionWithAlreadySelectedTitre: StoryFn = () => (
   />
 )
 
+export const FusionWithoutSelectedTitre: StoryFn = () => (
+  <TitresLinkForm
+    user={{ role: 'super', ...testBlankUser }}
+    titre={{
+      typeId: 'cxm',
+      administrations: [],
+      id: titreIdValidator.parse('titreId'),
+      demarches: [{ demarche_type_id: 'fus' }],
+    }}
+    apiClient={{
+      ...apiClient,
+      loadTitreLinks: async () => ({
+        aval: [],
+        amont: [],
+      }),
+    }}
+    onTitresFromLoaded={() => {}}
+  />
+)
+
 export const TitreWithTitreLinksLoading: StoryFn = () => (
   <TitresLinkForm
     user={{ role: 'super', ...testBlankUser }}
diff --git a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithAlreadySelectedTitre.html b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithAlreadySelectedTitre.html
index ce240850c..ffb573fa5 100644
--- a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithAlreadySelectedTitre.html
+++ b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithAlreadySelectedTitre.html
@@ -5,4 +5,5 @@
   </div>
   <div style="display: flex; gap: 0.5rem; align-items: center;" class="fr-mt-1w"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Titre issu de ce titre :<div class="flex flex-center" style="gap: 0.5rem;"><a href="/mocked-href" title="Titre fils" class="fr-tag fr-tag--sm" aria-label="Titre fils">Titre fils</a></div>
   </div>
+  <!---->
 </div>
\ No newline at end of file
diff --git a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithAlreadySelectedTitreNotEditable.html b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithAlreadySelectedTitreNotEditable.html
index c94a248e0..5cc9908db 100644
--- a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithAlreadySelectedTitreNotEditable.html
+++ b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithAlreadySelectedTitreNotEditable.html
@@ -5,4 +5,5 @@
   </div>
   <div style="display: flex; gap: 0.5rem; align-items: center;" class="fr-mt-1w"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Titre issu de ce titre :<div class="flex flex-center" style="gap: 0.5rem;"><a href="/mocked-href" title="Titre fils" class="fr-tag fr-tag--sm" aria-label="Titre fils">Titre fils</a></div>
   </div>
+  <!---->
 </div>
\ No newline at end of file
diff --git a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithoutSelectedTitre.html b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithoutSelectedTitre.html
new file mode 100644
index 000000000..efa67a367
--- /dev/null
+++ b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_AxmWithoutSelectedTitre.html
@@ -0,0 +1,8 @@
+<div>
+  <div style="display: flex; gap: 0.5rem; align-items: center;"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Lier un titre <div class="flex flex-center" style="gap: 0.5rem;"><button class="fr-btn fr-btn--tertiary-no-outline fr-btn--md fr-icon-pencil-line" title="modifier les titres liés" aria-label="modifier les titres liés" type="button">
+        <!---->
+      </button></div>
+  </div>
+  <!---->
+  <!---->
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_DefautCantUpdateLinks.html b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_DefautCantUpdateLinks.html
index c94a248e0..5cc9908db 100644
--- a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_DefautCantUpdateLinks.html
+++ b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_DefautCantUpdateLinks.html
@@ -5,4 +5,5 @@
   </div>
   <div style="display: flex; gap: 0.5rem; align-items: center;" class="fr-mt-1w"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Titre issu de ce titre :<div class="flex flex-center" style="gap: 0.5rem;"><a href="/mocked-href" title="Titre fils" class="fr-tag fr-tag--sm" aria-label="Titre fils">Titre fils</a></div>
   </div>
+  <!---->
 </div>
\ No newline at end of file
diff --git a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_FusionWithAlreadySelectedTitre.html b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_FusionWithAlreadySelectedTitre.html
index 45fdcc049..dce627fb0 100644
--- a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_FusionWithAlreadySelectedTitre.html
+++ b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_FusionWithAlreadySelectedTitre.html
@@ -3,6 +3,7 @@
         <!---->
       </button></div>
   </div>
-  <div style="display: flex; gap: 0.5rem; align-items: center;" class="fr-mt-1w"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Titres issu de ce titre :<div class="flex flex-center" style="gap: 0.5rem;"><a href="/mocked-href" title="Titre fils" class="fr-tag fr-tag--sm" aria-label="Titre fils">Titre fils</a><a href="/mocked-href" title="Titre fils 2" class="fr-tag fr-tag--sm" aria-label="Titre fils 2">Titre fils 2</a></div>
+  <div style="display: flex; gap: 0.5rem; align-items: center;" class="fr-mt-1w"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Titres issus de ce titre :<div class="flex flex-center" style="gap: 0.5rem;"><a href="/mocked-href" title="Titre fils" class="fr-tag fr-tag--sm" aria-label="Titre fils">Titre fils</a><a href="/mocked-href" title="Titre fils 2" class="fr-tag fr-tag--sm" aria-label="Titre fils 2">Titre fils 2</a></div>
   </div>
+  <!---->
 </div>
\ No newline at end of file
diff --git a/packages/ui/src/components/titre/titres-link-form.stories_snapshots_FusionWithoutSelectedTitre.html b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_FusionWithoutSelectedTitre.html
new file mode 100644
index 000000000..07ae89d8e
--- /dev/null
+++ b/packages/ui/src/components/titre/titres-link-form.stories_snapshots_FusionWithoutSelectedTitre.html
@@ -0,0 +1,8 @@
+<div>
+  <div style="display: flex; gap: 0.5rem; align-items: center;"><span class="fr-icon-link fr-icon--sm" style="color: var(--text-title-blue-france);" aria-hidden="true"></span>Lier plusieurs titres <div class="flex flex-center" style="gap: 0.5rem;"><button class="fr-btn fr-btn--tertiary-no-outline fr-btn--md fr-icon-pencil-line" title="modifier les titres liés" aria-label="modifier les titres liés" type="button">
+        <!---->
+      </button></div>
+  </div>
+  <!---->
+  <!---->
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/titre/titres-link-form.tsx b/packages/ui/src/components/titre/titres-link-form.tsx
index 30f8ad1dd..642a01b7b 100644
--- a/packages/ui/src/components/titre/titres-link-form.tsx
+++ b/packages/ui/src/components/titre/titres-link-form.tsx
@@ -1,5 +1,5 @@
-import { canLinkTitres, getLinkConfig } from 'camino-common/src/permissions/titres'
-import { computed, defineComponent, onMounted, ref, watch } from 'vue'
+import { canLinkTitres, getLinkConfig, LinkConfig } from 'camino-common/src/permissions/titres'
+import { computed, defineComponent, onMounted, watch } from 'vue'
 import { TitreTypeId } from 'camino-common/src/static/titresTypes'
 import { User } from 'camino-common/src/roles'
 import { AdministrationId } from 'camino-common/src/static/administrations'
@@ -11,10 +11,12 @@ import { TitreLink, TitreLinks } from 'camino-common/src/titres'
 import { TitreId } from 'camino-common/src/validators/titres'
 import { ApiClient } from '@/api/api-client'
 import { TitresLinkConfig } from '@/components/titre/titres-link-form-api-client'
-import { DsfrButton, DsfrButtonIcon } from '../_ui/dsfr-button'
+import { DsfrButtonIcon } from '../_ui/dsfr-button'
 import { DsfrIcon } from '../_ui/icon'
 import { DsfrTag } from '../_ui/tag'
 import { isNotNullNorUndefined, isNotNullNorUndefinedNorEmpty } from 'camino-common/src/typescript-tools'
+import { useState } from '@/utils/vue-tsx-utils'
+import { FunctionalPopup } from '../_ui/functional-popup'
 
 export interface Props {
   user: User
@@ -28,9 +30,16 @@ export interface Props {
   onTitresFromLoaded: (hasTitresFrom: boolean) => void
 }
 export const TitresLinkForm = defineComponent<Props>(props => {
-  const mode = ref<'read' | 'edit'>('read')
-  const selectedTitres = ref<TitreLink[]>([])
-  const titresLinks = ref<AsyncData<TitreLinks>>({ status: 'LOADING' })
+  const [popupOpen, setPopupOpen] = useState<boolean>(false)
+  const [titresLinks, setTitresLinks] = useState<AsyncData<TitreLinks>>({ status: 'LOADING' })
+
+  const openEditPopup = () => {
+    setPopupOpen(true)
+  }
+
+  const closeEditPopup = () => {
+    setPopupOpen(false)
+  }
 
   const linkConfig = computed(() => getLinkConfig(props.titre.typeId, props.titre.demarches))
 
@@ -46,22 +55,21 @@ export const TitresLinkForm = defineComponent<Props>(props => {
   )
 
   const init = async () => {
-    titresLinks.value = { status: 'LOADING' }
+    setTitresLinks({ status: 'LOADING' })
     const result = await props.apiClient.loadTitreLinks(props.titre.id)
     if ('message' in result) {
-      titresLinks.value = {
+      setTitresLinks({
         status: 'NEW_ERROR',
         error: result,
-      }
+      })
     } else {
-      titresLinks.value = { status: 'LOADED', value: result }
+      setTitresLinks({ status: 'LOADED', value: result })
       props.onTitresFromLoaded(result.amont.length > 0)
-      selectedTitres.value = titresLinks.value.value.amont
     }
   }
 
   const canEditLink = computed<boolean>(() => {
-    // On ne peut pas lier si ce type de titre n’accepte pas de liaison
+    // On ne peut pas lier si ce type de titre n'accepte pas de liaison
     if (!linkConfig.value) {
       return false
     }
@@ -69,94 +77,45 @@ export const TitresLinkForm = defineComponent<Props>(props => {
     return canLinkTitres(props.user, props.titre.administrations ?? [])
   })
 
-  const titreLinkConfig = computed<TitresLinkConfig | null>(() => {
-    if (titresLinks.value.status !== 'LOADED') {
-      return null
-    }
-
-    const titreFromIds = titresLinks.value.value.amont.map(({ id }) => id)
-    if (linkConfig.value?.count === 'single') {
-      return {
-        type: 'single',
-        selectedTitreId: titreFromIds.length === 1 ? titreFromIds[0] : null,
-      }
-    }
-
-    return {
-      type: 'multiple',
-      selectedTitreIds: titreFromIds,
-    }
-  })
-
-  const onSelectedTitres = (titres: TitreLink[]) => {
-    selectedTitres.value = titres
-  }
-
-  const saveLink = async () => {
-    titresLinks.value = { status: 'LOADING' }
-    try {
-      const links = await props.apiClient.linkTitres(
-        props.titre.id,
-        selectedTitres.value.map(({ id }) => id)
-      )
-      if ('message' in links) {
-        titresLinks.value = {
-          status: 'NEW_ERROR',
-          error: links,
-        }
-      } else {
-        mode.value = 'read'
-        titresLinks.value = {
+  const myApiClient = {
+    ...props.apiClient,
+    linkTitres: async (titreId: TitreId, titreFromIds: TitreId[]) => {
+      const links = await props.apiClient.linkTitres(titreId, titreFromIds)
+      if (!('message' in links)) {
+        setTitresLinks({
           status: 'LOADED',
           value: links,
-        }
+        })
       }
-    } catch (e: any) {
-      titresLinks.value = {
-        status: 'ERROR',
-        message: e.message ?? 'something wrong happened',
-      }
-    }
+      return links
+    },
   }
 
-  const closeForm = () => (mode.value = 'read')
-
   return () => (
     <div>
       <LoadingElement
         data={titresLinks.value}
         renderItem={item => (
           <>
-            {isNotNullNorUndefinedNorEmpty(item.amont) && isNotNullNorUndefined(linkConfig.value) ? (
+            {(isNotNullNorUndefinedNorEmpty(item.amont) || canEditLink.value) && isNotNullNorUndefined(linkConfig.value) ? (
               <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
                 <DsfrIcon name="fr-icon-link" size="sm" color="text-title-blue-france" aria-hidden="true" />
-                Titre{item.amont.length > 1 ? 's' : ''} à l'origine de ce titre :
-                {mode.value === 'edit' ? (
-                  <>
-                    {titreLinkConfig.value ? (
-                      <TitresLink config={titreLinkConfig.value} loadLinkableTitres={props.apiClient.loadLinkableTitres(props.titre.typeId, props.titre.demarches)} onSelectTitres={onSelectedTitres} />
-                    ) : null}
-                    <>
-                      <DsfrButton buttonType="primary" title="Enregistrer" onClick={saveLink} />
-                      <DsfrButton buttonType="secondary" title="Annuler" onClick={closeForm} />
-                    </>
-                  </>
-                ) : (
-                  <div class="flex flex-center" style={{ gap: '0.5rem' }}>
-                    {item.amont.map(titreFrom => (
-                      <DsfrTag key={titreFrom.id} tagSize="sm" to={{ name: 'titre', params: { id: titreFrom.id } }} ariaLabel={titreFrom.nom} />
-                    ))}
-
-                    {canEditLink.value ? <DsfrButtonIcon buttonType="tertiary-no-outline" title="modifier les titres liés" onClick={() => (mode.value = 'edit')} icon="fr-icon-pencil-line" /> : null}
-                  </div>
-                )}
+                {item.amont.length === 0 ? <>Lier {linkConfig.value.count === 'single' ? 'un titre' : 'plusieurs titres'} </> : <>Titre{item.amont.length > 1 ? 's' : ''} à l'origine de ce titre :</>}
+
+                <div class="flex flex-center" style={{ gap: '0.5rem' }}>
+                  {item.amont.map(titreFrom => (
+                    <DsfrTag key={titreFrom.id} tagSize="sm" to={{ name: 'titre', params: { id: titreFrom.id } }} ariaLabel={titreFrom.nom} />
+                  ))}
+
+                  {canEditLink.value ? <DsfrButtonIcon buttonType="tertiary-no-outline" title="modifier les titres liés" onClick={openEditPopup} icon="fr-icon-pencil-line" /> : null}
+                </div>
               </div>
             ) : null}
 
             {item.aval.length ? (
               <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }} class="fr-mt-1w">
                 <DsfrIcon name="fr-icon-link" size="sm" color="text-title-blue-france" aria-hidden="true" />
-                Titre{item.aval.length > 1 ? 's' : ''} issu de ce titre :
+                {item.aval.length > 1 ? 'Titres issus ' : 'Titre issu'} de ce titre :
                 <div class="flex flex-center" style={{ gap: '0.5rem' }}>
                   {item.aval.map(titreTo => (
                     <DsfrTag key={titreTo.id} tagSize="sm" to={{ name: 'titre', params: { id: titreTo.id } }} ariaLabel={titreTo.nom} />
@@ -164,6 +123,9 @@ export const TitresLinkForm = defineComponent<Props>(props => {
                 </div>
               </div>
             ) : null}
+            {popupOpen.value && isNotNullNorUndefined(linkConfig.value) ? (
+              <EditPopup titreLinks={item} linkConfig={linkConfig.value} close={closeEditPopup} apiClient={myApiClient} titre={props.titre} />
+            ) : null}
           </>
         )}
       />
@@ -171,5 +133,65 @@ export const TitresLinkForm = defineComponent<Props>(props => {
   )
 })
 
+interface EditPopupProps {
+  titreLinks: TitreLinks
+  linkConfig: LinkConfig
+  titre: {
+    id: TitreId
+    typeId: TitreTypeId
+    administrations: AdministrationId[]
+    demarches: { demarche_type_id: DemarcheTypeId }[]
+  }
+  close: () => void
+  apiClient: Pick<ApiClient, 'loadTitreLinks' | 'loadLinkableTitres' | 'linkTitres'>
+}
+
+const EditPopup = defineComponent<EditPopupProps>(props => {
+  const [selectedTitres, setSelectedTitres] = useState<TitreLink[]>([])
+  const onSelectedTitres = (titres: TitreLink[]) => {
+    setSelectedTitres(titres)
+  }
+  const titreLinkConfig = computed<TitresLinkConfig>(() => {
+    const titreFromIds = props.titreLinks.amont.map(({ id }) => id)
+    if (props.linkConfig.count === 'single') {
+      return {
+        type: 'single',
+        selectedTitreId: titreFromIds.length === 1 ? titreFromIds[0] : null,
+      }
+    }
+
+    return {
+      type: 'multiple',
+      selectedTitreIds: titreFromIds,
+    }
+  })
+
+  const content = () => (
+    <form>
+      <TitresLink config={titreLinkConfig.value} loadLinkableTitres={props.apiClient.loadLinkableTitres(props.titre.typeId, props.titre.demarches)} onSelectTitres={onSelectedTitres} />
+    </form>
+  )
+
+  return () => (
+    <FunctionalPopup
+      title="Modification des liens entre les titres"
+      content={content}
+      close={props.close}
+      validate={{
+        action: () =>
+          props.apiClient.linkTitres(
+            props.titre.id,
+            selectedTitres.value.map(({ id }) => id)
+          ),
+        text: 'Enregistrer',
+      }}
+      canValidate={true}
+    />
+  )
+})
+
 // @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
 TitresLinkForm.props = ['apiClient', 'titre', 'user', 'onTitresFromLoaded']
+
+// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
+EditPopup.props = ['apiClient', 'titre', 'titreLinks', 'linkConfig', 'close']
-- 
GitLab