Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
82 changes: 79 additions & 3 deletions components/MenuSearch/MenuSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,33 @@
</div>
</li>
</ComboboxOption>
<ComboboxOption
v-for="(suggestion, index) in suggestions"
:key="`${suggestion.kind}-${suggestion.id}`"
v-slot="{ active }"
as="template"
:value="suggestion"
>
<li
class="relative cursor-default select-none px-4 hover:bg-gray-some *:last:border-0"
:class="{ 'text-datagouv': active, 'border-t': index === 0 }"
>
<div class="flex items-center space-x-2 border-b py-3">
<component
:is="suggestionIcons[suggestion.kind]"
class="h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div class="flex-1 truncate">
<span class="sr-only">{{ suggestionKindLabels[suggestion.kind] }} : </span>
{{ suggestion.label }}
</div>
<div aria-hidden="true">
<RiArrowRightSLine class="h-4 w-4" />
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</Combobox>
Expand All @@ -93,27 +120,76 @@
import { RiArrowRightSLine, RiDatabase2Line, RiBuilding2Line, RiLineChartLine, RiTerminalLine, RiSearchLine } from '@remixicon/vue'
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, TransitionRoot } from '@headlessui/vue'
import type { Component } from 'vue'
import { TranslationT } from '@datagouv/components-next'
import { refDebounced } from '@vueuse/core'
import { TranslationT, type OrganizationSuggest } from '@datagouv/components-next'
import type { DataserviceSuggest, DatasetSuggest, ReuseSuggest } from '~/types/types'
import { toSuggestions, type Suggestion, type SuggestionKind } from '~/utils/search'

