Skip to content

Commit 5f540e2

Browse files
authored
feat: show a dataset DOI and let sysadmins mint one or visitors request one (#1238)
- Require opendatateam/udata#3735 - Part of datagouv/data.gouv.fr#1983 (affichage et demande de DOI ; les formats de citation restent à faire)
1 parent 6aa172b commit 5f540e2

11 files changed

Lines changed: 486 additions & 8 deletions

File tree

‎components/Datasets/AdminUpdateDatasetPage.vue‎

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,17 @@
5757
<strong>{{ $t('public') }}</strong>
5858
</template>
5959
</TranslationT>
60+
<div
61+
v-if="dataset.doi"
62+
class="mt-1"
63+
>
64+
{{ $t("Un jeu de données porteur d'un DOI ne peut plus repasser en brouillon.") }}
65+
</div>
6066

6167
<template #button>
6268
<BrandedButton
6369
:loading="isLoading"
70+
:disabled="!!dataset.doi"
6471
@click="switchDatasetPrivate"
6572
>
6673
{{ dataset.private ? $t('Publier le jeu de données') : $t('Passer en brouillon') }}
@@ -93,6 +100,88 @@
93100
:subject="dataset"
94101
:label="$t('Transférer le jeu de données')"
95102
/>
103+
<BannerAction
104+
v-if="getDatasetDoi(dataset)"
105+
type="primary"
106+
>
107+
<template #title>
108+
{{ $t('DOI du jeu de données :') }}
109+
<a
110+
:href="`https://doi.org/${getDatasetDoi(dataset)}`"
111+
target="_blank"
112+
rel="noopener noreferrer"
113+
class="link"
114+
>
115+
{{ getDatasetDoi(dataset) }}
116+
</a>
117+
<AdminBadge
118+
v-if="!dataset.doi"
119+
size="xs"
120+
type="secondary"
121+
class="ml-1"
122+
>
123+
{{ $t('Moissonné') }}
124+
</AdminBadge>
125+
</template>
126+
<template v-if="dataset.doi">
127+
{{ $t("Un jeu de données porteur d'un DOI ne peut plus être supprimé ni repassé en brouillon, seulement archivé.") }}
128+
</template>
129+
<template v-else>
130+
{{ $t("Ce DOI est déclaré par la source du moissonnage, qui en reste gestionnaire.") }}
131+
</template>
132+
133+
<template #button>
134+
<BrandedButton
135+
:icon="doiCopied ? RiCheckLine : RiFileCopyLine"
136+
@click="copyDoi(getDatasetDoi(dataset)!)"
137+
>
138+
{{ doiCopied ? $t('DOI copié !') : $t('Copier le DOI') }}
139+
</BrandedButton>
140+
</template>
141+
</BannerAction>
142+
<BannerAction
143+
v-if="!getDatasetDoi(dataset) && isMeAdmin()"
144+
type="primary"
145+
:title="$t('Créer un DOI')"
146+
>
147+
{{ doiBlockedReason(dataset) ?? $t("Un DOI est définitif : une fois créé, le jeu de données ne pourra plus être supprimé, seulement archivé.") }}
148+
149+
<template #button>
150+
<ModalWithButton :title="$t('Êtes-vous sûr de vouloir créer un DOI pour ce jeu de données ?')">
151+
<template #button="{ attrs, listeners }">
152+
<BrandedButton
153+
:icon="RiFingerprintLine"
154+
:disabled="!!doiBlockedReason(dataset)"
155+
v-bind="attrs"
156+
v-on="listeners"
157+
>
158+
{{ $t('Créer un DOI') }}
159+
</BrandedButton>
160+
</template>
161+
<p class="m-0">
162+
{{ $t("Un DOI est définitif : une fois créé, le jeu de données ne pourra plus être supprimé, seulement archivé.") }}
163+
</p>
164+
<template #footer="{ close }">
165+
<div class="flex-1 flex justify-end space-x-4">
166+
<BrandedButton
167+
color="secondary"
168+
:loading="isLoading"
169+
@click="close"
170+
>
171+
{{ $t('Annuler') }}
172+
</BrandedButton>
173+
<BrandedButton
174+
color="primary"
175+
:loading="isLoading"
176+
@click="mintDoi(close)"
177+
>
178+
{{ $t('Créer le DOI') }}
179+
</BrandedButton>
180+
</div>
181+
</template>
182+
</ModalWithButton>
183+
</template>
184+
</BannerAction>
96185
<BannerAction
97186
type="warning"
98187
:title="dataset.archived ? $t('Désarchiver le jeu de données') : $t('Archiver le jeu de données')"
@@ -110,7 +199,7 @@
110199
</template>
111200
</BannerAction>
112201
<BannerAction
113-
v-if="!dataset.deleted"
202+
v-if="!dataset.deleted && !dataset.doi"
114203
type="danger"
115204
:title="$t('Supprimer le jeu de données')"
116205
>
@@ -151,9 +240,10 @@
151240
<script setup lang="ts">
152241
import { BannerAction, BrandedButton, LoadingBlock, SimpleBanner, TranslationT, toast } from '@datagouv/components-next'
153242
import type { Badge, DatasetV2WithFullObject } from '@datagouv/components-next'
154-
import { RiArchiveLine, RiArrowGoBackLine, RiDeleteBin6Line } from '@remixicon/vue'
243+
import { RiArchiveLine, RiArrowGoBackLine, RiCheckLine, RiDeleteBin6Line, RiFileCopyLine, RiFingerprintLine } from '@remixicon/vue'
155244
import DescribeDataset from '~/components/Datasets/DescribeDataset.vue'
156245
import AdminDeleteModal from '~/components/Admin/AdminDeleteModal.vue'
246+
import ModalWithButton from '~/components/Modal/ModalWithButton.vue'
157247
import { updateBadges } from '~/api/badges'
158248
import type { DatasetForm } from '~/types/types'
159249
@@ -179,6 +269,33 @@ watchEffect(() => {
179269
}
180270
})
181271
272+
const doiBlockedReason = useDoiBlockedReason()
273+
274+
const doiCopied = ref(false)
275+
async function copyDoi(doi: string) {
276+
try {
277+
await navigator.clipboard.writeText(doi)
278+
doiCopied.value = true
279+
setTimeout(() => doiCopied.value = false, 2000)
280+
}
281+
catch {
282+
toast.error(t('Impossible de copier dans le presse-papier'))
283+
}
284+
}
285+
286+
async function mintDoi(close: () => void) {
287+
isLoading.value = true
288+
try {
289+
await $api(`/api/1/datasets/${route.params.id}/doi`, { method: 'POST' })
290+
close()
291+
await refresh()
292+
toast.success(t('DOI créé !'))
293+
}
294+
finally {
295+
isLoading.value = false
296+
}
297+
}
298+
182299
async function save() {
183300
if (!datasetForm.value) throw new Error('No dataset form')
184301
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<template>
2+
<ModalWithButton
3+
:title="$t('Demander un DOI')"
4+
size="lg"
5+
form
6+
@submit.prevent="(_event, close) => submit(close)"
7+
>
8+
<template #button="{ attrs, listeners }">
9+
<BrandedButton
10+
color="tertiary"
11+
size="2xs"
12+
:icon="RiChatNewLine"
13+
v-bind="attrs"
14+
v-on="listeners"
15+
>
16+
{{ $t('Demander un DOI') }}
17+
</BrandedButton>
18+
</template>
19+
20+
<p>
21+
{{ $t("Un DOI (Digital Object Identifier) est un identifiant pérenne qui permet de citer ce jeu de données dans une publication scientifique. Votre demande ouvre une conversation avec l'équipe du support, qui étudiera si ce jeu de données peut en recevoir un.") }}
22+
</p>
23+
24+
<InputGroup
25+
v-model="form.email"
26+
:label="$t('Votre adresse email')"
27+
type="email"
28+
required
29+
:has-error="!!getFirstError('email')"
30+
:error-text="getFirstError('email')"
31+
@blur="touch('email')"
32+
/>
33+
<InputGroup
34+
v-model="form.message"
35+
:label="$t('Pourquoi avez-vous besoin de ce DOI ?')"
36+
:placeholder="$t('Décrivez la publication ou le travail dans lequel vous souhaitez citer ce jeu de données.')"
37+
type="textarea"
38+
required
39+
:has-error="!!getFirstError('message')"
40+
:error-text="getFirstError('message')"
41+
@blur="touch('message')"
42+
/>
43+
44+
<template #footer="{ close }">
45+
<div class="flex-1 flex justify-end space-x-4">
46+
<BrandedButton
47+
color="secondary"
48+
:disabled="loading"
49+
@click="close"
50+
>
51+
{{ $t('Annuler') }}
52+
</BrandedButton>
53+
<BrandedButton
54+
type="submit"
55+
color="primary"
56+
:loading
57+
:icon="RiMailSendLine"
58+
>
59+
{{ $t('Envoyer la demande') }}
60+
</BrandedButton>
61+
</div>
62+
</template>
63+
</ModalWithButton>
64+
</template>
65+
66+
<script setup lang="ts">
67+
import { BrandedButton, toast } from '@datagouv/components-next'
68+
import type { DatasetV2WithFullObject } from '@datagouv/components-next'
69+
import { RiChatNewLine, RiMailSendLine } from '@remixicon/vue'
70+
import ModalWithButton from '~/components/Modal/ModalWithButton.vue'
71+
import type { SendMessageBody } from '~/types/support'
72+
73+
const props = defineProps<{
74+
dataset: DatasetV2WithFullObject
75+
}>()
76+
77+
const { t } = useTranslation()
78+
const me = useMaybeMe()
79+
const loading = ref(false)
80+
81+
const emptyForm = () => ({ email: me.value?.email ?? '', message: '' })
82+
83+
const { form, touch, getFirstError, validate } = useForm(emptyForm(), {
84+
email: [required(), email()],
85+
message: [required()],
86+
})
87+
88+
async function submit(close: () => void) {
89+
if (!await validate()) return
90+
91+
loading.value = true
92+
try {
93+
await $fetch('/nuxt-api/send-message', {
94+
method: 'POST',
95+
body: {
96+
email: form.value.email,
97+
segment: 'doi',
98+
subject: t('Demande de DOI pour « {title} »', { title: props.dataset.title }),
99+
body: `${props.dataset.page}\n\n${form.value.message}`,
100+
} satisfies SendMessageBody,
101+
})
102+
toast.success(t('Votre demande de DOI a bien été envoyée.'))
103+
// The modal is never unmounted, so reopening it would otherwise show the message
104+
// that was just sent and invite a duplicate.
105+
form.value = emptyForm()
106+
close()
107+
}
108+
catch {
109+
toast.error(t(`Une erreur est survenue lors de l'envoi de votre demande`))
110+
}
111+
finally {
112+
loading.value = false
113+
}
114+
}
115+
</script>

‎components/Support/Choices.vue‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100

101101
<script setup lang="ts">
102102
import { useActiveDescendant, BrandedButton, MarkdownViewer, SimpleBanner, toast } from '@datagouv/components-next'
103-
import type { Question, QuestionWithSegment } from '~/types/support'
103+
import type { Question, QuestionWithSegment, SendMessageBody } from '~/types/support'
104104
105105
const emit = defineEmits<{
106106
select: [id: string | undefined]
@@ -155,7 +155,7 @@ async function submit(question: QuestionWithSegment) {
155155
segment: question.segment,
156156
subject: form.value.subject,
157157
body: form.value.body,
158-
},
158+
} satisfies SendMessageBody,
159159
})
160160
messageSent.value = true
161161
}

