@datagouv/components-next 1.0.2-dev.225 → 1.0.2-dev.226

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { g as Ke } from "./main-Cv5Nde60.js";
1
+ import { g as Ke } from "./main-DGD6P2pt.js";
2
2
  import We from "vue";
3
3
  function Fe(I, K) {
4
4
  for (var V = 0; V < K.length; V++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datagouv/components-next",
3
- "version": "1.0.2-dev.225",
3
+ "version": "1.0.2-dev.226",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -26,6 +26,7 @@
26
26
  </span>
27
27
  <span
28
28
  v-if="loading || count !== undefined"
29
+ data-testid="radio-count"
29
30
  class="text-xs font-bold px-1 py-0.5 rounded"
30
31
  :class="isSelected && highlighted ? 'bg-white/20 text-white' : 'bg-gray-200 text-gray-600'"
31
32
  >
@@ -34,7 +34,7 @@
34
34
  :key="configKey(typeConfig)"
35
35
  :value="configKey(typeConfig)"
36
36
  :count="resultsMap[configKey(typeConfig)]?.data.value?.total"
37
- :loading="resultsMap[configKey(typeConfig)]?.status.value === 'pending' || resultsMap[configKey(typeConfig)]?.status.value === 'idle'"
37
+ :loading="resultsMap[configKey(typeConfig)]?.countsLoading.value"
38
38
  :icon="typeConfig.icon ?? strategies[typeConfig.class].icon"
39
39
  >
40
40
  {{ typeConfig.name || strategies[typeConfig.class].name }}
@@ -106,28 +106,28 @@
106
106
  v-if="isEnabled('format_family')"
107
107
  v-model="formatFamily"
108
108
  :facets="getFacets('format_family')"
109
- :loading="searchResultsStatus === 'pending'"
109
+ :loading="facetsLoading"
110
110
  :style="{ order: getOrder('format_family') }"
111
111
  />
112
112
  <AccessTypeFilter
113
113
  v-if="isEnabled('access_type')"
114
114
  v-model="accessType"
115
115
  :facets="getFacets('access_type')"
116
- :loading="searchResultsStatus === 'pending'"
116
+ :loading="facetsLoading"
117
117
  :style="{ order: getOrder('access_type') }"
118
118
  />
119
119
  <LastUpdateRangeFilter
120
120
  v-if="isEnabled('last_update_range')"
121
121
  v-model="lastUpdateRange"
122
122
  :facets="getFacets('last_update')"
123
- :loading="searchResultsStatus === 'pending'"
123
+ :loading="facetsLoading"
124
124
  :style="{ order: getOrder('last_update_range') }"
125
125
  />
126
126
  <ProducerTypeFilter
127
127
  v-if="isEnabled('producer_type')"
128
128
  v-model="producerType"
129
129
  :facets="getFacets('producer_type')"
130
- :loading="searchResultsStatus === 'pending'"
130
+ :loading="facetsLoading"
131
131
  :exclude="currentTypeConfig?.class === 'organizations' ? ['user'] : []"
132
132
  :style="{ order: getOrder('producer_type') }"
133
133
  />
@@ -135,14 +135,14 @@
135
135
  v-if="isEnabled('badge')"
136
136
  v-model="badge"
137
137
  :facets="getFacets('badge')"
138
- :loading="searchResultsStatus === 'pending'"
138
+ :loading="facetsLoading"
139
139
  :style="{ order: getOrder('badge') }"
140
140
  />
141
141
  <ReuseTypeFilter
142
142
  v-if="isEnabled('type')"
143
143
  v-model="reuseType"
144
144
  :facets="getFacets('type')"
145
- :loading="searchResultsStatus === 'pending'"
145
+ :loading="facetsLoading"
146
146
  :style="{ order: getOrder('type') }"
147
147
  />
148
148
  <slot
@@ -354,7 +354,7 @@
354
354
  </template>
355
355
 
356
356
  <script setup lang="ts">
357
- import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type Ref } from 'vue'
357
+ import { computed, provide, ref, shallowReactive, useSlots, watch, useTemplateRef, type Component, type ComputedRef, type Ref } from 'vue'
358
358
  import { useRouteQuery } from '@vueuse/router'
359
359
  import { useRoute } from 'vue-router'
360
360
  import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
@@ -365,6 +365,7 @@ import { configKey, forEachActiveCustomFilter, isCustomFilterActive, searchFilte
365
365
  import { useStableQueryParams } from '../../composables/useStableQueryParams'
366
366
  import { useComponentsConfig } from '../../config'
367
367
  import { useFetch } from '../../functions/api'
368
+ import { isEqualExcept } from '../../functions/helpers'
368
369
  import { scrollToBlockTop } from '../../functions/scroll'
369
370
  import type { AsyncDataRequestStatus } from '../../functions/api.types'
370
371
  import type { Dataset } from '../../types/datasets'
@@ -590,15 +591,21 @@ const stableParamsOptions = {
590
591
  sort,
591
592
  page,
592
593
  pageSize,
594
+ currentType,
595
+ }
596
+
597
+ // Refs produced by the fetch, before the counts loading state is attached.
598
+ type SearchResultRefs<C extends SearchType> = {
599
+ class: C
600
+ data: Ref<SearchResponseByClass[C] | null>
601
+ status: Ref<AsyncDataRequestStatus>
593
602
  }
594
603
 
595
604
  // Discriminated union: each variant carries its own response type so a `class`
596
605
  // narrow gives the precise shape of `data.value` (no cast needed).
597
606
  type SearchEntry = {
598
- [K in SearchType]: {
599
- class: K
600
- data: Ref<SearchResponseByClass[K] | null>
601
- status: Ref<AsyncDataRequestStatus>
607
+ [K in SearchType]: SearchResultRefs<K> & {
608
+ countsLoading: ComputedRef<boolean>
602
609
  }
603
610
  }[SearchType]
604
611
 
@@ -612,7 +619,7 @@ type SearchStrategy<C extends SearchType> = {
612
619
  fetch: (
613
620
  params: Ref<Record<string, unknown>>,
614
621
  server: boolean,
615
- ) => Promise<Extract<SearchEntry, { class: C }>>
622
+ ) => Promise<SearchResultRefs<C>>
616
623
  }
617
624
 
618
625
  function makeStrategy<C extends SearchType>(
@@ -626,9 +633,9 @@ function makeStrategy<C extends SearchType>(
626
633
  meta.url,
627
634
  { params, lazy: true, server },
628
635
  )
629
- // Tautologically equivalent to Extract<SearchEntry, { class: C }>, but TS
630
- // cannot prove it on a generic C, so we assert.
631
- return { class: cls, data, status } as Extract<SearchEntry, { class: C }>
636
+ // Tautologically equivalent to SearchResultRefs<C>, but TS cannot prove
637
+ // it on a generic C, so we assert.
638
+ return { class: cls, data, status } as SearchResultRefs<C>
632
639
  },
633
640
  }
634
641
  }