type Item = {
type MenuItem = {
icon: Component
type: string
to: string
to: string | { path: string, query: Record<string, string> }
}
type Item = MenuItem | Suggestion

const MIN_SUGGEST_LENGTH = 3

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

const { t } = useTranslation()
const config = useRuntimeConfig()
// Not `$api`: it toasts on 429/5xx, which would pile up in the header when the
// backend is degraded. Suggestions are best-effort and public, so fail silently.
const suggestFetch = $fetch.create({ baseURL: config.public.apiBase })
const query = ref('')
const queryDebounced = refDebounced(query, config.public.searchDebounce)
const selectedItem = ref<null | Item>(null)
const suggestions = ref<Array<Suggestion>>([])

const suggestionIcons: Record<SuggestionKind, Component> = {
dataset: RiDatabase2Line,
dataservice: RiTerminalLine,
reuse: RiLineChartLine,
organization: RiBuilding2Line,
}
const suggestionKindLabels = computed<Record<SuggestionKind, string>>(() => ({
dataset: t('Jeu de données'),
dataservice: t('API'),
reuse: t('Réutilisation'),
organization: t('Organisation'),
}))

watch(selectedItem, async () => {
if (!selectedItem.value) return
await navigateTo(selectedItem.value.to)
suggestions.value = []
emit('selected')
})

// Clear immediately (not after the debounce) so stale suggestions never
// linger under a query that is too short or was erased.
watch(query, (value) => {
if (value.trim().length < MIN_SUGGEST_LENGTH) suggestions.value = []
})

watch(queryDebounced, async (raw) => {
const q = raw.trim()
if (q.length < MIN_SUGGEST_LENGTH) return

const [datasets, dataservices, reuses, organizations] = await Promise.all([
suggestFetch<Array<DatasetSuggest>>('/api/1/datasets/suggest/', { query: { q, size: 4 } }).catch(() => []),
// The dataservices suggest endpoint may not exist yet, degrade gracefully
suggestFetch<Array<DataserviceSuggest>>('/api/1/dataservices/suggest/', { query: { q, size: 3 } }).catch(() => []),
suggestFetch<Array<ReuseSuggest>>('/api/1/reuses/suggest/', { query: { q, size: 3 } }).catch(() => []),
suggestFetch<Array<OrganizationSuggest>>('/api/1/organizations/suggest/', { query: { q, size: 3 } }).catch(() => []),
])
// Stale response: the user kept typing in the meantime.
if (q !== query.value.trim()) return

suggestions.value = toSuggestions({ datasets, dataservices, reuses, organizations })
})
const menu = computed(() => {
return [
{
Expand Down
2 changes: 1 addition & 1 deletion datagouv-components/src/types/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type Member = {
since: string | null
}

export type OrganizationSuggest = { id: string, image_url: string, name: string }
export type OrganizationSuggest = { id: string, image_url: string, name: string, acronym: string | null, slug: string }

export type OrganizationOrSuggest = Organization | OrganizationReference | OrganizationSuggest

Expand Down
33 changes: 33 additions & 0 deletions tests-unit/utils/search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { toSuggestions } from '~/utils/search'

const dataset = { id: 'd1', title: 'Population', acronym: '', slug: 'population', image_url: null, page: 'https://www.data.gouv.fr/datasets/population' }
const dataservice = { id: 's1', title: 'API Géo', acronym: null, slug: 'api-geo', page: 'https://www.data.gouv.fr/dataservices/api-geo' }
const reuse = { id: 'r1', title: 'Carte', slug: 'carte', image_url: 'https://example.org/carte.png', page: 'https://www.data.gouv.fr/reuses/carte' }
const organization = { id: 'o1', name: 'Institut national', acronym: 'INSEE', slug: 'insee', image_url: 'https://example.org/insee.png' }

describe('toSuggestions', () => {
it('returns nothing for empty inputs', () => {
expect(toSuggestions({})).toEqual([])
expect(toSuggestions({ datasets: [], dataservices: [], reuses: [], organizations: [] })).toEqual([])
})

it('orders datasets, then dataservices, reuses and organizations', () => {
const kinds = toSuggestions({ organizations: [organization], reuses: [reuse], dataservices: [dataservice], datasets: [dataset] }).map(s => s.kind)
expect(kinds).toEqual(['dataset', 'dataservice', 'reuse', 'organization'])
})

it('links to internal pages by slug, never to the absolute page URL', () => {
const paths = toSuggestions({ datasets: [dataset], dataservices: [dataservice], reuses: [reuse], organizations: [organization] }).map(s => s.to)
expect(paths).toEqual(['/datasets/population', '/dataservices/api-geo', '/reuses/carte', '/organizations/insee'])
})

it('appends the acronym to the label when present', () => {
expect(toSuggestions({ organizations: [organization] })[0].label).toEqual('Institut national (INSEE)')
expect(toSuggestions({ organizations: [{ ...organization, acronym: null }] })[0].label).toEqual('Institut national')
expect(toSuggestions({ datasets: [{ ...dataset, acronym: 'POP' }] })[0].label).toEqual('Population (POP)')
expect(toSuggestions({ datasets: [dataset] })[0].label).toEqual('Population')
expect(toSuggestions({ dataservices: [{ ...dataservice, acronym: 'GEO' }] })[0].label).toEqual('API Géo (GEO)')
expect(toSuggestions({ dataservices: [dataservice] })[0].label).toEqual('API Géo')
})
})
125 changes: 125 additions & 0 deletions tests/search/menu-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { Page } from '@playwright/test'
import { test, expect } from '../base'

// Real fixture datasets: selecting a suggestion navigates to a page that exists.
const datasets = [
{ id: 'd1', title: 'Base Adresse Nationale', acronym: 'BAN', slug: 'base-adresse-nationale', image_url: null, page: 'https://www.data.gouv.fr/datasets/base-adresse-nationale' },
{ id: 'd2', title: 'Base Sirene des entreprises', acronym: null, slug: 'base-sirene-des-entreprises-et-de-leurs-etablissements-siren-siret', image_url: null, page: 'https://www.data.gouv.fr/datasets/base-sirene-des-entreprises-et-de-leurs-etablissements-siren-siret' },
]
const dataservices = [{ id: 's1', title: 'API Adresse', acronym: null, slug: 'api-adresse', page: 'https://www.data.gouv.fr/dataservices/api-adresse' }]
const reuses = [{ id: 'r1', title: 'Carte des adresses', slug: 'carte-des-adresses', image_url: null, page: 'https://www.data.gouv.fr/reuses/carte-des-adresses' }]
const organizations = [{ id: 'o1', name: 'Direction des adresses', acronym: 'DA', slug: 'direction-des-adresses', image_url: '', page: 'https://www.data.gouv.fr/organizations/direction-des-adresses' }]

const suggestByKind: Record<string, Array<unknown>> = { datasets, dataservices, reuses, organizations }

test.beforeEach(async ({ page }) => {
await page.route(/\/api\/1\/(datasets|dataservices|organizations|reuses)\/suggest\//, (route) => {
const kind = route.request().url().match(/\/api\/1\/(\w+)\/suggest\//)![1]
return route.fulfill({ json: suggestByKind[kind] })
})
})

// The header renders a second, invisible search bar for the mobile modal.
const headerSearch = (page: Page) =>
page.getByPlaceholder('Recherche', { exact: true }).filter({ visible: true })

test('typing in the header search shows live suggestions below the search links', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')

const input = headerSearch(page)
await input.fill('adresse')

const options = page.getByRole('option')
await expect(options.filter({ hasText: 'Base Adresse Nationale (BAN)' })).toBeVisible()
await expect(options.filter({ hasText: 'API Adresse' })).toBeVisible()
await expect(options.filter({ hasText: 'Carte des adresses' })).toBeVisible()
await expect(options.filter({ hasText: 'Direction des adresses (DA)' })).toBeVisible()

// The four "search in…" links come first, the suggestions after them.
await expect(options.filter({ hasText: 'Rechercher « adresse » dans les' })).toHaveCount(4)
await expect(options).toHaveCount(4 + datasets.length + dataservices.length + reuses.length + organizations.length)
await expect(options.first()).toContainText('Rechercher « adresse » dans les jeux de données')
await expect(options.nth(4)).toContainText('Base Adresse Nationale (BAN)')
await expect(options.last()).toContainText('Direction des adresses (DA)')
})

test('Enter right after typing still opens the datasets search, even once suggestions are shown', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')

const input = headerSearch(page)
await input.fill('adresse')
await expect(page.getByRole('option').filter({ hasText: 'Base Adresse Nationale (BAN)' })).toBeVisible()

await input.press('Enter')
await expect(page).toHaveURL(/\/datasets\/search\?q=adresse$/)
})

test('keyboard selection of a suggestion opens the dataset page', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')

const input = headerSearch(page)
await input.fill('adresse')
await expect(page.getByRole('option').filter({ hasText: 'Base Sirene des entreprises' })).toBeVisible()

// Whether or not an option is already active when the list opens, `End` then
// three `ArrowUp` lands on the second dataset (last option is the organization,
// preceded by the reuse and the dataservice).
await input.press('End')
await input.press('ArrowUp')
await input.press('ArrowUp')
await input.press('ArrowUp')
const activeId = await input.getAttribute('aria-activedescendant')
expect(activeId).toBeTruthy()
await expect(page.locator(`[id="${activeId}"]`)).toContainText('Base Sirene des entreprises')

Check failure on line 76 in tests/search/menu-search.spec.ts

View workflow job for this annotation

GitHub Actions / e2e (chromium, 2)

[chromium] › tests/search/menu-search.spec.ts:59:1 › keyboard selection of a suggestion opens the dataset page

1) [chromium] › tests/search/menu-search.spec.ts:59:1 › keyboard selection of a suggestion opens the dataset page Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toContainText(expected) failed Locator: locator('[id="headlessui-combobox-option-v-0-27"]') Expected substring: "Base Sirene des entreprises" Received string: "Réutilisation : Carte des adresses" Timeout: 5000ms Call log: - Expect "toContainText" with timeout 5000ms - waiting for locator('[id="headlessui-combobox-option-v-0-27"]') - locator resolved to <li role="option" tabindex="-1" aria-selected="false" data-headlessui-state="active" id="headlessui-combobox-option-v-0-27" class="relative cursor-default select-none px-4 hover:bg-gray-some *:last:border-0 text-datagouv">…</li> 13 × unexpected value "Réutilisation : Carte des adresses" - locator resolved to <li role="option" tabindex="-1" aria-selected="false" data-headlessui-state="" id="headlessui-combobox-option-v-0-27" class="relative cursor-default select-none px-4 hover:bg-gray-some *:last:border-0">…</li> - unexpected value "Réutilisation : Carte des adresses" 74 | const activeId = await input.getAttribute('aria-activedescendant') 75 | expect(activeId).toBeTruthy() > 76 | await expect(page.locator(`[id="${activeId}"]`)).toContainText('Base Sirene des entreprises') | ^ 77 | 78 | await input.press('Enter') 79 | await expect(page).toHaveURL(new RegExp(`/datasets/${datasets[1].slug}$`)) at /__w/cdata/cdata/tests/search/menu-search.spec.ts:76:52

Check failure on line 76 in tests/search/menu-search.spec.ts

View workflow job for this annotation

GitHub Actions / e2e (chromium, 2)

[chromium] › tests/search/menu-search.spec.ts:59:1 › keyboard selection of a suggestion opens the dataset page

1) [chromium] › tests/search/menu-search.spec.ts:59:1 › keyboard selection of a suggestion opens the dataset page Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toContainText(expected) failed Locator: locator('[id="headlessui-combobox-option-v-0-27"]') Expected substring: "Base Sirene des entreprises" Received string: "Réutilisation : Carte des adresses" Timeout: 5000ms Call log: - Expect "toContainText" with timeout 5000ms - waiting for locator('[id="headlessui-combobox-option-v-0-27"]') 14 × locator resolved to <li role="option" tabindex="-1" aria-selected="false" data-headlessui-state="active" id="headlessui-combobox-option-v-0-27" class="relative cursor-default select-none px-4 hover:bg-gray-some *:last:border-0 text-datagouv">…</li> - unexpected value "Réutilisation : Carte des adresses" 74 | const activeId = await input.getAttribute('aria-activedescendant') 75 | expect(activeId).toBeTruthy() > 76 | await expect(page.locator(`[id="${activeId}"]`)).toContainText('Base Sirene des entreprises') | ^ 77 | 78 | await input.press('Enter') 79 | await expect(page).toHaveURL(new RegExp(`/datasets/${datasets[1].slug}$`)) at /__w/cdata/cdata/tests/search/menu-search.spec.ts:76:52

await input.press('Enter')
await expect(page).toHaveURL(new RegExp(`/datasets/${datasets[1].slug}$`))
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})

