Repository: opendatateam/udata-front-kit
Pull request #678: feat(dataset): multi attribution
Author: login: abulte
Created: 2025-03-04T14:17:32Z | Merged: 2025-03-05T10:49:40Z

Fix https://github.com/ecolabdata/ecospheres/issues/536

<img width="978" alt="Capture d’écran 2025-03-04 à 15 16 17" src="https://github.com/user-attachments/assets/9d5e634b-efc9-4b34-821e-777d5eb8461b" />

Exemple : https://deploy-preview-678--ecospheres.netlify.app/datasets/67b42e2a11a9813d380bbd5b/
Pour référence : https://demo.data.gouv.fr/fr/datasets/67b42e2a11a9813d380bbd5b/


Diff:
diff --git a/src/components/OrganizationLogo.vue b/src/components/OrganizationLogo.vue
index f40f72a11..8fabe4084 100644
--- a/src/components/OrganizationLogo.vue
+++ b/src/components/OrganizationLogo.vue
@@ -3,12 +3,12 @@ import type { DatasetV2 } from '@datagouv/components'
 import { computed } from 'vue'
 
 import config from '@/config'
-import type { WithOwned } from '@/model'
+import type { ExtendedDatasetV2 } from '@/model/dataset'
 import type { Topic } from '@/model/topic'
 
 const props = defineProps({
   object: {
-    type: Object as () => WithOwned<DatasetV2 | Topic>,
+    type: Object as () => DatasetV2 | ExtendedDatasetV2 | Topic,
     required: true
   },
   size: {
diff --git a/src/components/datasets/ContactPoints.vue b/src/components/datasets/ContactPoints.vue
new file mode 100644
index 000000000..3a48c67a9
--- /dev/null
+++ b/src/components/datasets/ContactPoints.vue
@@ -0,0 +1,53 @@
+<script setup lang="ts">
+import type { ContactPoint } from '@/model/dataset'
+import { useContactPointStore } from '@/store/ContactPointStore'
+import { storeToRefs } from 'pinia'
+
+const store = useContactPointStore()
+const { roles } = storeToRefs(store)
+
+defineProps({
+  contactPoints: {
+    type: Object as PropType<ContactPoint[]>,
+    required: true
+  }
+})
+
+const getRoleLabel = (roleId: string) => {
+  const role = roles.value.find((role) => role.id === roleId)
+  return role ? role.label : roleId
+}
+
+const getLink = (contactPoint: ContactPoint) => {
+  if (contactPoint.email) {
+    return `mailto:${contactPoint.email}`
+  } else if (contactPoint.contact_form) {
+    return contactPoint.contact_form
+  }
+  return '#'
+}
+
+onMounted(() => {
+  store.loadRoles()
+})
+</script>
+
+<template>
+  <p
+    v-for="contactPoint in contactPoints"
+    :key="contactPoint.id"
+    class="fr-text--sm fr-mt-0 fr-mb-1w fr-grid-row no-wrap fr-grid-row--middle"
+  >
+    <a
+      :href="getLink(contactPoint)"
+      rel="ugc nofollow noopener"
+      target="_blank"
+      class="fr-text--sm fr-link text-grey-500 text-overflow-ellipsis overflow-hidden"
+    >
+      {{ contactPoint.name || contactPoint.email || contactPoint.contact_form }}
+    </a>
+    <small class="fr-ml-1w fr-text--xs fr-m-0 text-grey-380 f-italic"
+      >({{ getRoleLabel(contactPoint.role) }})</small
+    >
+  </p>
+</template>
diff --git a/src/components/datasets/ExtendedInformationPanel.vue b/src/components/datasets/ExtendedInformationPanel.vue
index 58ecf1844..40c68efd9 100644
--- a/src/components/datasets/ExtendedInformationPanel.vue
+++ b/src/components/datasets/ExtendedInformationPanel.vue
@@ -12,15 +12,7 @@ const props = defineProps({
 const dcatExtras = props.dataset.extras?.dcat
 const uri: string | undefined = props.dataset.harvest?.uri
 
-const contactPoints = props.dataset.contact_points
-  .filter((contactPoint) => contactPoint.role === 'contact')
-  .map((contactPoint) => {
-    return contactPoint.email
-      ? `<a href="mailto:${contactPoint.email}">${contactPoint.name}</a>`
-      : contactPoint.name
-  })
-
-const hasExtendedInfo = contactPoints.length > 0 || !!dcatExtras || !!uri
+const hasExtendedInfo = !!dcatExtras || !!uri
 </script>
 
 <template>
@@ -43,10 +35,6 @@ const hasExtendedInfo = contactPoints.length > 0 || !!dcatExtras || !!uri
         :items="dcatExtras?.provenance"
         title="Généalogie"
       />
-      <ExtendedInformationPanelItem
-        :items="contactPoints"
-        title="Points de contact"
-      />
     </div>
   </div>
 </template>
diff --git a/src/model/contactPoint.ts b/src/model/contactPoint.ts
new file mode 100644
index 000000000..23166683d
--- /dev/null
+++ b/src/model/contactPoint.ts
@@ -0,0 +1,4 @@
+export interface ContactPointRole {
+  id: string
+  label: string
+}
diff --git a/src/model/dataset.ts b/src/model/dataset.ts
index a9b8b10f4..0d3d53983 100644
--- a/src/model/dataset.ts
+++ b/src/model/dataset.ts
@@ -14,7 +14,8 @@ export interface DatasetModalData {
   mode: 'edit' | 'create'
 }
 
-interface ContactPoint {
+export interface ContactPoint {
+  id: string
   name: string
   email?: string
   contact_form?: string
@@ -22,6 +23,7 @@ interface ContactPoint {
 }
 
 export type ExtendedDatasetV2 = DatasetV2 & {
+  // FIXME: remove when @datagouv/components >= 2.0.6
   contact_points: ContactPoint[]
   extras: {
     [key: string]: unknown
diff --git a/src/services/api/resources/ContactPointAPI.ts b/src/services/api/resources/ContactPointAPI.ts
new file mode 100644
index 000000000..b85cd7af8
--- /dev/null
+++ b/src/services/api/resources/ContactPointAPI.ts
@@ -0,0 +1,14 @@
+import type { ContactPointRole } from '@/model/contactPoint'
+import DatagouvfrAPI from '@/services/api/DatagouvfrAPI'
+
+export default class ContactPointAPI extends DatagouvfrAPI {
+  endpoint = 'contacts'
+
+  async getRoles(): Promise<ContactPointRole[]> {
+    const url = `${this.url()}/roles/`
+    return await this.request({
+      url,
+      method: 'get'
+    })
+  }
+}
diff --git a/src/store/ContactPointStore.ts b/src/store/ContactPointStore.ts
new file mode 100644
index 000000000..e51a1738e
--- /dev/null
+++ b/src/store/ContactPointStore.ts
@@ -0,0 +1,23 @@
+import type { ContactPointRole } from '@/model/contactPoint'
+import ContactPointAPI from '@/services/api/resources/ContactPointAPI'
+import { defineStore } from 'pinia'
+
+const api = new ContactPointAPI()
+
+interface RootState {
+  roles: ContactPointRole[]
+}
+
+export const useContactPointStore = defineStore('contactPoint', {
+  state: (): RootState => ({
+    roles: []
+  }),
+  actions: {
+    async loadRoles() {
+      if (!this.roles.length) {
+        this.roles = await api.getRoles()
+      }
+      return this.roles
+    }
+  }
+})
diff --git a/src/views/datasets/DatasetDetailView.vue b/src/views/datasets/DatasetDetailView.vue
index b3d418836..797e9de5a 100644
--- a/src/views/datasets/DatasetDetailView.vue
+++ b/src/views/datasets/DatasetDetailView.vue
@@ -12,11 +12,14 @@ import { computed, inject, onMounted, ref } from 'vue'
 
 import DiscussionsList from '@/components/DiscussionsList.vue'
 import GenericContainer from '@/components/GenericContainer.vue'
+import OrganizationLogo from '@/components/OrganizationLogo.vue'
 import ReusesList from '@/components/ReusesList.vue'
+import ContactPoints from '@/components/datasets/ContactPoints.vue'
 import DatasetAddToBouquetModal from '@/components/datasets/DatasetAddToBouquetModal.vue'
 import ExtendedInformationPanel from '@/components/datasets/ExtendedInformationPanel.vue'
 import ResourcesList from '@/components/datasets/ResourcesList.vue'
 import config from '@/config'
+import type { ExtendedDatasetV2 } from '@/model/dataset'
 import {
   AccessibilityPropertiesKey,
   type AccessibilityPropertiesType
@@ -35,7 +38,8 @@ const datasetStore = useDatasetStore()
 const userStore = useUserStore()
 const { canAddBouquet } = storeToRefs(userStore)
 
-const dataset = computed(() => datasetStore.get(datasetId))
+// FIXME: remove ExtendedDatasetV2 type cast when @datagouv/components >= 2.0.6 (contact_points)
+const dataset = computed(() => datasetStore.get(datasetId) as ExtendedDatasetV2)
 
 const selectedTabIndex = ref(0)
 const showAddToBouquetModal = ref(false)
@@ -105,21 +109,15 @@ onMounted(() => {
         </ReadMore>
       </div>
       <div class="fr-col-12 fr-col-md-4">
-        <h2 id="producer" class="subtitle fr-mb-1v">Producteur</h2>
+        <h2 id="producer" class="subtitle fr-mb-1v">
+          <span v-if="dataset.contact_points.length">Éditeur</span>
+          <span v-else>Producteur</span>
+        </h2>
         <div
           v-if="dataset.organization"
           class="fr-grid-row fr-grid-row--middle"
         >
-          <div class="fr-col-auto">
-            <div class="border fr-p-1-5v fr-mr-1-5v">
-              <img
-                :src="dataset.organization.logo"
-                alt=""
-                loading="lazy"
-                height="32"
-              />
-            </div>
-          </div>
+          <OrganizationLogo :object="dataset" :size="32" class="fr-mr-1-5v" />
           <p class="fr-col fr-m-0">
             <a class="fr-link" :href="dataset.organization.page">
               <OrganizationNameWithCertificate
@@ -128,6 +126,12 @@ onMounted(() => {
             </a>
           </p>
         </div>
+        <template v-if="dataset.contact_points.length">
+          <h2 id="attributions" class="subtitle fr-mb-1v fr-mt-3v">
+            Attributions
+          </h2>
+          <ContactPoints :contact-points="dataset.contact_points" />
+        </template>
         <div v-if="dataset.harvest?.remote_url" class="fr-my-3v fr-text--sm">
           <div class="bg-alt-blue-cumulus fr-p-3v fr-mb-1w">
             <p class="fr-grid-row fr-grid-row--middle fr-my-0">
@@ -143,7 +147,7 @@ onMounted(() => {
         </div>
         <h2 class="subtitle fr-mt-3v fr-mb-1v">Dernière mise à jour</h2>
         <p>{{ formatDate(dataset.last_update) }}</p>
-        <div v-if="license">
+        <template v-if="license">
           <h2 class="subtitle fr-mt-3v fr-mb-1v">Licence</h2>
           <p class="fr-text--sm fr-mt-0 fr-mb-3v">
             <code class="bg-alt-grey fr-px-1v text-grey-380">
@@ -152,7 +156,7 @@ onMounted(() => {
               </a>
             </code>
           </p>
-        </div>
+        </template>
         <QualityComponent
           v-if="config.website.show_quality_component"
           :quality="dataset.quality"


Reviews:
user: login: streino
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T17:15:48Z
user: login: abulte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T08:07:11Z
user: login: streino
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:08:04Z
user: login: streino
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:45:24Z

Review comments:
diff_hunk: @@ -128,6 +129,10 @@ onMounted(() => {
             </a>
           </p>
         </div>
+        <template v-if="dataset.contact_points.length">
+          <h2 id="producer" class="subtitle fr-mb-1v fr-mt-3v">Attributions</h2>
path: src/views/datasets/DatasetDetailView.vue
user: login: streino
body: Même `id=producer` que le `h2` Éditeur au dessus.
created_at: 2025-03-04T16:58:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,7 +106,7 @@ onMounted(() => {
         </ReadMore>
       </div>
       <div class="fr-col-12 fr-col-md-4">
-        <h2 id="producer" class="subtitle fr-mb-1v">Producteur</h2>
+        <h2 id="producer" class="subtitle fr-mb-1v">Éditeur</h2>
         <div
path: src/views/datasets/DatasetDetailView.vue
user: login: streino
body: Est-ce qu'on en profiterait pour ajuster le logo Éditeur ?

data.gouv : 
![image](https://github.com/user-attachments/assets/ca4bf5a3-cc4d-4c61-a9f7-ec43b7bd72c1)

ecologie.data.gouv : 
![image](https://github.com/user-attachments/assets/94f2b886-b7c3-4feb-8a9c-fba2cf19abea)

created_at: 2025-03-04T17:05:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,7 +106,7 @@ onMounted(() => {
         </ReadMore>
       </div>
       <div class="fr-col-12 fr-col-md-4">
-        <h2 id="producer" class="subtitle fr-mb-1v">Producteur</h2>
+        <h2 id="producer" class="subtitle fr-mb-1v">Éditeur</h2>
path: src/views/datasets/DatasetDetailView.vue
user: login: streino
body: "Producteur" quand pas d'autre attribution ?

https://deploy-preview-678--ecospheres.netlify.app/datasets/61dc7157488f8cdb4283e3c3
https://demo.data.gouv.fr/fr/datasets/base-de-donnee-nationale-des-batiments-version-0-6/
created_at: 2025-03-04T17:10:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,7 +106,7 @@ onMounted(() => {
         </ReadMore>
       </div>
       <div class="fr-col-12 fr-col-md-4">
-        <h2 id="producer" class="subtitle fr-mb-1v">Producteur</h2>
+        <h2 id="producer" class="subtitle fr-mb-1v">Éditeur</h2>
         <div
path: src/views/datasets/DatasetDetailView.vue
user: login: abulte
body: 🎁 
created_at: 2025-03-05T08:07:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+<script setup lang="ts">
+import type { ContactPoint } from '@/model/dataset'
+import { useContactPointStore } from '@/store/ContactPointStore'
+import { storeToRefs } from 'pinia'
+
+const store = useContactPointStore()
+const { roles } = storeToRefs(store)
+
+defineProps({
+  contactPoints: {
+    type: Object as PropType<ContactPoint[]>,
+    required: true
+  }
+})
+
+const getRoleLabel = (roleId: string) => {
+  const role = roles.value.find((role) => role.id === roleId)
+  return role ? role.label : roleId
+}
+
+const getLink = (contactPoint: ContactPoint) => {
+  if (contactPoint.email) {
+    return `mailto:${contactPoint.email}`
+  } else if (contactPoint.contact_form) {
+    return contactPoint.contact_form
+  }
+  return '#'
+}
+
+onMounted(() => {
+  store.loadRoles()
+})
+</script>
+
+<template>
+  <p
+    v-for="contactPoint in contactPoints"
+    :key="contactPoint.id"
+    class="fr-text--sm fr-mt-0 fr-mb-1w fr-grid-row no-wrap fr-grid-row--middle"
+  >
+    <a
+      v-if="contactPoint.email"
+      :href="getLink(contactPoint)"
+      rel="ugc nofollow noopener"
+      target="_blank"
+      class="fr-text--sm fr-link text-grey-500 text-overflow-ellipsis overflow-hidden"
+    >
+      {{ contactPoint.name || contactPoint.email || contactPoint.contact_form }}
path: src/components/datasets/ContactPoints.vue
user: login: streino
body: Oups...

https://deploy-preview-678--ecospheres.netlify.app/datasets/675cdd10ebe05a85fbd8fb83
![image](https://github.com/user-attachments/assets/28f6f3fc-d5d8-4b52-b799-8a6657659d33)

La même sur demo.data.gouv : 
![image](https://github.com/user-attachments/assets/9fab9811-aab0-4bda-9ad2-9d040f81d138)

(en bonus, qqch différent de "contact" :v:)

created_at: 2025-03-05T10:07:14Z
author_association: CONTRIBUTOR

PR comments:
user: login: netlify[bot]
created_at: 2025-03-04T14:19:21Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *ecospheres* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | ff4a5bb1811439cc702db31e3157a0b667129d22 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/ecospheres/deploys/67c8286e8830a30008d7101e |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-678--ecospheres.netlify.app](https://deploy-preview-678--ecospheres.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTY3OC0tZWNvc3BoZXJlcy5uZXRsaWZ5LmFwcCJ9.hQd4h1YYYU5Zl8StuJJtzawuGLhk3hlWp0sbUmAmk1E)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [ecospheres Preview](https://deploy-preview-678--ecospheres.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/ecospheres/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: netlify[bot]
created_at: 2025-03-04T14:21:56Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *meteo-france* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | ff4a5bb1811439cc702db31e3157a0b667129d22 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/meteo-france/deploys/67c8286ede8fa5000843df97 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-678--meteo-france.netlify.app](https://deploy-preview-678--meteo-france.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTY3OC0tbWV0ZW8tZnJhbmNlLm5ldGxpZnkuYXBwIn0.WiE8E66bAD2q0ac9wFVU5VF_2sXC-jr8IlKv8Wc4r0E)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [meteo-france Preview](https://deploy-preview-678--meteo-france.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/meteo-france/configuration/notifications#deploy-webhooks)._
author_association: NONE