Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 41 additions & 0 deletions components/Design/ThemeTagFilter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<template>
<div class="fr-input-group">
<label
for="theme-filter"
class="fr-label"
>
Thème (custom filter)
</label>
<select
id="theme-filter"
v-model="value"
class="fr-select shadow-input-blue!"
>
<option :value="undefined">
Tous les thèmes
</option>
<option
v-for="theme in themes"
:key="theme.value"
:value="theme.value"
>
{{ theme.label }}
</option>
</select>
</div>
</template>

<script setup lang="ts">
import { useSearchFilter } from '@datagouv/components-next'

const themes = [
{ value: 'environnement', label: 'Environnement' },
{ value: 'transport', label: 'Transport' },
{ value: 'sante', label: 'Santé' },
{ value: 'education', label: 'Éducation' },
{ value: 'logement', label: 'Logement' },
]

// ?theme=environnement dans l'URL → tag=environnement dans l'API
const value = useSearchFilter('theme', { apiParam: 'tag' })
</script>
48 changes: 45 additions & 3 deletions datagouv-components/src/components/Search/GlobalSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
</Sidemenu>
</div>

<div v-if="activeFilters.length > 0">
<div v-if="activeFilters.length > 0 || hasCustomFilters">
<Sidemenu :button-text="t('Filtres')">
<template #title>
{{ t('Filtres') }}
Expand Down Expand Up @@ -146,6 +146,10 @@
:get-order="getOrder"
/>
</BasicAndAdvancedFilters>
<slot
name="custom-filters"
:current-type="currentType"
/>
<div
v-if="hasFilters"
class="mt-6 text-center"
Expand Down Expand Up @@ -340,12 +344,13 @@
</template>

<script setup lang="ts">
import { computed, watch, useTemplateRef, type Ref } from 'vue'
import { computed, provide, shallowReactive, watch, useTemplateRef, type Ref } from 'vue'
import { useRouteQuery } from '@vueuse/router'
import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
import { useTranslation } from '../../composables/useTranslation'
import { useDebouncedRef } from '../../composables/useDebouncedRef'
import { searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
import { useStableQueryParams } from '../../composables/useStableQueryParams'
import { useComponentsConfig } from '../../config'
import { useFetch } from '../../functions/api'
Expand Down Expand Up @@ -399,6 +404,18 @@ if (!currentType.value) currentType.value = props.config[0]?.class ?? 'datasets'
const { t } = useTranslation()
const componentsConfig = useComponentsConfig()

// Custom filter registry for useSearchFilter composable
const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())

provide(searchFilterContextKey, {
register(urlParam, entry) {
customFilterRegistry.set(urlParam, entry)
},
unregister(urlParam) {
customFilterRegistry.delete(urlParam)
},
})

// Initial type is used to determine which fetch should be SSR (non-lazy)
const initialType = currentType.value

Expand Down Expand Up @@ -439,7 +456,8 @@ const activeFilters = computed(() => [
...(currentTypeConfig.value?.advancedFilters ?? []),
] as string[])

const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0)
const hasCustomFilters = computed(() => customFilterRegistry.size > 0)
const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || hasCustomFilters.value)

