Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e3548c3
fix: stop stuck top loading bar on admin dataset pages
abulte Jul 29, 2026
0b99b6c
feat: add geopf sync
abulte Jul 30, 2026
55299ee
409 -> 424
abulte Jul 31, 2026
a9b9672
conditionnal tab on eligible resources
abulte Aug 14, 2026
77adc3b
Merge branch 'main' into feat/geopf
abulte Aug 14, 2026
bcc11a4
refactor around new status endpoint
abulte Aug 19, 2026
30156d2
feature flag guard
abulte Aug 19, 2026
4faed50
simpler polling
abulte Aug 19, 2026
9e14f6a
scope 424 intercept
abulte Aug 19, 2026
abcf544
factorize isGeopfSynced
abulte Aug 19, 2026
3fae728
catch
abulte Aug 19, 2026
645cdd1
more unit tests
abulte Aug 19, 2026
947e20f
geopfStatus → geopfDatasetStatus
abulte Aug 20, 2026
6c4c531
nitpick
abulte Aug 20, 2026
8b11cf4
renaming pull push buttons
abulte Aug 20, 2026
229f3fd
add unit tests
abulte Aug 20, 2026
1f9bc8a
better perm check on GeopfSyncPage
abulte Aug 20, 2026
a14ade8
simplify badges in components/Datasets/GeopfPushStatus.vue
abulte Aug 20, 2026
199d257
disabled reason
abulte Aug 20, 2026
222710e
nitpicks
abulte Aug 20, 2026
48e38b5
Merge branch 'main' into feat/geopf
abulte Aug 20, 2026
913fa65
add e2e tests
abulte Aug 20, 2026
ea40b92
debug e2e
abulte Aug 20, 2026
832058a
e2e need udata geopf
abulte Aug 20, 2026
b2c2757
new backend version + nitpicks on urls
abulte Aug 27, 2026
64396d1
Merge branch 'main' into feat/geopf
abulte Aug 27, 2026
b43c1be
migrate isGeopfSynced to new backend
abulte Aug 31, 2026
51ea3ba
Merge branch 'main' into feat/geopf
abulte Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ jobs:
with:
repository: opendatateam/udata
path: ${{ env.UDATA_WORKING_DIR }}
ref: main
# FIXME: geopf lives on this branch, not yet merged to main. Point back
# to `main` once the udata geopf PR (feat/geopf/init) merges.
ref: feat/geopf/init
Comment on lines +151 to +153

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXME


- name: Set up uv
uses: astral-sh/setup-uv@v6
Expand Down Expand Up @@ -248,6 +250,7 @@ jobs:
NUXT_SITE_URL: http://localhost:3000
NUXT_PUBLIC_CHARTS_API_BASE: http://localhost:7000
NUXT_PUBLIC_CADA_RESOURCE_ID: 3a6d6fe3-8548-43ed-ad09-72052771447c
NUXT_PUBLIC_GEOPF_ENABLED: "true"
run: |
# Start cdata server in background using pre-built artifacts
PORT=3000 node .output/server/index.mjs > cdata.log 2>&1 &
Expand Down
17 changes: 13 additions & 4 deletions components/Datasets/AdminDatasetResourcesPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@
:schemas="schemas ?? []"
:dataset
@submit="updateResource"
@delete="refreshResources"
@delete="refreshAfterMutation"
/>

<LoadingBlock
v-slot="{ data: resourcesPage }"

Check warning on line 85 in components/Datasets/AdminDatasetResourcesPage.vue

View workflow job for this annotation

GitHub Actions / quality_and_test

Variable 'resourcesPage' is already declared in the upper scope
:status
:data="resourcesPage"
>
Expand Down Expand Up @@ -187,7 +187,7 @@
:loading
:resource="resourceToForm(resource, schemas ?? [])"
@submit="updateResource"
@delete="refreshResources"
@delete="refreshAfterMutation"
/>
</td>
</tr>
Expand All @@ -214,6 +214,7 @@
import UploadResourceModal from './UploadResourceModal.vue'
import FileEditModal from './FileEditModal.vue'
import FileEditModalFromQueryStringClient from './FileEditModalFromQueryString.client.vue'
import { geopfEligibilityRefreshKey } from './geopfEligibilityRefreshKey'
import type { AdminBadgeType, CommunityResourceForm, PaginatedArray, ResourceForm } from '~/types/types'

const route = useRoute()
Expand Down Expand Up @@ -247,6 +248,14 @@
}
watchEffect(async () => await refreshResources())

