Skip to content
Open
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
4 changes: 0 additions & 4 deletions assets/css/overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@
@apply !bg-none underline underline-offset-4 hover:decoration-2 text-new-primary;
}

.input {
@apply block w-full rounded-t text-base leading-6 py-2 px-4 text-gray-plain bg-gray-lower placeholder:opacity-100 placeholder:italic placeholder:text-gray-medium;
}

.close-button {
@apply w-10 leading-0 text-lg;
}
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 in URL → tag=environnement in API
const value = useSearchFilter('theme', { apiParam: 'tag' })
</script>
4 changes: 4 additions & 0 deletions datagouv-components/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@
}

@layer components {
.input {
@apply block w-full rounded-t text-base leading-6 py-2 px-4 text-gray-plain bg-gray-lower! placeholder:opacity-100 placeholder:italic placeholder:text-gray-medium;
}

.subtitle {
@apply text-sm! font-bold! leading-5!;
}
Expand Down
80 changes: 59 additions & 21 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 || $slots['custom-filters']">
<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, useSlots, 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 slots = useSlots()
const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters'])

// 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 @@ -548,24 +567,30 @@ const organizationsUrl = computed(() => organizationsEnabled.value ? '/api/2/org
const topicsUrl = computed(() => topicsEnabled.value ? '/api/2/topics/search/' : null)

// Reset page on filter/sort change
const filtersForReset = computed(() => ({
q: qDebounced.value,
organization: organizationId.value,
organization_badge: organizationType.value,
tag: tag.value,
format: format.value,
license: license.value,
schema: schema.value,
geozone: geozone.value,
granularity: granularity.value,
badge: badge.value,
topic: topic.value,
format_family: formatFamily.value,
access_type: accessType.value,
last_update_range: lastUpdateRange.value,
producer_type: producerType.value,
type: reuseType.value,
}))
const filtersForReset = computed(() => {
const filters: Record<string, unknown> = {
q: qDebounced.value,
organization: organizationId.value,
organization_badge: organizationType.value,
tag: tag.value,
format: format.value,
license: license.value,
schema: schema.value,
geozone: geozone.value,
granularity: granularity.value,
badge: badge.value,
topic: topic.value,
format_family: formatFamily.value,
access_type: accessType.value,
last_update_range: lastUpdateRange.value,
producer_type: producerType.value,
type: reuseType.value,
}
for (const [urlParam, entry] of customFilterRegistry) {
filters[urlParam] = entry.ref.value
}
return filters
})

watch(filtersForReset, () => page.value = 1)
watch(sort, () => page.value = 1)
Expand All @@ -587,6 +612,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 +635,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 +733,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
70 changes: 70 additions & 0 deletions datagouv-components/src/composables/useSearchFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { type InjectionKey, type Ref, inject, onMounted, 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)

// Register in onMounted to avoid SSR/hydration mismatch: the registry must be
// empty during SSR so server and client produce the same initial HTML.
onMounted(() => {
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
Loading
Loading