From 3d0e2e44a8d2ce1f1a7412153179e9fd73662545 Mon Sep 17 00:00:00 2001 From: Adrien Carpentier Date: Thu, 10 Sep 2026 16:32:51 +0200 Subject: [PATCH 1/3] feat(search): add live suggestions to the header search bar --- components/MenuSearch/MenuSearch.vue | 86 ++++++++++++++-- .../src/types/organizations.ts | 2 +- tests-unit/utils/search.spec.ts | 35 +++++++ tests/search/menu-search.spec.ts | 98 +++++++++++++++++++ types/types.d.ts | 3 +- utils/search.ts | 56 +++++++++++ 6 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 tests-unit/utils/search.spec.ts create mode 100644 tests/search/menu-search.spec.ts create mode 100644 utils/search.ts diff --git a/components/MenuSearch/MenuSearch.vue b/components/MenuSearch/MenuSearch.vue index 8d9ca5bdc..b9c9249c7 100644 --- a/components/MenuSearch/MenuSearch.vue +++ b/components/MenuSearch/MenuSearch.vue @@ -40,15 +40,42 @@ class="list-none pl-0 text-left mt-1 mb-0 max-h-60 overflow-auto rounded-md bg-white text-base shadow-lg focus:outline-none sm:text-sm" >
  • +
    +
    +
  • +
    + +
  • } } +type Item = MenuItem | Suggestion + +const MIN_SUGGEST_LENGTH = 3 const emit = defineEmits<{ selected: [] }>() const { t } = useTranslation() +const { $api } = useNuxtApp() +const config = useRuntimeConfig() const query = ref('') +const queryDebounced = refDebounced(query, config.public.searchDebounce) const selectedItem = ref(null) +const suggestions = ref>([]) + +const suggestionIcons: Record = { + dataset: RiDatabase2Line, + dataservice: RiTerminalLine, + reuse: RiLineChartLine, + organization: RiBuilding2Line, +} +const suggestionKindLabels = computed>(() => ({ + 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([ + $api>('/api/1/datasets/suggest/', { query: { q, size: 4 } }).catch(() => []), + // The dataservices endpoint may not exist yet on every server: degrade gracefully. + $api>('/api/1/dataservices/suggest/', { query: { q, size: 3 } }).catch(() => []), + $api>('/api/1/reuses/suggest/', { query: { q, size: 3 } }).catch(() => []), + $api>('/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 [ { diff --git a/datagouv-components/src/types/organizations.ts b/datagouv-components/src/types/organizations.ts index ff5d03a8e..6e5474fc7 100644 --- a/datagouv-components/src/types/organizations.ts +++ b/datagouv-components/src/types/organizations.ts @@ -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 diff --git a/tests-unit/utils/search.spec.ts b/tests-unit/utils/search.spec.ts new file mode 100644 index 000000000..175c7f7a5 --- /dev/null +++ b/tests-unit/utils/search.spec.ts @@ -0,0 +1,35 @@ +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', 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') + }) + + it('keeps the id and image for display', () => { + expect(toSuggestions({ reuses: [reuse] })[0]).toMatchObject({ id: 'r1', image_url: 'https://example.org/carte.png' }) + }) +}) diff --git a/tests/search/menu-search.spec.ts b/tests/search/menu-search.spec.ts new file mode 100644 index 000000000..b6785ed17 --- /dev/null +++ b/tests/search/menu-search.spec.ts @@ -0,0 +1,98 @@ +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', 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> = { 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 above 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 are still there, after the suggestions. + await expect(options.filter({ hasText: 'Rechercher « adresse » dans les' })).toHaveCount(4) + await expect(options).toHaveCount(datasets.length + dataservices.length + reuses.length + organizations.length + 4) + await expect(options.first()).toContainText('Base Adresse Nationale (BAN)') + await expect(options.last()).toContainText('Rechercher « adresse » dans les organisations') +}) + +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 the first option is already active when the list opens, the + // option selected with Enter is the one the input points at after ArrowDown. + await input.press('ArrowDown') + const activeId = await input.getAttribute('aria-activedescendant') + expect(activeId).toBeTruthy() + const activeText = await page.locator(`[id="${activeId}"]`).textContent() + const activeDataset = datasets.find(d => activeText?.includes(d.title)) + expect(activeDataset, 'ArrowDown must land on a dataset suggestion').toBeTruthy() + + await input.press('Enter') + await expect(page).toHaveURL(new RegExp(`/datasets/${activeDataset!.slug}$`)) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() +}) + +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 = [] + 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([]) +}) diff --git a/types/types.d.ts b/types/types.d.ts index ac20e30c9..492553a8d 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -121,6 +121,7 @@ export type MultiSelectOption = { export type UserSuggest = Omit & { avatar_url: string | null } export type DatasetSuggest = Pick & { image_url: string | null } +export type DataserviceSuggest = { id: string, title: string, slug: string, page: string } export type SpatialZone = { code: string id: string @@ -177,7 +178,7 @@ export type NewDatasetForApi = { extras?: Record } & WithAccessType -export type ReuseSuggest = Pick & { image_url: string | null } +export type ReuseSuggest = Pick & { image_url: string | null } export type ReuseForm = { featured: boolean diff --git a/utils/search.ts b/utils/search.ts new file mode 100644 index 000000000..0e48910dd --- /dev/null +++ b/utils/search.ts @@ -0,0 +1,56 @@ +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 + image_url?: string | null + // Internal path: never the absolute `page` URL returned by the API. + to: string +} + +export type SuggestResults = { + datasets?: Array + dataservices?: Array + reuses?: Array + organizations?: Array +} + +function withAcronym(label: string, acronym?: string | null): string { + return acronym ? `${label} (${acronym})` : label +} + +export function toSuggestions({ datasets = [], dataservices = [], reuses = [], organizations = [] }: SuggestResults): Array { + return [ + ...datasets.map((dataset): Suggestion => ({ + kind: 'dataset', + id: dataset.id, + label: withAcronym(dataset.title, dataset.acronym), + image_url: dataset.image_url, + to: `/datasets/${dataset.slug}`, + })), + ...dataservices.map((dataservice): Suggestion => ({ + kind: 'dataservice', + id: dataservice.id, + label: dataservice.title, + to: `/dataservices/${dataservice.slug}`, + })), + ...reuses.map((reuse): Suggestion => ({ + kind: 'reuse', + id: reuse.id, + label: reuse.title, + image_url: reuse.image_url, + to: `/reuses/${reuse.slug}`, + })), + ...organizations.map((organization): Suggestion => ({ + kind: 'organization', + id: organization.id, + label: withAcronym(organization.name, organization.acronym), + image_url: organization.image_url, + to: `/organizations/${organization.slug}`, + })), + ] +} From 064d19642fb27a601e28c065326dc2d8ae487907 Mon Sep 17 00:00:00 2001 From: Adrien Carpentier Date: Thu, 10 Sep 2026 16:32:51 +0200 Subject: [PATCH 2/3] fix(search): keep search actions first and silence suggest errors --- components/MenuSearch/MenuSearch.vue | 72 ++++++++++++++-------------- tests-unit/utils/search.spec.ts | 8 ++-- tests/search/menu-search.spec.ts | 53 +++++++++++++++----- types/types.d.ts | 2 +- utils/search.ts | 6 +-- 5 files changed, 82 insertions(+), 59 deletions(-) diff --git a/components/MenuSearch/MenuSearch.vue b/components/MenuSearch/MenuSearch.vue index b9c9249c7..dc62172bd 100644 --- a/components/MenuSearch/MenuSearch.vue +++ b/components/MenuSearch/MenuSearch.vue @@ -40,42 +40,15 @@ class="list-none pl-0 text-left mt-1 mb-0 max-h-60 overflow-auto rounded-md bg-white text-base shadow-lg focus:outline-none sm:text-sm" > -
  • -
    -
    -
  • -
    -
  • + +
  • +
    +
    +
  • +
    @@ -139,8 +139,10 @@ const emit = defineEmits<{ }>() const { t } = useTranslation() -const { $api } = useNuxtApp() 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) @@ -177,11 +179,11 @@ watch(queryDebounced, async (raw) => { if (q.length < MIN_SUGGEST_LENGTH) return const [datasets, dataservices, reuses, organizations] = await Promise.all([ - $api>('/api/1/datasets/suggest/', { query: { q, size: 4 } }).catch(() => []), + suggestFetch>('/api/1/datasets/suggest/', { query: { q, size: 4 } }).catch(() => []), // The dataservices endpoint may not exist yet on every server: degrade gracefully. - $api>('/api/1/dataservices/suggest/', { query: { q, size: 3 } }).catch(() => []), - $api>('/api/1/reuses/suggest/', { query: { q, size: 3 } }).catch(() => []), - $api>('/api/1/organizations/suggest/', { query: { q, size: 3 } }).catch(() => []), + suggestFetch>('/api/1/dataservices/suggest/', { query: { q, size: 3 } }).catch(() => []), + suggestFetch>('/api/1/reuses/suggest/', { query: { q, size: 3 } }).catch(() => []), + suggestFetch>('/api/1/organizations/suggest/', { query: { q, size: 3 } }).catch(() => []), ]) // Stale response: the user kept typing in the meantime. if (q !== query.value.trim()) return diff --git a/tests-unit/utils/search.spec.ts b/tests-unit/utils/search.spec.ts index 175c7f7a5..28510be32 100644 --- a/tests-unit/utils/search.spec.ts +++ b/tests-unit/utils/search.spec.ts @@ -2,7 +2,7 @@ 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', slug: 'api-geo', page: 'https://www.data.gouv.fr/dataservices/api-geo' } +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' } @@ -27,9 +27,7 @@ describe('toSuggestions', () => { 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') - }) - - it('keeps the id and image for display', () => { - expect(toSuggestions({ reuses: [reuse] })[0]).toMatchObject({ id: 'r1', image_url: 'https://example.org/carte.png' }) + expect(toSuggestions({ dataservices: [{ ...dataservice, acronym: 'GEO' }] })[0].label).toEqual('API Géo (GEO)') + expect(toSuggestions({ dataservices: [dataservice] })[0].label).toEqual('API Géo') }) }) diff --git a/tests/search/menu-search.spec.ts b/tests/search/menu-search.spec.ts index b6785ed17..46318ef61 100644 --- a/tests/search/menu-search.spec.ts +++ b/tests/search/menu-search.spec.ts @@ -6,7 +6,7 @@ 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', slug: 'api-adresse', page: 'https://www.data.gouv.fr/dataservices/api-adresse' }] +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' }] @@ -23,7 +23,7 @@ test.beforeEach(async ({ page }) => { const headerSearch = (page: Page) => page.getByPlaceholder('Recherche', { exact: true }).filter({ visible: true }) -test('typing in the header search shows live suggestions above the search links', async ({ page }) => { +test('typing in the header search shows live suggestions below the search links', async ({ page }) => { await page.goto('/') await page.waitForLoadState('networkidle') @@ -36,11 +36,24 @@ test('typing in the header search shows live suggestions above the search links' await expect(options.filter({ hasText: 'Carte des adresses' })).toBeVisible() await expect(options.filter({ hasText: 'Direction des adresses (DA)' })).toBeVisible() - // The four "search in…" links are still there, after the suggestions. + // 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(datasets.length + dataservices.length + reuses.length + organizations.length + 4) - await expect(options.first()).toContainText('Base Adresse Nationale (BAN)') - await expect(options.last()).toContainText('Rechercher « adresse » dans les organisations') + 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 }) => { @@ -51,20 +64,34 @@ test('keyboard selection of a suggestion opens the dataset page', async ({ page await input.fill('adresse') await expect(page.getByRole('option').filter({ hasText: 'Base Sirene des entreprises' })).toBeVisible() - // Whether or not the first option is already active when the list opens, the - // option selected with Enter is the one the input points at after ArrowDown. - await input.press('ArrowDown') + // 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() - const activeText = await page.locator(`[id="${activeId}"]`).textContent() - const activeDataset = datasets.find(d => activeText?.includes(d.title)) - expect(activeDataset, 'ArrowDown must land on a dataset suggestion').toBeTruthy() + await expect(page.locator(`[id="${activeId}"]`)).toContainText('Base Sirene des entreprises') await input.press('Enter') - await expect(page).toHaveURL(new RegExp(`/datasets/${activeDataset!.slug}$`)) + 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') diff --git a/types/types.d.ts b/types/types.d.ts index 492553a8d..cf044a768 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -121,7 +121,7 @@ export type MultiSelectOption = { export type UserSuggest = Omit & { avatar_url: string | null } export type DatasetSuggest = Pick & { image_url: string | null } -export type DataserviceSuggest = { id: string, title: string, slug: string, page: string } +export type DataserviceSuggest = { id: string, title: string, acronym: string | null, slug: string, page: string } export type SpatialZone = { code: string id: string diff --git a/utils/search.ts b/utils/search.ts index 0e48910dd..fdae9e7d0 100644 --- a/utils/search.ts +++ b/utils/search.ts @@ -7,7 +7,6 @@ export type Suggestion = { kind: SuggestionKind id: string label: string - image_url?: string | null // Internal path: never the absolute `page` URL returned by the API. to: string } @@ -29,27 +28,24 @@ export function toSuggestions({ datasets = [], dataservices = [], reuses = [], o kind: 'dataset', id: dataset.id, label: withAcronym(dataset.title, dataset.acronym), - image_url: dataset.image_url, to: `/datasets/${dataset.slug}`, })), ...dataservices.map((dataservice): Suggestion => ({ kind: 'dataservice', id: dataservice.id, - label: dataservice.title, + label: withAcronym(dataservice.title, dataservice.acronym), to: `/dataservices/${dataservice.slug}`, })), ...reuses.map((reuse): Suggestion => ({ kind: 'reuse', id: reuse.id, label: reuse.title, - image_url: reuse.image_url, to: `/reuses/${reuse.slug}`, })), ...organizations.map((organization): Suggestion => ({ kind: 'organization', id: organization.id, label: withAcronym(organization.name, organization.acronym), - image_url: organization.image_url, to: `/organizations/${organization.slug}`, })), ] From dcaaa71645fe181503a54fa07accdfec05e95286 Mon Sep 17 00:00:00 2001 From: Adrien Carpentier Date: Thu, 10 Sep 2026 21:35:17 +0200 Subject: [PATCH 3/3] docs: update comment --- components/MenuSearch/MenuSearch.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/MenuSearch/MenuSearch.vue b/components/MenuSearch/MenuSearch.vue index dc62172bd..959ef50cc 100644 --- a/components/MenuSearch/MenuSearch.vue +++ b/components/MenuSearch/MenuSearch.vue @@ -180,7 +180,7 @@ watch(queryDebounced, async (raw) => { const [datasets, dataservices, reuses, organizations] = await Promise.all([ suggestFetch>('/api/1/datasets/suggest/', { query: { q, size: 4 } }).catch(() => []), - // The dataservices endpoint may not exist yet on every server: degrade gracefully. + // The dataservices suggest endpoint may not exist yet, degrade gracefully suggestFetch>('/api/1/dataservices/suggest/', { query: { q, size: 3 } }).catch(() => []), suggestFetch>('/api/1/reuses/suggest/', { query: { q, size: 3 } }).catch(() => []), suggestFetch>('/api/1/organizations/suggest/', { query: { q, size: 3 } }).catch(() => []),