diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13ab29d5e..529c18086 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 - name: Set up uv uses: astral-sh/setup-uv@v6 @@ -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 & diff --git a/components/Datasets/AdminDatasetResourcesPage.vue b/components/Datasets/AdminDatasetResourcesPage.vue index 08fa3258d..6b5fffd51 100644 --- a/components/Datasets/AdminDatasetResourcesPage.vue +++ b/components/Datasets/AdminDatasetResourcesPage.vue @@ -78,7 +78,7 @@ :schemas="schemas ?? []" :dataset @submit="updateResource" - @delete="refreshResources" + @delete="refreshAfterMutation" /> @@ -214,6 +214,7 @@ import AdminTableTh from '../AdminTable/Table/AdminTableTh.vue' 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() @@ -247,6 +248,14 @@ const refreshResources = async () => { } 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>([]) @@ -271,7 +280,7 @@ const saveFirstNewFile = async (closeModal: () => void, form: ResourceForm | Com removeFirstNewFile() page.value = 1 - refreshResources() + refreshAfterMutation() } const updateResource = async (closeModal: () => void, resourceForm: ResourceForm | CommunityResourceForm) => { if (!dataset.value) return @@ -279,7 +288,7 @@ const updateResource = async (closeModal: () => void, resourceForm: ResourceForm try { await saveResourceForm(dataset.value, resourceForm) - await refreshResources() + await refreshAfterMutation() closeModal() } finally { diff --git a/components/Datasets/FileEditModal.vue b/components/Datasets/FileEditModal.vue index d239e0603..2065589eb 100644 --- a/components/Datasets/FileEditModal.vue +++ b/components/Datasets/FileEditModal.vue @@ -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" /> @@ -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() @@ -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 diff --git a/components/Datasets/GeopfDatastoreSelector.vue b/components/Datasets/GeopfDatastoreSelector.vue new file mode 100644 index 000000000..51d8c694d --- /dev/null +++ b/components/Datasets/GeopfDatastoreSelector.vue @@ -0,0 +1,55 @@ + + + diff --git a/components/Datasets/GeopfPanel.vue b/components/Datasets/GeopfPanel.vue new file mode 100644 index 000000000..100020e04 --- /dev/null +++ b/components/Datasets/GeopfPanel.vue @@ -0,0 +1,84 @@ + + + diff --git a/components/Datasets/GeopfPullButton.vue b/components/Datasets/GeopfPullButton.vue new file mode 100644 index 000000000..2925c5f13 --- /dev/null +++ b/components/Datasets/GeopfPullButton.vue @@ -0,0 +1,81 @@ + + + diff --git a/components/Datasets/GeopfPushStatus.vue b/components/Datasets/GeopfPushStatus.vue new file mode 100644 index 000000000..871f567e2 --- /dev/null +++ b/components/Datasets/GeopfPushStatus.vue @@ -0,0 +1,129 @@ + + + diff --git a/components/Datasets/GeopfSyncPage.vue b/components/Datasets/GeopfSyncPage.vue new file mode 100644 index 000000000..431c73ad1 --- /dev/null +++ b/components/Datasets/GeopfSyncPage.vue @@ -0,0 +1,192 @@ + + + diff --git a/components/Datasets/geopfEligibilityRefreshKey.ts b/components/Datasets/geopfEligibilityRefreshKey.ts new file mode 100644 index 000000000..0511bd9c2 --- /dev/null +++ b/components/Datasets/geopfEligibilityRefreshKey.ts @@ -0,0 +1,7 @@ +import type { InjectionKey } from 'vue' + +// Lets the Files tab (which mutates resources) tell the admin dataset layout +// (which decides whether the geopf tab is shown) to recheck eligibility, so a +// newly-added resource surfaces the tab without a full page reload. +export type GeopfEligibilityRefresh = () => void +export const geopfEligibilityRefreshKey = Symbol() as InjectionKey diff --git a/components/SiteHeader/SiteHeader.vue b/components/SiteHeader/SiteHeader.vue index 87334b0fb..a443f0de4 100644 --- a/components/SiteHeader/SiteHeader.vue +++ b/components/SiteHeader/SiteHeader.vue @@ -631,6 +631,9 @@ onMounted(() => { if (message) { toast[message.type](message.text) } + + // Consume the flash: strip it from the URL so it can't resurface on a later reload. + router.replace({ query: { ...route.query, flash: undefined, info: undefined, error: undefined } }) } }) diff --git a/composables/useGeopfPolling.ts b/composables/useGeopfPolling.ts new file mode 100644 index 000000000..2e8f121d3 --- /dev/null +++ b/composables/useGeopfPolling.ts @@ -0,0 +1,12 @@ +import { useIntervalFn } from '@vueuse/core' +import type { ComputedRef } from 'vue' + +const POLL_INTERVAL_MS = 8000 + +// Polls for as long as a push or pull is pending. `useIntervalFn` pauses itself on +// scope dispose. +export function useGeopfPolling(pending: ComputedRef, tick: () => Promise) { + const { pause, resume } = useIntervalFn(tick, POLL_INTERVAL_MS, { immediate: false }) + + watch(pending, isPending => isPending ? resume() : pause(), { immediate: true }) +} diff --git a/datagouv-components/src/components/ResourceAccordion/EditButton.vue b/datagouv-components/src/components/ResourceAccordion/EditButton.vue index 4f8554255..b785d63a4 100644 --- a/datagouv-components/src/components/ResourceAccordion/EditButton.vue +++ b/datagouv-components/src/components/ResourceAccordion/EditButton.vue @@ -4,7 +4,8 @@ icon-only :icon="RiPencilLine" color="warning" - :title="t('Éditer le fichier')" + :disabled="!!disabledReason" + :title="disabledReason ?? t('Éditer le fichier')" data-testid="edit-button" /> @@ -19,10 +20,12 @@ type Props = { datasetId: string isCommunityResource?: boolean resourceId: string + disabledReason?: string | null } const props = withDefaults(defineProps(), { isCommunityResource: false, + disabledReason: null, }) const { t } = useTranslation() diff --git a/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue b/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue index af35894a3..b922a28c3 100644 --- a/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue +++ b/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue @@ -173,6 +173,7 @@ :dataset-id="dataset.id" :resource-id="resource.id" :is-community-resource="isCommunityResource" + :disabled-reason="editDisabledReason" size="xs" />

@@ -327,7 +328,7 @@ import { trackEvent } from '../../functions/matomo' import CopyButton from '../CopyButton.vue' import { useComponentsConfig } from '../../config' import { getOwnerName } from '../../functions/owned' -import { getResourceFormatIcon, getResourceTitleId, detectOgcService, getResourceExternalUrl, getResourceFilesize, isImagePreviewFormat } from '../../functions/resources' +import { getResourceFormatIcon, getResourceTitleId, detectOgcService, getResourceExternalUrl, getResourceFilesize, isGeopfSynced, isImagePreviewFormat } from '../../functions/resources' import BrandedButton from '../BrandedButton.vue' import { useTranslation } from '../../composables/useTranslation' import { useHasTabularData } from '../../composables/useHasTabularData' @@ -382,6 +383,10 @@ const hasPmtiles = computed(() => { return props.resource.extras['analysis:parsing:pmtiles_url'] || props.resource.format === 'pmtiles' }) +const editDisabledReason = computed(() => { + return isGeopfSynced(props.resource) ? t('Vous ne pouvez pas modifier cette ressource car elle est synchronisée avec cartes.gouv.fr') : null +}) + const hasDatafairPreview = computed(() => { // Checks if there are the corresponding extras for a datafair preview. // Limited only to datasets published by certified organizations since it will load an iframe. diff --git a/datagouv-components/src/functions/resources.ts b/datagouv-components/src/functions/resources.ts index 3e0b17c1e..b3f95f0b1 100644 --- a/datagouv-components/src/functions/resources.ts +++ b/datagouv-components/src/functions/resources.ts @@ -179,6 +179,10 @@ export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit metrics: { views: number } harvest: Harvest + geopf: GeopfResourceMetadata | null filesize: number | null filetype: ResourceFileType format: string diff --git a/nuxt.config.ts b/nuxt.config.ts index cc0a6ff23..58b7e532a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -185,6 +185,8 @@ export default defineNuxtConfig({ resourceFileUploadChunk: 20 * 1000 * 1000, maxSortableFiles: 50, + geopfEnabled: false, + maxNumberOfDatasetsForDataserviceUpdate: 200, captcheta: { diff --git a/pages/admin.vue b/pages/admin.vue index 70e150617..11be0fc78 100644 --- a/pages/admin.vue +++ b/pages/admin.vue @@ -44,7 +44,7 @@
@@ -54,6 +54,7 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import AdminSidebarMenu from '~/components/AdminSidebar/AdminSidebarMenu/AdminSidebarMenu.vue' import type { OrganizationReference } from '@datagouv/components-next' +import type { RouteLocationNormalizedLoaded } from 'vue-router' definePageMeta({ layout: 'fluid', @@ -78,6 +79,12 @@ useSeoMeta({ title: 'Admin' }) const { organizations, users } = useCurrentOwned() const isSiteAdmin = computed(() => me.value.roles?.includes('admin') || false) + +// Stable reference: NuxtPage compares page-key by reference, so an inline function here +// would re-trigger the loading indicator on every render of this component. +function pageKey(route: RouteLocationNormalizedLoaded) { + return route.fullPath +}