test('clicking a suggestion opens its page', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')

const input = headerSearch(page)
await input.fill('adresse')
await page.getByRole('option').filter({ hasText: 'Base Adresse Nationale (BAN)' }).click()

await expect(page).toHaveURL(new RegExp(`/datasets/${datasets[0].slug}$`))
await expect(page.getByRole('heading', { level: 1 })).toContainText('Base Adresse Nationale')
})

test('Escape closes the suggestion list', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')

const input = headerSearch(page)
await input.fill('adresse')
await expect(page.getByRole('option').filter({ hasText: 'API Adresse' })).toBeVisible()

await input.press('Escape')
await expect(page.getByRole('listbox')).toHaveCount(0)
await expect(page.getByRole('option')).toHaveCount(0)
})

test('short queries do not call the suggest endpoints', async ({ page }) => {
const suggestCalls: Array<string> = []
page.on('request', (request) => {
if (/\/suggest\//.test(request.url())) suggestCalls.push(request.url())
})

await page.goto('/')
await page.waitForLoadState('networkidle')

const input = headerSearch(page)
await input.fill('ad')
await expect(page.getByRole('option')).toHaveCount(4)
await expect(page.getByRole('option').filter({ hasText: 'Rechercher « ad » dans les jeux de données' })).toBeVisible()

// Longer than the debounce: a request for the short query would have been sent by now.
await page.waitForTimeout(600)
expect(suggestCalls).toEqual([])
})
3 changes: 2 additions & 1 deletion types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export type MultiSelectOption = {

export type UserSuggest = Omit<User, 'avatar' | 'avatar_thumbnail' | 'roles' | 'pages'> & { avatar_url: string | null }
export type DatasetSuggest = Pick<Dataset, 'acronym' | 'id' | 'slug' | 'title' | 'page'> & { image_url: string | null }
export type DataserviceSuggest = { id: string, title: string, acronym: string | null, slug: string, page: string }
export type SpatialZone = {
code: string
id: string
Expand Down Expand Up @@ -177,7 +178,7 @@ export type NewDatasetForApi = {
extras?: Record<string, unknown>
} & WithAccessType

export type ReuseSuggest = Pick<Reuse, 'acronym' | 'id' | 'slug' | 'title' | 'page'> & { image_url: string | null }
export type ReuseSuggest = Pick<Reuse, 'id' | 'slug' | 'title' | 'page'> & { image_url: string | null }

export type ReuseForm = {
featured: boolean
Expand Down
52 changes: 52 additions & 0 deletions utils/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { OrganizationSuggest } from '@datagouv/components-next'
import type { DataserviceSuggest, DatasetSuggest, ReuseSuggest } from '~/types/types'

export type SuggestionKind = 'dataset' | 'dataservice' | 'reuse' | 'organization'

export type Suggestion = {
kind: SuggestionKind
id: string
label: string
// Internal path: never the absolute `page` URL returned by the API.
to: string
}

export type SuggestResults = {
datasets?: Array<DatasetSuggest>
dataservices?: Array<DataserviceSuggest>
reuses?: Array<ReuseSuggest>
organizations?: Array<OrganizationSuggest>
}

function withAcronym(label: string, acronym?: string | null): string {
return acronym ? `${label} (${acronym})` : label
}

export function toSuggestions({ datasets = [], dataservices = [], reuses = [], organizations = [] }: SuggestResults): Array<Suggestion> {
return [
...datasets.map((dataset): Suggestion => ({
kind: 'dataset',
id: dataset.id,
label: withAcronym(dataset.title, dataset.acronym),
to: `/datasets/${dataset.slug}`,
})),
...dataservices.map((dataservice): Suggestion => ({
kind: 'dataservice',
id: dataservice.id,
label: withAcronym(dataservice.title, dataservice.acronym),
to: `/dataservices/${dataservice.slug}`,
})),
...reuses.map((reuse): Suggestion => ({
kind: 'reuse',
id: reuse.id,
label: reuse.title,
to: `/reuses/${reuse.slug}`,
})),
...organizations.map((organization): Suggestion => ({
kind: 'organization',
id: organization.id,
label: withAcronym(organization.name, organization.acronym),
to: `/organizations/${organization.slug}`,
})),
]
}
Loading