@@ -666,12 +673,37 @@ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
666
673
  }),
667
674
  }
668
675
 
676
+ // `sort`, `page` and `page_size` change which results come back, never how many
677
+ // there are nor how the API aggregates them: a refetch that touches only those
678
+ // cannot change a single count we display.
679
+ const COUNT_INVARIANT_PARAMS = ['sort', 'page', 'page_size']
680
+
669
681
  // One params + fetch per config entry, keyed by configKey
670
682
  const resultsMap: Record<string, SearchEntry> = {}
671
683
  for (const c of props.config) {
672
684
  const key = configKey(c)
673
685
  const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
674
- resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
686
+ const result = await strategies[c.class].fetch(params, initialType === key)
687
+
688
+ // A reload that only moves sort or pagination leaves every count where it is,
689
+ // so it must not replace them with loading dots. The comparison is against the
690
+ // params the displayed counts came from, so that two changes in a row cannot
691
+ // clear the loading state owed to the first one. `data` already holds a value
692
+ // for the type rendered on the server; no applied params means the initial
693
+ // load, which does need its loading state.
694
+ const appliedParams = ref(result.data.value ? params.value : null)
695
+ watch(result.data, () => {
696
+ appliedParams.value = params.value
697
+ })
698
+
699
+ const countsCanChange = computed(() => !appliedParams.value
700
+ || !isEqualExcept(params.value, appliedParams.value, COUNT_INVARIANT_PARAMS))
701
+
702
+ const countsLoading = computed(() => countsCanChange.value
703
+ && (result.status.value === 'pending' || result.status.value === 'idle'))
704
+
705
+ // Same generic-C limitation as in makeStrategy: the union is narrowed by key.
706
+ resultsMap[key] = { ...result, countsLoading } as SearchEntry
675
707
  }
676
708
 
677
709
  // Reset page on filter/sort change. Custom filters (registered via
