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
140 changes: 126 additions & 14 deletions datagouv-components/src/components/Search/GlobalSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,27 @@
v-if="showSidebar"
class="col-span-12 md:col-span-4 lg:col-span-3 md:space-y-8"
>
<div v-if="config.length > 1">
<div v-if="universes && universes.length > 1">
<Sidemenu :button-text="t('Univers')">
<template #title>
{{ t('Univers') }}
</template>
<RadioGroup
v-model="universeParam"
name="search-universe"
>
<RadioInput
v-for="u in universes"
:key="u.key"
:value="u.key"
:icon="u.icon"
>
{{ u.name }}
</RadioInput>
</RadioGroup>
</Sidemenu>
</div>
<div v-if="effectiveConfig.length > 1">
<Sidemenu :button-text="t('Type')">
<template #title>
{{ t('Type') }}
Expand All @@ -31,7 +51,7 @@
name="search-type"
>
<RadioInput
v-for="typeConfig in config"
v-for="typeConfig in effectiveConfig"
:key="configKey(typeConfig)"
:value="configKey(typeConfig)"
:count="resultsMap[configKey(typeConfig)]?.data.value?.total"
Expand Down Expand Up @@ -370,7 +390,7 @@ import type { Dataservice } from '../../types/dataservices'
import type { Organization } from '../../types/organizations'
import type { ReuseV2 } from '../../types/reuses'
import type { TopicV2 } from '../../types/topics'
import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
import type { GlobalSearchConfig, UniverseConfig, SearchTypeConfig, NonEmptyArray, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
import { getDefaultGlobalSearchConfig } from '../../types/search'
import BrandedButton from '../BrandedButton.vue'
import LoadingBlock from '../LoadingBlock.vue'
Expand Down Expand Up @@ -402,7 +422,12 @@ import DatasetBadgeFilter from './Filter/DatasetBadgeFilter.vue'
import ReuseTypeFilter from './Filter/ReuseTypeFilter.vue'

const props = withDefaults(defineProps<{
config?: GlobalSearchConfig
/**
* Either a flat list of search types (`GlobalSearchConfig`, e.g. `[{ class: 'datasets', ... }]`,
* shared across the whole component), or a list of topic-scoped bundles for universes
* (`UniverseConfig[]`, e.g. `[{ key, name, topicId, types: [...] }]`)
*/
config?: GlobalSearchConfig | UniverseConfig[]
placeholder?: string | null
hideSearchInput?: boolean
autoFocus?: boolean
Expand All @@ -416,9 +441,57 @@ const emit = defineEmits<{
resultsCount: [total: number]
}>()

// Duck-type config list: universes list or standard config?
function isUniverseList(config: GlobalSearchConfig | UniverseConfig[]): config is UniverseConfig[] {
const first = config[0]
return first !== undefined && 'topicId' in first
}

// Normalizes props.config for universes with synthetic
// {universe}-{class} keys injected for any type missing an explicit key
const universes = computed<NonEmptyArray<UniverseConfig> | undefined>(() => {
if (!isUniverseList(props.config)) return undefined
return props.config.map(u => ({
...u,
types: u.types.map(c => c.key ? c : { ...c, key: `${u.key}-${c.class}` }) as NonEmptyArray<SearchTypeConfig>,
})) as NonEmptyArray<UniverseConfig>
})

const flatConfig = computed<GlobalSearchConfig>(() =>
universes.value ? [] : (props.config as GlobalSearchConfig),
)

// Universe state: computed before currentType init so SSR validation can use it
const universeParam = useRouteQuery<string>('universe', universes.value?.[0]?.key ?? '')

const activeUniverse = computed<UniverseConfig | undefined>(() => {
if (!universes.value) return undefined
return universes.value.find(u => u.key === universeParam.value) ?? universes.value[0]
})

const activeUniverseTopic = computed<string | undefined>(() =>
activeUniverse.value?.topicId,
)

// Effective config: active universe's types when in universe mode, otherwise flatConfig
const effectiveConfig = computed<GlobalSearchConfig>(() =>
activeUniverse.value ? activeUniverse.value.types : flatConfig.value,
)

// defineModel's default is static and can't depend on props, so we cast and initialize manually
const currentType = defineModel<string>('type') as Ref<string>
if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
if (!currentType.value) {
const first = activeUniverse.value?.types[0] ?? flatConfig.value[0] ?? { class: 'datasets' as const }
currentType.value = configKey(first)
}
else if (activeUniverse.value) {
// Clamp type to the active universe's types on page load
const validKeys = new Set(activeUniverse.value.types.map(c => configKey(c)))
const firstType = activeUniverse.value.types[0]
if (!validKeys.has(currentType.value) && firstType) {
currentType.value = configKey(firstType)
}
}

const { t } = useTranslation()
const componentsConfig = useComponentsConfig()
Expand All @@ -434,7 +507,7 @@ const customFilterStops = new Map<string, () => void>()
const initialType = currentType.value

const currentTypeConfig = computed(() =>
props.config.find(c => configKey(c) === currentType.value),
effectiveConfig.value.find(c => configKey(c) === currentType.value),
)

// Precedence: prop → per-type config → strategy default.
Expand Down Expand Up @@ -467,7 +540,8 @@ const activeSortValues = computed(() =>
// intrinsic width regardless of which type is currently active.
const allSortOptions = computed(() => {
const seen = new Set<string>()
return props.config.flatMap(c => (c.sortOptions ?? []) as SortOption<string>[]).filter((o) => {
const allTypeConfigs = universes.value ? universes.value.flatMap(u => u.types) : flatConfig.value
return allTypeConfigs.flatMap(c => (c.sortOptions ?? []) as SortOption<string>[]).filter((o) => {
if (seen.has(o.value)) return false
seen.add(o.value)
return true
Expand All @@ -480,7 +554,7 @@ const activeFilters = computed(() => [
] as string[])

const slots = useSlots()
const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
const showSidebar = computed(() => (universes.value && universes.value.length > 1) || effectiveConfig.value.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])

// URL query params
const q = useRouteQuery<string>('q', '')
Expand Down Expand Up @@ -549,6 +623,22 @@ const allFilters: Record<string, Ref<unknown>> = {
type: reuseType,
}

// Reset type, sort, and filters when changing universe
watch(universeParam, (newKey, oldKey) => {
if (!universes.value || newKey === oldKey) return
const newUniverse = universes.value.find(u => u.key === newKey)
if (!newUniverse) return
const newTypeKeys = new Set(newUniverse.types.map(c => configKey(c)))
if (!newTypeKeys.has(currentType.value)) {
const oldUniverse = universes.value.find(u => u.key === oldKey)
const currentClass = oldUniverse?.types.find(c => configKey(c) === currentType.value)?.class
const sameClassType = currentClass ? newUniverse.types.find(c => c.class === currentClass) : undefined
currentType.value = configKey(sameClassType ?? newUniverse.types[0])
}
resetFilters({ preserveQ: true })
page.value = 1
}, { flush: 'post' }) // post: let Vue finish patching the universe RadioGroup before secondary mutations trigger a new render

// Reset page, sort and filters when changing type. Every reset below goes
// through useRouteQuery, so VueUse coalesces them into a single router.replace
// (its shared _queriesQueue flushes once on nextTick). Custom filters are cleared
Expand Down Expand Up @@ -587,6 +677,7 @@ const stableParamsOptions = {
sort,
page,
pageSize,
universeTopic: activeUniverseTopic,
}

// Discriminated union: each variant carries its own response type so a `class`
Expand Down Expand Up @@ -663,9 +754,23 @@ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
}),
}

// One params + fetch per config entry, keyed by configKey
// One params + fetch per config entry, keyed by configKey.
// In universe mode, build from the deduplicated union of all resolved universes' types.
// Synthetic keys ({universe}-{class}) ensure each universe×type gets its own fetch instance.
const buildConfigs: GlobalSearchConfig = universes.value
? (() => {
const seen = new Set<string>()
return universes.value.flatMap(u => u.types).filter((c) => {
const k = configKey(c)
if (seen.has(k)) return false
seen.add(k)
return true
})
})()
: flatConfig.value

const resultsMap: Record<string, SearchEntry> = {}
for (const c of props.config) {
for (const c of buildConfigs) {
const key = configKey(c)
const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
Expand Down Expand Up @@ -717,9 +822,9 @@ const hasFilters = computed(() => {
|| Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
})

const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
const showForumLink = computed(() => (currentTypeConfig.value?.class === 'datasets' || currentTypeConfig.value?.class === 'dataservices') && !!componentsConfig.forumUrl)

function resetFilters() {
function resetFilters(options: { preserveQ?: boolean } = {}) {
organizationId.value = undefined
organizationType.value = undefined
tag.value = undefined
Expand All @@ -738,8 +843,10 @@ function resetFilters() {
for (const entry of customFilterRegistry.values()) {
entry.ref.value = entry.defaultValue
}
q.value = ''
flushQ()
if (!options.preserveQ) {
q.value = ''
flushQ()
}
}

const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
Expand Down Expand Up @@ -775,6 +882,11 @@ const rssUrl = computed(() => {
if (badge.value) params.set('badge', badge.value)
if (topic.value) params.set('topic', topic.value)

// Universe topic (authoritative — set last so it wins over user topic filter)
if (activeUniverseTopic.value) {
params.set('topic', activeUniverseTopic.value)
}

forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
params.set(apiParam, value)
}, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
Expand Down
16 changes: 13 additions & 3 deletions datagouv-components/src/composables/useStableQueryParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface StableQueryParamsOptions {
sort: Ref<string | undefined>
page: Ref<number>
pageSize: number
universeTopic?: Ref<string | undefined>
}

/**
Expand All @@ -20,6 +21,7 @@ interface StableQueryParamsOptions {
*/
export function useStableQueryParams(options: StableQueryParamsOptions) {
const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options
const universeTopic = options.universeTopic ?? ref(undefined)
const stableParams = ref<Record<string, unknown>>({})

const buildParams = () => {
Expand Down Expand Up @@ -52,7 +54,7 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
}
}

// 3.5. Apply custom filter values. Concatenate into an array on collision
// 4. Apply custom filter values. Concatenate into an array on collision
// so a custom filter mapped onto a built-in apiParam (e.g. theme → tag)
// combines with an existing built-in value instead of overwriting it.
// Pass the current type key so filters scoped to specific types are excluded
Expand All @@ -68,7 +70,15 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
}
}, currentTypeKey)

// 4. Always include q, sort (if valid for this type), page, page_size
// 5. Universe topic: authoritative scope, overrides any user-set topic for supported types
if (universeTopic.value) {
const cls = typeConfig?.class
if (cls === 'datasets' || cls === 'dataservices') {
params.topic = universeTopic.value
}
}

// 6. Always include q, sort (if valid for this type), page, page_size
if (q.value) {
params.q = q.value
}
Expand Down Expand Up @@ -100,7 +110,7 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {

// Watch all dependencies and update only if content changed
watch(
[q, sort, page, ...Object.values(allFilters), customFilterValues],
[q, sort, page, ...Object.values(allFilters), customFilterValues, universeTopic],
() => {
const newParams = buildParams()
// JSON.stringify comparison is safe here because buildParams() builds the object deterministically
Expand Down
4 changes: 3 additions & 1 deletion datagouv-components/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { User, UserReference } from './types/users'
import type { Report, ReportSubject, ReportReason } from './types/reports'
import type { Chart, ChartForm, ChartForApi, FilterCondition, Filter, AndFilters, GenericFilter, XAxisType, XAxisSortBy, SortDirection, XAxis, XAxisForm, UnitPosition, YAxis, DataSeriesType, DataSeries, DataSeriesForm, CombinedSort, ColumnDefinition, ColumnsDefinition } from './types/visualizations'
import type { ColumnType } from './components/TabularExplorer/types'
import type { GlobalSearchConfig, SearchType, SearchTypeConfig, SortOption, HiddenFilter, BuiltInFilterKey, DatasetSearchConfig, DatasetSearchFilters, DataserviceSearchConfig, DataserviceSearchFilters, ReuseSearchConfig, ReuseSearchFilters, OrganizationSearchConfig, OrganizationSearchFilters, TopicSearchConfig, TopicSearchFilters } from './types/search'
import type { NonEmptyArray, GlobalSearchConfig, UniverseConfig, SearchType, SearchTypeConfig, SortOption, HiddenFilter, BuiltInFilterKey, DatasetSearchConfig, DatasetSearchFilters, DataserviceSearchConfig, DataserviceSearchFilters, ReuseSearchConfig, ReuseSearchFilters, OrganizationSearchConfig, OrganizationSearchFilters, TopicSearchConfig, TopicSearchFilters } from './types/search'
import { getDefaultDatasetConfig, getDefaultDataserviceConfig, getDefaultReuseConfig, getDefaultOrganizationConfig, getDefaultTopicConfig, getDefaultGlobalSearchConfig, defaultDatasetSortOptions, defaultDataserviceSortOptions, defaultReuseSortOptions, defaultOrganizationSortOptions } from './types/search'
import { useSearchFilter } from './composables/useSearchFilter'
import type { UseSearchFilterOptions } from './composables/useSearchFilter'
Expand Down Expand Up @@ -145,7 +145,9 @@ export * from './functions/charts'
export * from './types/access_types'

export type {
NonEmptyArray,
GlobalSearchConfig,
UniverseConfig,
SearchType,
SearchTypeConfig,
SortOption,
Expand Down
10 changes: 10 additions & 0 deletions datagouv-components/src/types/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type {

// Common types

export type NonEmptyArray<T> = [T, ...T[]]

export type LastUpdateRange = 'last_30_days' | 'last_12_months' | 'last_3_years'

export type ProducerType = Exclude<OrganizationTypes, 'other'> | typeof USER | 'not-specified'
Expand Down Expand Up @@ -368,6 +370,14 @@ export type SearchType = SearchTypeConfig['class']

export type GlobalSearchConfig = SearchTypeConfig[]

export type UniverseConfig = {
key: string
name: string
icon?: Component | string
topicId: string
types: NonEmptyArray<SearchTypeConfig>
}

// Maps each search class to its concrete response shape.
export type SearchResponseByClass = {
datasets: DatasetSearchResponse<Dataset>
Expand Down
7 changes: 6 additions & 1 deletion pages/design.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
:label="$t('Recherche de réutilisations')"
to="/design/reuse-search"
/>
<AdminSidebarLink
:icon="RiGlobalLine"
:label="$t('Recherche par univers')"
to="/design/universe-search"
/>
<AdminSidebarLink
:icon="RiUserSearchLine"
:label="$t('Recherche d\'organisations')"
Expand Down Expand Up @@ -87,7 +92,7 @@
</template>

<script setup lang="ts">
import { RiEyeLine, RiExternalLinkLine, RiFileSearchLine, RiIdCardLine, RiLineChartLine, RiListView, RiRadioButtonLine, RiSearch2Line, RiSearchEyeLine, RiTranslate, RiUserSearchLine, RiListCheck } from '@remixicon/vue'
import { RiEyeLine, RiExternalLinkLine, RiFileSearchLine, RiGlobalLine, RiIdCardLine, RiLineChartLine, RiListView, RiRadioButtonLine, RiSearch2Line, RiSearchEyeLine, RiTranslate, RiUserSearchLine, RiListCheck } from '@remixicon/vue'
import AdminSidebarLink from '~/components/AdminSidebar/AdminSidebarLink/AdminSidebarLink.vue'
import LogoOnly from '~/components/LogoOnly.vue'
import Sidemenu from '~/components/Sidemenu/Sidemenu.global.vue'
Expand Down
Loading
Loading