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
55 changes: 36 additions & 19 deletions datagouv-components/src/components/Search/GlobalSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,43 +107,43 @@
v-if="isEnabled('format_family')"
v-model="formatFamily"
:facets="getFacets('format_family')"
:loading="searchResultsStatus === 'pending'"
:loading="facetsLoading"
:style="{ order: getOrder('format_family') }"
/>
<AccessTypeFilter
v-if="isEnabled('access_type')"
v-model="accessType"
:facets="getFacets('access_type')"
:loading="searchResultsStatus === 'pending'"
:loading="facetsLoading"
:style="{ order: getOrder('access_type') }"
/>
<LastUpdateRangeFilter
v-if="isEnabled('last_update_range')"
v-model="lastUpdateRange"
:facets="getFacets('last_update')"
:loading="searchResultsStatus === 'pending'"
:loading="facetsLoading"
:style="{ order: getOrder('last_update_range') }"
/>
<ProducerTypeFilter
v-if="isEnabled('producer_type')"
v-model="producerType"
:facets="getFacets('producer_type')"
:loading="searchResultsStatus === 'pending'"
:loading="facetsLoading"
:exclude="currentTypeConfig?.class === 'organizations' ? ['user'] : []"
:style="{ order: getOrder('producer_type') }"
/>
<DatasetBadgeFilter
v-if="isEnabled('badge')"
v-model="badge"
:facets="getFacets('badge')"
:loading="searchResultsStatus === 'pending'"
:loading="facetsLoading"
:style="{ order: getOrder('badge') }"
/>
<ReuseTypeFilter
v-if="isEnabled('type')"
v-model="reuseType"
:facets="getFacets('type')"
:loading="searchResultsStatus === 'pending'"
:loading="facetsLoading"
:style="{ order: getOrder('type') }"
/>
<slot
Expand Down Expand Up @@ -355,14 +355,15 @@
</template>

<script setup lang="ts">
import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type Ref } from 'vue'
import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type ComputedRef, 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 { configKey, forEachActiveCustomFilter, isCustomFilterActive, searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
import { useStableQueryParams } from '../../composables/useStableQueryParams'
import { useStableFacets } from '../../composables/useStableFacets'
import { useComponentsConfig } from '../../config'
import { useFetch } from '../../functions/api'
import type { AsyncDataRequestStatus } from '../../functions/api.types'
Expand Down Expand Up @@ -590,13 +591,19 @@ const stableParamsOptions = {
pageSize,
}

// Refs produced by the fetch, before facets stabilization is attached.
type SearchResultRefs<C extends SearchType> = {
class: C
data: Ref<SearchResponseByClass[C] | null>
status: Ref<AsyncDataRequestStatus>
}

// Discriminated union: each variant carries its own response type so a `class`
// narrow gives the precise shape of `data.value` (no cast needed).
type SearchEntry = {
[K in SearchType]: {
class: K
data: Ref<SearchResponseByClass[K] | null>
status: Ref<AsyncDataRequestStatus>
[K in SearchType]: SearchResultRefs<K> & {
facets: ComputedRef<SearchResponseByClass[K]['facets'] | null>
facetsLoading: ComputedRef<boolean>
}
}[SearchType]

Expand All @@ -610,7 +617,7 @@ type SearchStrategy<C extends SearchType> = {
fetch: (
params: Ref<Record<string, unknown>>,
server: boolean,
) => Promise<Extract<SearchEntry, { class: C }>>
) => Promise<SearchResultRefs<C>>
}

function makeStrategy<C extends SearchType>(
Expand All @@ -624,9 +631,9 @@ function makeStrategy<C extends SearchType>(
meta.url,
{ params, lazy: true, server },
)
// Tautologically equivalent to Extract<SearchEntry, { class: C }>, but TS
// cannot prove it on a generic C, so we assert.
return { class: cls, data, status } as Extract<SearchEntry, { class: C }>
// Tautologically equivalent to SearchResultRefs<C>, but TS cannot prove
// it on a generic C, so we assert.
return { class: cls, data, status } as SearchResultRefs<C>
},
}
}
Expand Down Expand Up @@ -668,8 +675,15 @@ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
const resultsMap: Record<string, SearchEntry> = {}
for (const c of props.config) {
const key = configKey(c)
const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
const { params, facetParams } = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
const result = await strategies[c.class].fetch(params, initialType === key)
const { facets, loading: facetsLoading } = useStableFacets({
data: result.data,
status: result.status,
facetParams,
})
// Same generic-C limitation as in makeStrategy: the union is narrowed by key.
resultsMap[key] = { ...result, facets, facetsLoading } as SearchEntry
}