@@ -792,6 +824,7 @@ const rssUrl = computed(() => {
792
824
 
793
825
  // Facets for filters
794
826
  const currentFacets = computed(() => searchResults.value?.facets)
827
+ const facetsLoading = computed(() => resultsMap[currentType.value]?.countsLoading.value ?? false)
795
828
 
796
829
  function getFacets(key: string): FacetItem[] | undefined {
797
830
  if (!currentFacets.value) return undefined
@@ -12,18 +12,23 @@ interface StableQueryParamsOptions {
12
12
  sort: Ref<string | undefined>
13
13
  page: Ref<number>
14
14
  pageSize: number
15
+ currentType: Ref<string>
15
16
  }
16
17
 
17
18
  /**
18
19
  * Creates a stable ref for query params that only updates when content actually changes.
19
20
  * Applies hiddenFilters first, then user filters (which can override hiddenFilters).
21
+ * Sort and pagination are only sent for the displayed type, so changing them
22
+ * refetches that type alone.
20
23
  */
21
24
  export function useStableQueryParams(options: StableQueryParamsOptions) {
22
- const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options
25
+ const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize, currentType } = options
23
26
  const stableParams = ref<Record<string, unknown>>({})
24
27
 
25
28
  const buildParams = () => {
26
29
  const params: Record<string, unknown> = {}
30
+ const typeKey = typeConfig ? configKey(typeConfig) : undefined
31
+ const isDisplayedType = typeKey === undefined || typeKey === currentType.value
27
32
 
28
33
  // 1. Apply hiddenFilters first (can be overridden by user filters)
29
34
  if (typeConfig?.hiddenFilters) {
@@ -57,7 +62,6 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
57
62
  // combines with an existing built-in value instead of overwriting it.
58
63
  // Pass the current type key so filters scoped to specific types are excluded
59
64
  // from background fetches for other types.
60
- const currentTypeKey = typeConfig ? configKey(typeConfig) : undefined
61
65
  forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
62
66
  const existing = params[apiParam]
63
67
  if (existing === undefined) {
@@ -66,24 +70,31 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
66
70
  else {
67
71
  params[apiParam] = Array.isArray(existing) ? [...existing, value] : [existing, value]
68
72
  }
69
- }, currentTypeKey)
73
+ }, typeKey)
70
74
 
71
- // 4. Always include q, sort (if valid for this type), page, page_size
75
+ // 4. Always include q
72
76
  if (q.value) {
73
77
  params.q = q.value
74
78
  }
75
- const sortToUse = sort.value ?? typeConfig?.defaultSort
76
- if (sortToUse) {
77
- const validSortValues = typeConfig?.sortOptions?.map(o => o.value as string) ?? []
78
- if (validSortValues.includes(sortToUse)) {
79
- params.sort = sortToUse
80
- }
81
- else if (import.meta.env.DEV && typeConfig?.defaultSort && typeConfig?.sortOptions && sortToUse === typeConfig.defaultSort) {
82
- console.warn(`[GlobalSearch] defaultSort "${typeConfig.defaultSort}" is not in sortOptions for "${typeConfig.class}". Valid values: ${validSortValues.join(', ')}`)
79
+
80
+ // 5. Sort and pagination only apply to the displayed type. The other tabs
81
+ // are fetched for their `total` alone, which is invariant under sort and
82
+ // page, so sending those there would refetch every tab on each sort or
83
+ // page change. They ask for a single result for the same reason.
84
+ if (isDisplayedType) {
85
+ const sortToUse = sort.value ?? typeConfig?.defaultSort
86
+ if (sortToUse) {
87
+ const validSortValues = typeConfig?.sortOptions?.map(o => o.value as string) ?? []
88
+ if (validSortValues.includes(sortToUse)) {
89
+ params.sort = sortToUse
90
+ }
91
+ else if (import.meta.env.DEV && typeConfig?.defaultSort && typeConfig?.sortOptions && sortToUse === typeConfig.defaultSort) {
92
+ console.warn(`[GlobalSearch] defaultSort "${typeConfig.defaultSort}" is not in sortOptions for "${typeConfig.class}". Valid values: ${validSortValues.join(', ')}`)
93
+ }
83
94
  }
95
+ params.page = page.value
84
96
  }
85
- params.page = page.value
86
- params.page_size = pageSize
97
+ params.page_size = isDisplayedType ? pageSize : 1
87
98
 
88
99
  return params
89
100
  }
@@ -100,7 +111,7 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
100
111
 
101
112
  // Watch all dependencies and update only if content changed
102
113
  watch(
103
- [q, sort, page, ...Object.values(allFilters), customFilterValues],
114
+ [q, sort, page, currentType, ...Object.values(allFilters), customFilterValues],
104
115
  () => {
105
116
  const newParams = buildParams()
106
117
  // JSON.stringify comparison is safe here because buildParams() builds the object deterministically
@@ -53,6 +53,16 @@ export const isSafeHttpUrl = (value: unknown): value is string => {
53
53
  }
54
54
  }
55
55
 
56
+ export const removeKeys = <T extends Record<string, unknown>>(object: T, keys: Array<string>): Partial<T> => {
57
+ return Object.fromEntries(
58
+ Object.entries(object).filter(([key]) => !keys.includes(key)),
59
+ ) as Partial<T>
60
+ }
61
+
62
+ export const isEqualExcept = <T extends Record<string, unknown>>(a: T, b: T, keys: Array<string>): boolean => {
63
+ return JSON.stringify(removeKeys(a, keys)) === JSON.stringify(removeKeys(b, keys))
64
+ }
65
+
56
66
  export const escapeCsvValue = (value: string | number | null | undefined): string => {
57
67
  if (value === null || value === undefined || value === '') {
58
68
  return ''