// Kept off `refreshResources`, which the watchEffect above also runs on every
// pagination change.
const refreshGeopfEligibility = inject(geopfEligibilityRefreshKey, () => {})
const refreshAfterMutation = async () => {
await refreshResources()
refreshGeopfEligibility()
}

const { t } = useTranslation()

const resourceForms = ref<Array<ResourceForm>>([])
Expand All @@ -271,15 +280,15 @@
removeFirstNewFile()

page.value = 1
refreshResources()
refreshAfterMutation()
}
const updateResource = async (closeModal: () => void, resourceForm: ResourceForm | CommunityResourceForm) => {
if (!dataset.value) return
loading.value = true

try {
await saveResourceForm(dataset.value, resourceForm)
await refreshResources()
await refreshAfterMutation()
closeModal()
}
finally {
Expand Down
6 changes: 5 additions & 1 deletion components/Datasets/FileEditModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
:icon="RiPencilLine"
:color="buttonColor"
:size="buttonSize"
:title="$t('Éditer le fichier')"
:disabled="isSyncedWithGeopf"
:title="isSyncedWithGeopf ? $t('Vous ne pouvez pas modifier cette ressource car elle est synchronisée avec cartes.gouv.fr') : $t('Éditer le fichier')"
keep-margins-even-without-borders
v-on="listeners"
/>
Expand Down Expand Up @@ -201,6 +202,7 @@ import DescribeResource from './DescribeResource.vue'
import CdataLink from '../CdataLink.vue'
import type { CommunityResourceForm, ResourceForm } from '~/types/types'
import { useComponentsConfig } from '../../datagouv-components/src/config'
import { isGeopfSynced } from '~/utils/geopf'

const { t } = useTranslation()
const { $api } = useNuxtApp()
Expand Down Expand Up @@ -232,6 +234,8 @@ const resourceForm = ref(cloneDeep(props.resource))
const open = ref(false)
const hasFileChanged = ref(false)

const isSyncedWithGeopf = computed(() => props.resource.resource ? isGeopfSynced(props.resource.resource) : false)

const hasTabularData = useHasTabularData()

// Check if resource has tabular API
Expand Down
55 changes: 55 additions & 0 deletions components/Datasets/GeopfDatastoreSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<template>
<div v-if="pinnedDatastoreId">
<p class="text-sm text-gray-medium m-0">
{{ pinnedDatastoreName ? t('Entrepôt cartes.gouv.fr : {name} ({id})', { name: pinnedDatastoreName, id: pinnedDatastoreId }) : t('Entrepôt cartes.gouv.fr : {id}', { id: pinnedDatastoreId }) }}
</p>
</div>

<SelectGroup
v-else-if="connected === true"
v-model="localSelection"
:label="t('Entrepôt cartes.gouv.fr')"
:hint-text="t('Choisissez l\'entrepôt qui accueillera ce jeu de données sur cartes.gouv.fr. Ce choix ne pourra plus être modifié après le premier envoi.')"
:options="datastoreOptions"
class="max-w-sm"
/>

<p
v-else
class="text-sm text-gray-medium m-0"
>
{{ t("Connectez-vous à cartes.gouv.fr pour choisir l'entrepôt de ce jeu de données.") }}
</p>
</template>

<script setup lang="ts">
import { SelectGroup } from '@datagouv/components-next'
import type { GeopfDatastore } from '~/utils/geopf'

const props = defineProps<{
// Set on the dataset's first successful push, not editable afterwards.
pinnedDatastoreId: string | null
connected: boolean | null
}>()

const model = defineModel<string | null>({ default: null })

const { t } = useTranslation()

const { data: datastores } = props.connected === true
? await useAPI<Array<GeopfDatastore>>('/api/1/geopf/datastores/')
: { data: ref(null) }

const datastoreOptions = computed(() => (datastores.value ?? []).map(datastore => ({ label: datastore.name, value: datastore.datastore_id })))
const pinnedDatastoreName = computed(() => (datastores.value ?? []).find(datastore => datastore.datastore_id === props.pinnedDatastoreId)?.name ?? null)

const localSelection = ref<string | null>(null)

watch(() => props.pinnedDatastoreId, (id) => {
if (id) model.value = id
}, { immediate: true })

watch(localSelection, (id) => {
if (!props.pinnedDatastoreId) model.value = id
})
</script>
84 changes: 84 additions & 0 deletions components/Datasets/GeopfPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<template>
<BannerAction
:type="bannerType"
:title="bannerTitle"
>
<template v-if="reauthRequired">
{{ t('Votre connexion a expiré ou a été révoquée.') }}
</template>
<template v-else-if="connected === false">
{{ t('Connectez-vous pour envoyer des fichiers et récupérer les services publiés sur cartes.gouv.fr.') }}
</template>
<template v-else-if="connected === true">
{{ t('Vous pouvez envoyer des fichiers vers cartes.gouv.fr depuis la liste ci-dessous.') }}
</template>

<template #button>
<a
v-if="connected !== true || reauthRequired"
class="fr-btn fr-btn--sm"
:href="geopfLoginUrl"
>
{{ t('Se connecter') }}
</a>
<BrandedButton
v-else
color="tertiary"
size="xs"
:icon="RiLogoutBoxLine"
:loading="disconnecting"
@click="disconnect"
>
{{ t('Se déconnecter') }}
</BrandedButton>
</template>
</BannerAction>
</template>

<script setup lang="ts">
import { BannerAction, BrandedButton, toast } from '@datagouv/components-next'
import { RiLogoutBoxLine } from '@remixicon/vue'

const props = defineProps<{
// Id, not slug: /geopf/login/ only resolves by id.
datasetId: string
connected: boolean | null
reauthRequired?: boolean
}>()

const emit = defineEmits<{
disconnected: []
}>()

const { $api } = useNuxtApp()
const { t } = useTranslation()
const config = useRuntimeConfig()

const geopfLoginUrl = computed(() => `${config.public.apiBase}/api/1/geopf/login/?dataset_id=${encodeURIComponent(props.datasetId)}`)

const bannerType = computed(() => {
if (props.reauthRequired) return 'danger'
return props.connected === true ? 'primary' : 'warning'
})
const bannerTitle = computed(() => {
if (props.reauthRequired) return t('Reconnexion à cartes.gouv.fr requise')
return props.connected === true ? t('Connecté à cartes.gouv.fr') : t('Non connecté à cartes.gouv.fr')
})

const disconnecting = ref(false)

const disconnect = async () => {
disconnecting.value = true
try {
await $api('/api/1/geopf/token/', { method: 'DELETE' })
toast.success(t('Vous êtes déconnecté de cartes.gouv.fr.'))
emit('disconnected')
}
catch {
// plugins/api.ts already toasted the error.
}
finally {
disconnecting.value = false
}
}
</script>
81 changes: 81 additions & 0 deletions components/Datasets/GeopfPullButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<template>
<div class="flex flex-col items-end gap-1">
<component :is="disabledReason ? Tooltip : 'div'">
<BrandedButton
color="secondary"
size="xs"
:icon="RiDownloadCloud2Line"
:disabled="!!disabledReason"
:loading="pulling || pull.status === 'pending'"
@click="startPull"
>
{{ t('Récupérer les services') }}
</BrandedButton>

<template #tooltip>
{{ disabledReason }}
</template>
</component>

<span
v-if="pull.status === 'done' && pull.last_synced_at"
class="text-xs text-gray-medium"
>
{{ t('Dernière synchronisation : {date}', { date: formatDate(pull.last_synced_at) }) }}
</span>
<span
v-else-if="pull.status === 'error'"
class="text-xs text-new-error"
>
{{ pull.error }}
</span>
</div>
</template>

<script setup lang="ts">
import { BrandedButton, Tooltip, useFormatDate } from '@datagouv/components-next'
import { RiDownloadCloud2Line } from '@remixicon/vue'
import { isGeopfReauthRequired, type GeopfDatasetStatus } from '~/utils/geopf'

const props = defineProps<{
datasetId: string
connected: boolean | null
pull: GeopfDatasetStatus['pull']
ficheUrl: string | null
refresh: () => Promise<void>
}>()

const emit = defineEmits<{
reauthRequired: []
}>()

const { $api } = useNuxtApp()
const { t } = useTranslation()
const { formatDate } = useFormatDate()

const disabledReason = computed(() => {
if (props.connected !== true) return t('Connectez-vous à cartes.gouv.fr pour récupérer les services.')
if (!props.ficheUrl) return t('Envoyez d\'abord un fichier éligible vers cartes.gouv.fr.')
return null
})

const pulling = ref(false)

const startPull = async () => {
pulling.value = true
try {
await $api(`/api/1/geopf/pull-offerings/${props.datasetId}/`, { method: 'POST' })
// Awaited: udata marks the dataset `pending` before enqueueing, so this returns the new status.
await props.refresh()
}
catch (error) {
if (isGeopfReauthRequired(error)) {
emit('reauthRequired')
}
// Otherwise plugins/api.ts already toasted the error.
}
finally {
pulling.value = false
}
}
</script>
Loading
Loading