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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<template>
<SearchableSelect
:model-value="selectedOption"
:options="options"
:loading="loading"
:label="t('Organisation')"
:placeholder="t('Toutes les organisations')"
:get-option-id="(opt) => opt.value"
:display-value="(opt) => opt.label"
:multiple="false"
@update:model-value="emit('update:modelValue', $event?.value ?? undefined)"
/>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { FacetItem } from '../../../types/search'
import { useTranslation } from '../../../composables/useTranslation'
import SearchableSelect from '../../Form/SearchableSelect.vue'

const props = defineProps<{
modelValue: string | undefined
facets?: FacetItem[]
loading?: boolean
}>()

const emit = defineEmits<{
'update:modelValue': [value: string | undefined]
}>()

const { t } = useTranslation()

// organization_id_with_name facet name format: "org-slug|Org Display Name"
const options = computed(() =>
(props.facets ?? [])
.map((f) => {
const [id = '', ...labelParts] = f.name.split('|')
return { value: id, label: labelParts.join('|') }
})
.filter(o => o.value !== '' && o.label !== ''),
)

const selectedOption = computed(() =>
options.value.find(o => o.value === props.modelValue) ?? null,
)
</script>
89 changes: 68 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 @@ -140,12 +140,23 @@
:loading="searchResultsStatus === 'pending'"
:style="{ order: getOrder('type') }"
/>
<OrganizationFacetFilter
v-if="isEnabled('organization_facet')"
v-model="organizationId"
:facets="getFacets('organization_id_with_name')"
:loading="searchResultsStatus === 'pending'"
:style="{ order: getOrder('organization_facet') }"
/>
<slot
name="filters"
:is-enabled="isEnabled"
: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 +351,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 @@ -384,6 +396,7 @@ import LastUpdateRangeFilter from './Filter/LastUpdateRangeFilter.vue'
import ProducerTypeFilter from './Filter/ProducerTypeFilter.vue'
import DatasetBadgeFilter from './Filter/DatasetBadgeFilter.vue'
import ReuseTypeFilter from './Filter/ReuseTypeFilter.vue'
import OrganizationFacetFilter from './Filter/OrganizationFacetFilter.vue'

const props = withDefaults(defineProps<{
config?: GlobalSearchConfig
Expand All @@ -399,6 +412,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 +464,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 @@ -471,6 +497,7 @@ const pageSize = 20
// All filter values as a record
const allFilters: Record<string, Ref<unknown>> = {
organization: organizationId,
organization_facet: organizationId,
organization_badge: organizationType,
tag,
format,
Expand Down Expand Up @@ -513,6 +540,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 +576,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 +621,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 +644,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 +742,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
}
Loading
Loading