// Reset page on filter/sort change. Custom filters (registered via
Expand Down Expand Up @@ -788,8 +802,11 @@ const rssUrl = computed(() => {
return `${componentsConfig.apiBase}${basePath}${queryString ? '?' + queryString : ''}`
})

// Facets for filters
const currentFacets = computed(() => searchResults.value?.facets)
// Facets for filters. Stabilized per type by useStableFacets: sort and page
// changes refetch the results but keep the previous facets, so the facet
// filters neither flash their loading state nor re-render their counts.
const currentFacets = computed(() => resultsMap[currentType.value]?.facets.value ?? undefined)
const facetsLoading = computed(() => resultsMap[currentType.value]?.facetsLoading.value ?? false)

function getFacets(key: string): FacetItem[] | undefined {
if (!currentFacets.value) return undefined
Expand Down
46 changes: 46 additions & 0 deletions datagouv-components/src/composables/useStableFacets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { computed, shallowRef, watch, type ComputedRef, type Ref } from 'vue'
import type { AsyncDataRequestStatus } from '../functions/api.types'

interface StableFacetsOptions<F> {
data: Ref<{ facets: F } | null>
status: Ref<AsyncDataRequestStatus>
facetParams: Ref<Record<string, unknown>>
}

interface StableFacets<F> {
facets: ComputedRef<F | null>
loading: ComputedRef<boolean>
}

/**
* Keeps facets stable across refetches that cannot change them (sort/page
* changes): the cached facets object is kept instead of the new response's
* copy, so facet filters neither re-render their counts nor flash loading.
*/
export function useStableFacets<F>(options: StableFacetsOptions<F>): StableFacets<F> {
const { data, status, facetParams } = options
const cachedFacets = shallowRef<F | null>(null)
// facetParams only gets a new identity when its content changes
const appliedFacetParams = shallowRef<Record<string, unknown> | null>(null)

// Cache a response only if it answers the current facetParams.
// Sort/page refetch: facetParams identity is unchanged and the new response
// is a copy of the same aggregations — skip it to keep facets stable.
watch(data, (results) => {
if (!results) return
if (facetParams.value !== appliedFacetParams.value) {
cachedFacets.value = results.facets
appliedFacetParams.value = facetParams.value
}
}, { immediate: true })

const facets = computed(() => cachedFacets.value)

// A change to a facet-affecting param updates facetParams and triggers a new fetch.
// While that fetch is pending, facetParams is ahead of the cached facets.
const loading = computed(() =>
status.value === 'pending' && facetParams.value !== appliedFacetParams.value,
)

return { facets, loading }
}
16 changes: 14 additions & 2 deletions datagouv-components/src/composables/useStableQueryParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ interface StableQueryParamsOptions {
}

/**
* Creates a stable ref for query params that only updates when content actually changes.
* Creates stable refs for query params that only update when content actually changes.
* Applies hiddenFilters first, then user filters (which can override hiddenFilters).
* `facetParams` is the same params minus `sort` and `page`, which do not affect
* facet aggregations.
*/
export function useStableQueryParams(options: StableQueryParamsOptions) {
const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options
const stableParams = ref<Record<string, unknown>>({})
// Same params minus sort and page
const stableFacetParams = ref<Record<string, unknown>>({})

const buildParams = () => {
const params: Record<string, unknown> = {}
Expand Down Expand Up @@ -108,9 +112,17 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
if (JSON.stringify(newParams) !== JSON.stringify(stableParams.value)) {
stableParams.value = newParams
}

const newFacetParams = { ...newParams }
delete newFacetParams.sort
delete newFacetParams.page
delete newFacetParams.page_size
if (JSON.stringify(newFacetParams) !== JSON.stringify(stableFacetParams.value)) {
stableFacetParams.value = newFacetParams
}
},
{ immediate: true },
)

return stableParams
return { params: stableParams, facetParams: stableFacetParams }
}
70 changes: 70 additions & 0 deletions tests-unit/datagouv-components/stable-facets.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { nextTick, ref, shallowRef } from 'vue'
import { useStableFacets } from '~/datagouv-components/src/composables/useStableFacets'
import type { AsyncDataRequestStatus } from '~/datagouv-components/src/functions/api.types'

