Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,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_EXPLORER_FEEDBACK_URL: https://example.com/feedback
run: |
# Start cdata server in background using pre-built artifacts
PORT=3000 node .output/server/index.mjs > cdata.log 2>&1 &
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { RouteLocationRaw } from 'vue-router'
import { useTranslation } from '../../composables/useTranslation'
Expand All @@ -124,6 +124,12 @@ const props = withDefaults(defineProps<{
fullscreen: false,
})

// The dataset page's feedback link needs the resource currently shown; the URL
// query param is the source of truth inside, so we forward the resolved selection.
const emit = defineEmits<{
select: [resource: Resource | null]
}>()

const { t } = useTranslation()
const route = useRoute()

Expand All @@ -138,6 +144,8 @@ const {
updateSearch,
} = await useDatasetResources(() => props.dataset)

watch(selectedResource, resource => emit('select', resource), { immediate: true })

const sidebarCollapsed = ref(false)

const resourceTo = (resource: Resource): RouteLocationRaw => ({
Expand Down
4 changes: 3 additions & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ export default defineNuxtConfig({

// Feedback form for the new resource explorer. Empty by default (set via
// NUXT_PUBLIC_EXPLORER_FEEDBACK_URL) — the banner's "Donner votre avis" link
// only shows when it is set.
// only shows when it is set. The link is pre-filled with query params
// (dataset_id, dataset_url, dataset_name, url_ressource, format_ressource,
// navigateur_appareil), see utils/explorer-feedback.ts.
explorerFeedbackUrl: '',

// Grist endpoint for the "Suivi des ouvertures" table on /suivi-de-publication/engagements-et-demandes.
Expand Down
25 changes: 22 additions & 3 deletions pages/datasets/[did]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
:dataset
:explore-to="exploreTo"
no-results-image="/illustrations/dataset.svg"
@select="feedbackResource = $event"
/>
<DatasetsLegacyResourceList
v-else
Expand All @@ -39,14 +40,32 @@
</template>

<script setup lang="ts">
import { BannerAction, BrandedButton, ResourceExplorer, type DatasetV2, type Resource } from '@datagouv/components-next'
import { BannerAction, BrandedButton, getResourceExternalUrl, ResourceExplorer, type DatasetV2, type Resource } from '@datagouv/components-next'

const props = defineProps<{ dataset: DatasetV2 }>()

const route = useRoute()

// Feedback form link for the banner; only shown when configured.
const feedbackUrl = useRuntimeConfig().public.explorerFeedbackUrl
// Feedback form link for the banner; only shown when configured. Query params
// pre-fill the form with what the visitor was looking at: the dataset, the
// resource shown in the explorer (forwarded via `select`, unknown during SSR)
// and a simplified "Browser - device" string (client-side only, so it appears
// after hydration).
const feedbackBaseUrl = useRuntimeConfig().public.explorerFeedbackUrl
const feedbackResource = ref<Resource | null>(null)
const feedbackUserAgent = ref<string | null>(null)
onMounted(() => {
feedbackUserAgent.value = getSimplifiedUserAgent(navigator.userAgent)
})
const feedbackUrl = computed(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think it currently doesn't show in the fields in the form. As discussed, I think it would be fair to be transparent about the context information being sent

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.

@agarrone regarding the form fields visibility

if (!feedbackBaseUrl) return ''
return buildExplorerFeedbackUrl(feedbackBaseUrl, {
dataset: props.dataset,
resourceExternalUrl: feedbackResource.value ? getResourceExternalUrl(props.dataset, feedbackResource.value) : null,
resourceFormat: feedbackResource.value?.format ?? null,
simplifiedUserAgent: feedbackUserAgent.value,
})
})

// Opens the fullscreen explorer on the current resource, next to the download button.
// Slug rather than id, so the explorer doesn't answer with a canonical redirect.
Expand Down
79 changes: 79 additions & 0 deletions tests-unit/utils/explorer-feedback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { buildExplorerFeedbackUrl, getSimplifiedUserAgent } from '~/utils/explorer-feedback'

const dataset = {
id: 'ds-1',
title: 'Recensement de la population',
page: 'https://www.data.gouv.fr/fr/datasets/recensement/',
}
const resourceExternalUrl = 'https://www.data.gouv.fr/fr/datasets/recensement/?resource_id=res-1'
const resourceFormat = 'csv'
const simplifiedUserAgent = 'Firefox - desktop'

describe('buildExplorerFeedbackUrl', () => {
it('appends all six context params to the base URL', () => {
const url = new URL(buildExplorerFeedbackUrl('https://example.com/feedback', { dataset, resourceExternalUrl, resourceFormat, simplifiedUserAgent }))

expect(url.searchParams.get('dataset_id')).toBe('ds-1')
expect(url.searchParams.get('dataset_url')).toBe('https://www.data.gouv.fr/fr/datasets/recensement/')
expect(url.searchParams.get('dataset_name')).toBe('Recensement de la population')
expect(url.searchParams.get('url_ressource')).toBe('https://www.data.gouv.fr/fr/datasets/recensement/?resource_id=res-1')
expect(url.searchParams.get('format_ressource')).toBe('csv')
expect(url.searchParams.get('navigateur_appareil')).toBe(simplifiedUserAgent)
})

it('keeps query params already present in the base URL', () => {
const url = new URL(buildExplorerFeedbackUrl('https://example.com/feedback?source=banner', { dataset, resourceExternalUrl, resourceFormat, simplifiedUserAgent }))

expect(url.searchParams.get('source')).toBe('banner')
expect(url.searchParams.get('dataset_id')).toBe('ds-1')
})

it('omits resource params when no resource is resolved yet', () => {
const url = new URL(buildExplorerFeedbackUrl('https://example.com/feedback', { dataset, resourceExternalUrl: null, resourceFormat: null, simplifiedUserAgent }))

expect(url.searchParams.has('url_ressource')).toBe(false)
expect(url.searchParams.has('format_ressource')).toBe(false)
expect(url.searchParams.get('dataset_id')).toBe('ds-1')
})

it('omits navigateur_appareil during SSR (no user agent)', () => {
const url = new URL(buildExplorerFeedbackUrl('https://example.com/feedback', { dataset, resourceExternalUrl, resourceFormat, simplifiedUserAgent: null }))

expect(url.searchParams.has('navigateur_appareil')).toBe(false)
})

it('returns the base URL unchanged when it is not a valid absolute URL', () => {
expect(buildExplorerFeedbackUrl('/relative/path', { dataset, resourceExternalUrl, resourceFormat, simplifiedUserAgent })).toBe('/relative/path')
})
})

describe('getSimplifiedUserAgent', () => {
it('detects Firefox on desktop', () => {
expect(getSimplifiedUserAgent('Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0')).toBe('Firefox - desktop')
})

it('detects Chrome on desktop', () => {
expect(getSimplifiedUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36')).toBe('Chrome - desktop')
})

it('detects Safari on mobile', () => {
expect(getSimplifiedUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1')).toBe('Safari - mobile')
})

it('detects Edge on tablet', () => {
expect(getSimplifiedUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0 Tablet PC')).toBe('Edge - tablet')
})

it('detects Android phone as mobile', () => {
expect(getSimplifiedUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36')).toBe('Chrome - mobile')
})

it('detects Android tablet without an explicit Tablet token', () => {
expect(getSimplifiedUserAgent('Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36')).toBe('Chrome - tablet')
})

it('falls back to Other for unknown browsers', () => {
expect(getSimplifiedUserAgent('Mozilla/5.0 (X11; Linux x86_64) SomeUnknownBrowser/1.0')).toBe('Other - desktop')
})
})
36 changes: 36 additions & 0 deletions tests/datasets/explorer-feedback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { test, expect } from '../base'
import { createDatasetWithRemoteResources, deleteDatasets, enableNewExplorer } from '../helpers'

const createdDatasets: Array<string> = []

test.afterEach(async ({ page, request }) => {
await page.context().clearCookies({ name: 'new_explorer' })
await deleteDatasets(request, createdDatasets)
})

test('the feedback link pre-fills the form with the current context', async ({ page, request }) => {
const { dataset, resources } = await createDatasetWithRemoteResources(request, `Test explorer feedback ${Date.now()}`, ['Fichier numero 01'])
createdDatasets.push(dataset.id)
const resource = resources[0]!
const resourceQuery = `?resource_id=${resource.id}`

await enableNewExplorer(page, `/datasets/${dataset.id}${resourceQuery}`)

const link = page.getByRole('link', { name: 'Donner votre avis' })
// The resource params only appear once the explorer has forwarded its
// selection client-side, so let the assertion retry instead of reading the
// SSR href once.
await expect(link).toHaveAttribute('href', /url_ressource=/)

const hrefAttribute = await link.getAttribute('href')
expect(hrefAttribute).toBeTruthy()
const href = new URL(hrefAttribute!)
expect(href.searchParams.get('dataset_id')).toBe(dataset.id)
expect(href.searchParams.get('dataset_name')).toBe(dataset.title)
expect(href.searchParams.get('dataset_url')).toContain(`/datasets/${dataset.slug}`)
expect(href.searchParams.get('url_ressource')).toBe(`${href.searchParams.get('dataset_url')}${resourceQuery}`)
expect(href.searchParams.get('format_ressource')).toBe('csv')
// The spec runs on both chromium and firefox projects.
const expectedBrowser = test.info().project.name === 'firefox' ? 'Firefox' : 'Chrome'
expect(href.searchParams.get('navigateur_appareil')).toBe(`${expectedBrowser} - desktop`)
})
77 changes: 77 additions & 0 deletions utils/explorer-feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { DatasetV2 } from '@datagouv/components-next'

export type ExplorerFeedbackContext = {
dataset: Pick<DatasetV2, 'id' | 'page' | 'title'>
// The selected resource's canonical permalink (getResourceExternalUrl), not
// its raw file URL.
resourceExternalUrl: string | null
// The selected resource's format; only set together with resourceExternalUrl.
resourceFormat: string | null
simplifiedUserAgent: string | null
}

// Returns a human-readable "Browser - device" string from a raw user agent.
// Detection is intentionally simple: the form owner only needs a rough idea of
// the visitor's environment, not a full parser.
export function getSimplifiedUserAgent(userAgent: string): string {
const ua = userAgent.toLowerCase()

let browser = 'Other'
if (ua.includes('firefox/')) {
browser = 'Firefox'
}
else if (ua.includes('edg/')) {
browser = 'Edge'
}
else if (ua.includes('chrome/') || ua.includes('chromium/')) {
browser = 'Chrome'
}
else if (ua.includes('safari/')) {
browser = 'Safari'
}
else if (ua.includes('opera/') || ua.includes('opr/')) {
browser = 'Opera'
}

let device = 'desktop'
if (ua.includes('ipad') || ua.includes('tablet')) {
device = 'tablet'
}
else if (ua.includes('mobile')) {
device = 'mobile'
}
else if (ua.includes('android')) {
// Android tablets usually omit both "Mobile" and "Tablet".
device = 'tablet'
}

return `${browser} - ${device}`
}
Comment thread
nicolaskempf57 marked this conversation as resolved.

// Builds the banner's feedback-form URL with the current context pre-filled as
// query params, so the form owner knows what the visitor was looking at. Resource
// params are omitted (not sent empty) until the explorer resolves its selection,
// and the simplified user agent only exists client-side.
export function buildExplorerFeedbackUrl(baseUrl: string, { dataset, resourceExternalUrl, resourceFormat, simplifiedUserAgent }: ExplorerFeedbackContext): string {
let url: URL
try {
url = new URL(baseUrl)
}
catch {
// Misconfigured base URL: better an un-prefilled form than a broken page.
return baseUrl
}
url.searchParams.set('dataset_id', dataset.id)
url.searchParams.set('dataset_url', dataset.page)
url.searchParams.set('dataset_name', dataset.title)
if (resourceExternalUrl) {
url.searchParams.set('url_ressource', resourceExternalUrl)
}
if (resourceFormat) {
url.searchParams.set('format_ressource', resourceFormat)
}
if (simplifiedUserAgent) {
url.searchParams.set('navigateur_appareil', simplifiedUserAgent)
}
return url.toString()
}
Loading