// URL query params
const q = useRouteQuery<string>('q', '')
Expand Down Expand Up @@ -513,6 +531,7 @@ const topicsEnabled = computed(() => props.config.some(c => c.class === 'topics'
// Create stable params for each type
const stableParamsOptions = {
allFilters,
customFilterRegistry,
q: qDebounced,
sort,
page,
Expand Down Expand Up @@ -547,6 +566,15 @@ const reusesUrl = computed(() => reusesEnabled.value ? '/api/2/reuses/search/' :
const organizationsUrl = computed(() => organizationsEnabled.value ? '/api/2/organizations/search/' : null)
const topicsUrl = computed(() => topicsEnabled.value ? '/api/2/topics/search/' : null)

// Snapshot of custom filter values for reactivity tracking
const customFiltersSnapshot = computed(() => {
const result: Record<string, unknown> = {}
for (const [urlParam, entry] of customFilterRegistry) {
result[urlParam] = entry.ref.value
}
return result
})

// Reset page on filter/sort change
const filtersForReset = computed(() => ({
q: qDebounced.value,
Expand All @@ -565,6 +593,7 @@ const filtersForReset = computed(() => ({
last_update_range: lastUpdateRange.value,
producer_type: producerType.value,
type: reuseType.value,
...customFiltersSnapshot.value,
}))

watch(filtersForReset, () => page.value = 1)
Expand All @@ -587,6 +616,9 @@ const hasFilters = computed(() => {
|| lastUpdateRange.value
|| producerType.value
|| reuseType.value
|| Array.from(customFilterRegistry.values()).some(
entry => entry.ref.value !== undefined && entry.ref.value !== entry.defaultValue,
)
})

const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
Expand All @@ -607,6 +639,9 @@ function resetFilters() {
lastUpdateRange.value = undefined
producerType.value = undefined
reuseType.value = undefined
for (const entry of customFilterRegistry.values()) {
entry.ref.value = entry.defaultValue
}
q.value = ''
flushQ()
}
Expand Down Expand Up @@ -702,6 +737,13 @@ const rssUrl = computed(() => {
if (badge.value) params.set('badge', badge.value)
if (topic.value) params.set('topic', topic.value)

// Add custom filter values
for (const [, entry] of customFilterRegistry) {
if (entry.ref.value !== undefined && entry.ref.value !== entry.defaultValue) {
params.set(entry.apiParam, String(entry.ref.value))
}
}

// Add sort if set
if (sort.value) params.set('sort', sort.value)

Expand Down
66 changes: 66 additions & 0 deletions datagouv-components/src/composables/useSearchFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { type InjectionKey, type Ref, inject, onScopeDispose } from 'vue'
import { useRouteQuery } from '@vueuse/router'

export interface CustomFilterEntry {
apiParam: string
ref: Ref<string | undefined>
defaultValue: string | undefined
}

export interface SearchFilterContext {
register(urlParam: string, entry: CustomFilterEntry): void
unregister(urlParam: string): void
}

export const searchFilterContextKey: InjectionKey<SearchFilterContext>
= Symbol('SearchFilterContext')

export interface UseSearchFilterOptions {
/** The API parameter name to map this filter to. Defaults to the urlParam. */
apiParam?: string
/** Default value when not present in URL. Defaults to undefined. */
defaultValue?: string
}

/**
* Registers a custom filter with the parent GlobalSearch component.
*
* Must be called inside a component rendered within GlobalSearch's `#custom-filters` slot.
*
* @param urlParam - The URL query parameter name (e.g. 'theme' → `?theme=value`)
* @param options - Optional: `apiParam` to map to a different API param (e.g. 'tag'), `defaultValue`
* @returns A reactive ref bound to the URL parameter, suitable for v-model
*
* @example
* ```vue
* <script setup>
* import { useSearchFilter } from '@datagouv/components-next'
* // URL: ?theme=environment → API: ?tag=environment
* const value = useSearchFilter('theme', { apiParam: 'tag' })
* </script>
* ```
*/
export function useSearchFilter(
urlParam: string,
options: UseSearchFilterOptions = {},
): Ref<string | undefined> {
const context = inject(searchFilterContextKey)
if (!context) {
throw new Error(
`useSearchFilter("${urlParam}") must be used inside a <GlobalSearch> component.`,
)
}

const { apiParam = urlParam, defaultValue = undefined } = options

const value = useRouteQuery<string | undefined>(urlParam, defaultValue)

context.register(urlParam, { apiParam, ref: value, defaultValue })

onScopeDispose(() => {
value.value = defaultValue
context.unregister(urlParam)
})

return value
}
35 changes: 32 additions & 3 deletions datagouv-components/src/composables/useStableQueryParams.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { ref, watch, type Ref } from 'vue'
import { computed, ref, watch, type Ref } from 'vue'
import type { SearchTypeConfig } from '../types/search'
import type { CustomFilterEntry } from './useSearchFilter'

type FilterRefs = Record<string, Ref<unknown>>

interface StableQueryParamsOptions {
typeConfig: SearchTypeConfig | undefined
allFilters: FilterRefs
customFilterRegistry: Map<string, CustomFilterEntry>
q: Ref<string>
sort: Ref<string | undefined>
page: Ref<number>
Expand All @@ -17,7 +19,7 @@ interface StableQueryParamsOptions {
* Applies hiddenFilters first, then user filters (which can override hiddenFilters).
*/
export function useStableQueryParams(options: StableQueryParamsOptions) {
const { typeConfig, allFilters, q, sort, page, pageSize } = options
const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options
const stableParams = ref<Record<string, unknown>>({})

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

// 3.5. Apply custom filter values
for (const [, entry] of customFilterRegistry) {
const value = entry.ref.value
if (value !== undefined && value !== '' && value !== null) {
const existing = params[entry.apiParam]
if (existing !== undefined) {
// Concatenate into array for multi-value params (e.g., tag)
params[entry.apiParam] = Array.isArray(existing)
? [...existing, value]
: [existing, value]
}
else {
params[entry.apiParam] = value
}
}
}

// 4. Always include q, sort (if valid for this type), page, page_size
if (q.value) {
params.q = q.value
Expand All @@ -66,9 +85,19 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
return params
}

// Computed that reads all custom filter values, establishing reactive dependencies
// on both the Map mutations (shallowReactive) and each entry's ref.value.
const customFilterValues = computed(() => {
const snapshot: Record<string, unknown> = {}
for (const [urlParam, entry] of customFilterRegistry) {
snapshot[urlParam] = entry.ref.value
}
return snapshot
})
Comment thread
ThibaudDauce marked this conversation as resolved.

// Watch all dependencies and update only if content changed
watch(
[q, sort, page, ...Object.values(allFilters)],
[q, sort, page, ...Object.values(allFilters), customFilterValues],
() => {
const newParams = buildParams()
// JSON.stringify comparison is safe here because buildParams() builds the object deterministically
Expand Down
4 changes: 4 additions & 0 deletions datagouv-components/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type { User, UserReference } from './types/users'
import type { Report, ReportSubject, ReportReason } from './types/reports'
import type { GlobalSearchConfig, SearchType, SortOption } 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'

import ActivityList from './components/ActivityList/ActivityList.vue'
import UserActivityList from './components/ActivityList/UserActivityList.vue'
Expand Down Expand Up @@ -126,6 +128,7 @@ export type {
GlobalSearchConfig,
SearchType,
SortOption,
UseSearchFilterOptions,
UseFetchFunction,
AccessType,
AccessAudience,
Expand Down Expand Up @@ -229,6 +232,7 @@ export {
defaultDataserviceSortOptions,
defaultReuseSortOptions,
defaultOrganizationSortOptions,
useSearchFilter,
}

// Vue Plugin
Expand Down
7 changes: 6 additions & 1 deletion pages/design/dataset-search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@
</h1>

<div class="bg-white py-4 px-4 -mx-4">
<GlobalSearch :config="searchConfig" />
<GlobalSearch :config="searchConfig">
<template #custom-filters="{ currentType }">
<ThemeTagFilter v-if="currentType === 'datasets'" />
</template>
</GlobalSearch>
</div>
</div>
</template>

<script setup lang="ts">
import { GlobalSearch, type GlobalSearchConfig } from '@datagouv/components-next'
import BreadcrumbItem from '~/components/Breadcrumbs/BreadcrumbItem.vue'
import ThemeTagFilter from '~/components/Design/ThemeTagFilter.vue'
const searchConfig: GlobalSearchConfig = [
{
Expand Down
Loading