type Facets = Record<string, { name: string, count: number }[]>

function setup() {
// useFetch (the real data source) stores its payload in a shallowRef: facet
// objects keep their identity, which is what the identity assertions below
// rely on. A deep ref would wrap facets in reactive proxies.
const data = shallowRef<{ facets: Facets } | null>(null)
const status = ref<AsyncDataRequestStatus>('idle')
const facetParams = ref<Record<string, unknown>>({})
return { data, status, facetParams, ...useStableFacets<Facets>({ data, status, facetParams }) }
}

describe('useStableFacets', () => {
it('exposes null facets before the first response', () => {
const { facets } = setup()
expect(facets.value).toBeNull()
})

it('exposes the facets of the first response', async () => {
const { data, facets } = setup()
const responseFacets = { format_family: [{ name: 'tabular', count: 3 }] }
data.value = { facets: responseFacets }
await nextTick()
expect(facets.value).toBe(responseFacets)
})

it('keeps the previous facets when a refetch only changed sort or page', async () => {
const { data, status, facets, loading } = setup()
const responseFacets = { format_family: [{ name: 'tabular', count: 3 }] }
data.value = { facets: responseFacets }
await nextTick()

// facetParams identity is unchanged (only sort/page changed)
status.value = 'pending'
await nextTick()
expect(loading.value).toBe(false)

data.value = { facets: { format_family: [{ name: 'tabular', count: 3 }] } }
status.value = 'success'
await nextTick()
expect(facets.value).toBe(responseFacets)
})

it('recomputes facets when facet params change', async () => {
const { data, status, facetParams, facets, loading } = setup()
const initialFacets = { badge: [{ name: 'x', count: 1 }] }
data.value = { facets: initialFacets }
await nextTick()

// A filter change gives facetParams a new identity (see useStableQueryParams)
facetParams.value = { tag: 'energy' }
status.value = 'pending'
await nextTick()
expect(loading.value).toBe(true)
// The previous facets stay displayed while the fetch is in flight
expect(facets.value).toBe(initialFacets)

const newFacets = { badge: [{ name: 'y', count: 2 }] }
data.value = { facets: newFacets }
status.value = 'success'
await nextTick()
expect(facets.value).toBe(newFacets)
expect(loading.value).toBe(false)
})
})
66 changes: 66 additions & 0 deletions tests-unit/datagouv-components/stable-query-params.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { useStableQueryParams } from '~/datagouv-components/src/composables/useStableQueryParams'
import type { SearchTypeConfig } from '~/datagouv-components/src/types/search'

function setup() {
const q = ref('')
const sort = ref<string | undefined>(undefined)
const page = ref(1)
const tag = ref<string | undefined>(undefined)
const typeConfig: SearchTypeConfig = {
class: 'datasets',
basicFilters: ['tag'],
sortOptions: [{ value: '-created', label: 'Plus récents' }],
}
const { params, facetParams } = useStableQueryParams({
typeConfig,
allFilters: { tag },
customFilterRegistry: new Map(),
q,
sort,
page,
pageSize: 20,
})
return { q, sort, page, tag, params, facetParams }
}

describe('useStableQueryParams', () => {
it('excludes sort and page from facetParams', () => {
const { params, facetParams } = setup()

expect(params.value).toEqual({ page: 1, page_size: 20 })
expect(facetParams.value).toEqual({})
})

it('keeps facetParams identity stable when only sort or page change', async () => {
const { sort, page, params, facetParams } = setup()
const before = facetParams.value

sort.value = '-created'
await nextTick()
expect(params.value.sort).toBe('-created')
expect(facetParams.value).toBe(before)

page.value = 2
await nextTick()
expect(params.value.page).toBe(2)
expect(facetParams.value).toBe(before)
})

it('updates facetParams when a facet-affecting param changes', async () => {
const { q, tag, facetParams } = setup()
const before = facetParams.value

tag.value = 'energy'
await nextTick()
expect(facetParams.value).toEqual({ tag: 'energy' })
expect(facetParams.value).not.toBe(before)

const afterTag = facetParams.value
q.value = 'test'
await nextTick()
expect(facetParams.value).toEqual({ tag: 'energy', q: 'test' })
expect(facetParams.value).not.toBe(afterTag)
})
})
Loading