‎datagouv-components/src/types/datasets.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export type Dataset = BaseDataset & {
7070
id: DatasetReference['id']
7171
badges: Badges
7272
deleted: string | null
73+
doi: string | null
7374
page: DatasetReference['page']
7475
resources: Array<Resource>
7576
community_resources: Array<Resource>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export type Harvest = Record<string, unknown> & {
22
backend?: string
3+
doi?: string | null
34
} | null

‎pages/datasets/[did]/informations.vue‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,38 @@
11
<template>
22
<div class="divide-y">
3-
<DatasetInformationSection :dataset="dataset" />
3+
<DatasetInformationSection :dataset="dataset">
4+
<!-- A dataset that cannot get a DOI offers no request at all rather than sending the support a demand refused in advance. -->
5+
<div v-if="doi || !doiBlockedReason(dataset)">
6+
<DescriptionListTerm>{{ $t('DOI') }}</DescriptionListTerm>
7+
<DescriptionListDetails
8+
v-if="doi"
9+
class="flex items-center gap-2"
10+
>
11+
<a
12+
:href="`https://doi.org/${doi}`"
13+
target="_blank"
14+
rel="noopener noreferrer"
15+
class="link"
16+
>
17+
{{ doi }}
18+
</a>
19+
<CopyButton
20+
class="!-mt-0.5"
21+
:label="$t('Copier le DOI')"
22+
:copied-label="$t('DOI copié !')"
23+
:text="doi"
24+
:hide-label="true"
25+
/>
26+
</DescriptionListDetails>
27+
<DescriptionListDetails
28+
v-else
29+
class="flex flex-wrap items-center gap-x-3 gap-y-1"
30+
>
31+
<span>{{ $t("Ce jeu de données n'a pas de DOI.") }}</span>
32+
<DoiRequestModal :dataset="dataset" />
33+
</DescriptionListDetails>
34+
</div>
35+
</DatasetInformationSection>
436
<DatasetTemporalitySection :dataset="dataset" />
537
<DatasetSpatialSection :dataset="dataset">
638
<template #map="{ geojson }">
@@ -44,10 +76,14 @@
4476
</template>
4577

4678
<script setup lang="ts">
47-
import { BrandedButton, DatasetInformationSection, DatasetTemporalitySection, DatasetSpatialSection, DatasetSchemaSection, DatasetEmbedSection, ExtraAccordion, LeafletMap, type DatasetV2WithFullObject } from '@datagouv/components-next'
79+
import { BrandedButton, CopyButton, DatasetInformationSection, DatasetTemporalitySection, DatasetSpatialSection, DatasetSchemaSection, DatasetEmbedSection, DescriptionListDetails, DescriptionListTerm, ExtraAccordion, LeafletMap, type DatasetV2WithFullObject } from '@datagouv/components-next'
4880
import { RiServerLine } from '@remixicon/vue'
81+
import DoiRequestModal from '~/components/Datasets/DoiRequestModal.vue'
82+
83+
const props = defineProps<{ dataset: DatasetV2WithFullObject }>()
4984
50-
defineProps<{ dataset: DatasetV2WithFullObject }>()
85+
const doi = computed(() => getDatasetDoi(props.dataset))
86+
const doiBlockedReason = useDoiBlockedReason()
5187
5288
useSeoMeta({ robots: 'noindex' })
5389
</script>

‎server/routes/nuxt-api/send-message.post.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { SendMessageBody } from '~/types/support'
2+
13
const CRISP_API_BASE = 'https://api.crisp.chat/v1'
24

35
export function getNicknameFromEmail(email: string) {
@@ -29,7 +31,7 @@ export default defineEventHandler(async (event) => {
2931
statusMessage: 'Content type should be application/json',
3032
})
3133
}
32-
const body = await readBody(event)
34+
const body = await readBody<SendMessageBody>(event)
3335

3436
const config = useRuntimeConfig(event)
3537
const websiteId = config.crispWebsiteId as string

0 commit comments

Comments
 (0)