@datagouv/components-next 1.0.2-dev.20 → 1.0.2-dev.200

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.
Files changed (165) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-CFTmBFoY.js} +9 -9
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-BvDM3qjE.js +36 -0
  8. package/dist/JsonPreview.client-BP6gPRxf.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-D9N3664r.js} +41 -42
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-C6A6ZiKl.js} +835 -856
  13. package/dist/Pmtiles.client-B_sDZoqu.js +26333 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-DGLXBys_.js +60 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-DFQ1T-LN.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +196 -150
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-B_tPYi5Z.js} +34094 -27864
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-PeylHk0o.js} +162 -164
  26. package/dist/{main-vqhkVhVS.js → main-CVOZp_Nx.js} +41939 -73132
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-CcF3Xd23.js} +1 -1
  32. package/package.json +47 -38
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +24 -14
  35. package/src/components/AppLink.vue +3 -0
  36. package/src/components/BannerAction.vue +6 -2
  37. package/src/components/Chart/ChartViewer.vue +320 -0
  38. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  39. package/src/components/CopyButton.vue +6 -2
  40. package/src/components/DataserviceCard.vue +24 -5
  41. package/src/components/DataserviceQuality.vue +82 -0
  42. package/src/components/DataserviceQualityInline.vue +31 -0
  43. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  44. package/src/components/DatasetCard.vue +24 -9
  45. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
  46. package/src/components/DatasetQuality.vue +11 -11
  47. package/src/components/DatasetQualityInline.vue +2 -2
  48. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  49. package/src/components/DiscussionMessageCard.vue +14 -15
  50. package/src/components/Form/Listbox.vue +101 -0
  51. package/src/components/Form/SchemaSelect.vue +2 -0
  52. package/src/components/FormattedDate.vue +81 -0
  53. package/src/components/InfiniteLoader.vue +53 -0
  54. package/src/components/LoadingBlock.vue +4 -1
  55. package/src/components/ObjectCardHeader.vue +11 -4
  56. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  57. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  58. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  59. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  60. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  61. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  62. package/src/components/OpenApiViewer/openapi.ts +150 -0
  63. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  64. package/src/components/Pagination.vue +25 -62
  65. package/src/components/PostCard.vue +13 -15
  66. package/src/components/RadioInput.vue +7 -2
  67. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  68. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  69. package/src/components/ResourceAccordion/Downloads.vue +172 -0
  70. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  71. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  72. package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
  73. package/src/components/ResourceAccordion/Metadata.vue +4 -6
  74. package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
  75. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  76. package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
  77. package/src/components/ResourceAccordion/Preview.vue +19 -15
  78. package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
  79. package/src/components/ResourceAccordion/ResourceAccordion.vue +36 -117
  80. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  81. package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
  82. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
  83. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
  84. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
  85. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  86. package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
  87. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +122 -0
  88. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  89. package/src/components/ResourceListItem.vue +168 -0
  90. package/src/components/ReuseCard.vue +28 -9
  91. package/src/components/ReuseDetails.vue +2 -2
  92. package/src/components/ReuseHorizontalCard.vue +2 -2
  93. package/src/components/SchemaCard.vue +10 -1
  94. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  95. package/src/components/Search/GlobalSearch.vue +229 -123
  96. package/src/components/Search/SearchInput.vue +5 -4
  97. package/src/components/Search/Sidemenu.vue +4 -3
  98. package/src/components/SmallChart.vue +11 -10
  99. package/src/components/StatBox.vue +13 -5
  100. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  101. package/src/components/TabularExplorer/TabularCell.vue +50 -0
  102. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  103. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  104. package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
  105. package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
  106. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  107. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  108. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  109. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  110. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  111. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  112. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  113. package/src/components/TabularExplorer/types.ts +84 -0
  114. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  115. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  116. package/src/components/TopicCard.vue +13 -14
  117. package/src/composables/useActiveDescendant.ts +5 -0
  118. package/src/composables/useDatasetResources.ts +164 -0
  119. package/src/composables/useHasTabularData.ts +15 -1
  120. package/src/composables/useMetrics.ts +1 -1
  121. package/src/composables/useResizable.ts +46 -0
  122. package/src/composables/useResourceCapabilities.ts +48 -40
  123. package/src/composables/useSearchFilter.ts +120 -0
  124. package/src/composables/useStableQueryParams.ts +38 -6
  125. package/src/composables/useTabularProfile.ts +70 -0
  126. package/src/composables/useTranslation.ts +24 -9
  127. package/src/config.ts +21 -3
  128. package/src/functions/activities.ts +3 -3
  129. package/src/functions/api.ts +20 -42
  130. package/src/functions/api.types.ts +1 -0
  131. package/src/functions/charts.ts +99 -0
  132. package/src/functions/datasets.ts +0 -17
  133. package/src/functions/dates.ts +6 -7
  134. package/src/functions/helpers.ts +15 -0
  135. package/src/functions/metrics.ts +63 -60
  136. package/src/functions/paginate.ts +46 -0
  137. package/src/functions/resourceCapabilities.ts +34 -0
  138. package/src/functions/resources.ts +136 -1
  139. package/src/functions/schemas.ts +27 -0
  140. package/src/functions/scroll.ts +20 -0
  141. package/src/functions/tabular.ts +214 -0
  142. package/src/functions/tabularApi.ts +96 -11
  143. package/src/main.ts +119 -14
  144. package/src/types/dataservices.ts +17 -0
  145. package/src/types/organizations.ts +10 -0
  146. package/src/types/pages.ts +0 -5
  147. package/src/types/posts.ts +2 -2
  148. package/src/types/reports.ts +5 -1
  149. package/src/types/resources.ts +0 -1
  150. package/src/types/reuses.ts +12 -1
  151. package/src/types/search.ts +63 -1
  152. package/src/types/site.ts +5 -3
  153. package/src/types/ui.ts +2 -0
  154. package/src/types/users.ts +2 -1
  155. package/src/types/visualizations.ts +93 -0
  156. package/assets/swagger-themes/newspaper.css +0 -1670
  157. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  158. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  159. package/dist/Swagger.client-B4y0hXME.js +0 -4
  160. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  161. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  162. package/src/functions/pagination.ts +0 -9
  163. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  164. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  165. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -5,13 +5,14 @@
5
5
  @submit.prevent
6
6
  >
7
7
  <div
8
- ref="search"
8
+ v-if="!hideSearchInput"
9
9
  class="flex flex-wrap items-center justify-between"
10
10
  data-cy="search"
11
11
  >
12
12
  <SearchInput
13
13
  v-model="q"
14
- :placeholder="placeholder || typesMeta[currentType].placeholder"
14
+ :placeholder="resolvedPlaceholder"
15
+ :auto-focus
15
16
  />
16
17
  </div>
17
18
  <div class="grid grid-cols-12 mt-2 md:mt-5">
@@ -30,23 +31,27 @@
30
31
  >
31
32
  <RadioInput
32
33
  v-for="typeConfig in config"
33
- :key="typeConfig.class"
34
- :value="typeConfig.class"
35
- :count="typesMeta[typeConfig.class].results.value?.total"
36
- :loading="typesMeta[typeConfig.class].status.value === 'pending' || typesMeta[typeConfig.class].status.value === 'idle'"
37
- :icon="typesMeta[typeConfig.class].icon"
34
+ :key="configKey(typeConfig)"
35
+ :value="configKey(typeConfig)"
36
+ :count="resultsMap[configKey(typeConfig)]?.data.value?.total"
37
+ :loading="resultsMap[configKey(typeConfig)]?.status.value === 'pending' || resultsMap[configKey(typeConfig)]?.status.value === 'idle'"
38
+ :icon="typeConfig.icon ?? strategies[typeConfig.class].icon"
38
39
  >
39
- {{ typeConfig.name || typesMeta[typeConfig.class].name }}
40
+ {{ typeConfig.name || strategies[typeConfig.class].name }}
40
41
  </RadioInput>
41
42
  </RadioGroup>
42
43
  </Sidemenu>
43
44
  </div>
44
45
 
45
- <div v-if="activeFilters.length > 0">
46
+ <div v-if="activeFilters.length > 0 || $slots['custom-filters-top'] || $slots['custom-filters-bottom']">
46
47
  <Sidemenu :button-text="t('Filtres')">
47
48
  <template #title>
48
49
  {{ t('Filtres') }}
49
50
  </template>
51
+ <slot
52
+ name="custom-filters-top"
53
+ :current-type="currentType"
54
+ />
50
55
  <BasicAndAdvancedFilters
51
56
  v-slot="{ isEnabled, getOrder }"
52
57
  :basic-filters="activeBasicFilters"
@@ -123,7 +128,7 @@
123
128
  v-model="producerType"
124
129
  :facets="getFacets('producer_type')"
125
130
  :loading="searchResultsStatus === 'pending'"
126
- :exclude="currentType === 'organizations' ? ['user'] : []"
131
+ :exclude="currentTypeConfig?.class === 'organizations' ? ['user'] : []"
127
132
  :style="{ order: getOrder('producer_type') }"
128
133
  />
129
134
  <DatasetBadgeFilter
@@ -146,6 +151,10 @@
146
151
  :get-order="getOrder"
147
152
  />
148
153
  </BasicAndAdvancedFilters>
154
+ <slot
155
+ name="custom-filters-bottom"
156
+ :current-type="currentType"
157
+ />
149
158
  <div
150
159
  v-if="hasFilters"
151
160
  class="mt-6 text-center"
@@ -175,6 +184,7 @@
175
184
  <p
176
185
  class="fr-col-auto my-0"
177
186
  role="status"
187
+ data-testid="search-result-count"
178
188
  >
179
189
  {{ t("{count} résultats | {count} résultat | {count} résultats", searchResults.total) }}
180
190
  </p>
@@ -189,10 +199,13 @@
189
199
  <div class="fr-col">
190
200
  <select
191
201
  id="sort-search"
192
- v-model="sort"
202
+ v-model="effectiveSort"
193
203
  class="fr-select text-sm shadow-input-blue!"
194
204
  >
195
- <option :value="undefined">
205
+ <option
206
+ v-if="!currentTypeConfig?.defaultSort"
207
+ :value="undefined"
208
+ >
196
209
  {{ t('Pertinence') }}
197
210
  </option>
198
211
  <option
@@ -214,6 +227,7 @@
214
227
  size="sm"
215
228
  :icon="RiRssLine"
216
229
  icon-only
230
+ external
217
231
  target="_blank"
218
232
  />
219
233
  </div>
@@ -221,7 +235,7 @@
221
235
  <transition mode="out-in">
222
236
  <LoadingBlock
223
237
  v-slot="{ data: results }"
224
- :status="searchResultsStatus"
238
+ :status="searchResultsStatus!"
225
239
  :data="searchResults"
226
240
  >
227
241
  <div v-if="results && results.data.length">
@@ -231,7 +245,7 @@
231
245
  :key="result.id"
232
246
  class="p-0"
233
247
  >
234
- <template v-if="currentType === 'datasets'">
248
+ <template v-if="currentTypeConfig?.class === 'datasets'">
235
249
  <slot
236
250
  name="dataset"
237
251
  :dataset="result"
@@ -239,7 +253,7 @@
239
253
  <DatasetCard :dataset="(result as Dataset)" />
240
254
  </slot>
241
255
  </template>
242
- <template v-else-if="currentType === 'dataservices'">
256
+ <template v-else-if="currentTypeConfig?.class === 'dataservices'">
243
257
  <slot
244
258
  name="dataservice"
245
259
  :dataservice="result"
@@ -247,15 +261,15 @@
247
261
  <DataserviceCard :dataservice="(result as Dataservice)" />
248
262
  </slot>
249
263
  </template>
250
- <template v-else-if="currentType === 'reuses'">
264
+ <template v-else-if="currentTypeConfig?.class === 'reuses'">
251
265
  <slot
252
266
  name="reuse"
253
267
  :reuse="result"
254
268
  >
255
- <ReuseHorizontalCard :reuse="(result as Reuse)" />
269
+ <ReuseHorizontalCard :reuse="(result as ReuseV2)" />
256
270
  </slot>
257
271
  </template>
258
- <template v-else-if="currentType === 'organizations'">
272
+ <template v-else-if="currentTypeConfig?.class === 'organizations'">
259
273
  <slot
260
274
  name="organization"
261
275
  :organization="result"
@@ -263,6 +277,14 @@
263
277
  <OrganizationHorizontalCard :organization="(result as Organization)" />
264
278
  </slot>
265
279
  </template>
280
+ <template v-else-if="currentTypeConfig?.class === 'topics'">
281
+ <slot
282
+ name="topic"
283
+ :topic="result"
284
+ >
285
+ <TopicCard :topic="(result as TopicV2)" />
286
+ </slot>
287
+ </template>
266
288
  </li>
267
289
  </ul>
268
290
  <Pagination
@@ -271,7 +293,6 @@
271
293
  :page-size
272
294
  :total-results="results.total"
273
295
  class="mt-4"
274
- :link="getLink"
275
296
  @change="changePage"
276
297
  />
277
298
  </div>
@@ -333,21 +354,25 @@
333
354
  </template>
334
355
 
335
356
  <script setup lang="ts">
336
- import { computed, watch, useTemplateRef, type Ref } from 'vue'
357
+ import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type Ref } from 'vue'
337
358
  import { useRouteQuery } from '@vueuse/router'
338
- import { RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
359
+ import { useRoute } from 'vue-router'
360
+ import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
339
361
  import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
340
362
  import { useTranslation } from '../../composables/useTranslation'
341
363
  import { useDebouncedRef } from '../../composables/useDebouncedRef'
364
+ import { configKey, forEachActiveCustomFilter, isCustomFilterActive, searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
342
365
  import { useStableQueryParams } from '../../composables/useStableQueryParams'
343
366
  import { useComponentsConfig } from '../../config'
344
367
  import { useFetch } from '../../functions/api'
345
- import { getLink } from '../../functions/pagination'
368
+ import { scrollToBlockTop } from '../../functions/scroll'
369
+ import type { AsyncDataRequestStatus } from '../../functions/api.types'
346
370
  import type { Dataset } from '../../types/datasets'
347
371
  import type { Dataservice } from '../../types/dataservices'
348
372
  import type { Organization } from '../../types/organizations'
349
- import type { Reuse } from '../../types/reuses'
350
- import type { GlobalSearchConfig, SearchType, SortOption, DatasetSearchResponse, DataserviceSearchResponse, ReuseSearchResponse, OrganizationSearchResponse, FacetItem } from '../../types/search'
373
+ import type { ReuseV2 } from '../../types/reuses'
374
+ import type { TopicV2 } from '../../types/topics'
375
+ import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
351
376
  import { getDefaultGlobalSearchConfig } from '../../types/search'
352
377
  import BrandedButton from '../BrandedButton.vue'
353
378
  import LoadingBlock from '../LoadingBlock.vue'
@@ -358,6 +383,7 @@ import DatasetCard from '../DatasetCard.vue'
358
383
  import DataserviceCard from '../DataserviceCard.vue'
359
384
  import OrganizationHorizontalCard from '../OrganizationHorizontalCard.vue'
360
385
  import ReuseHorizontalCard from '../ReuseHorizontalCard.vue'
386
+ import TopicCard from '../TopicCard.vue'
361
387
  import SearchInput from './SearchInput.vue'
362
388
  import Sidemenu from './Sidemenu.vue'
363
389
  import BasicAndAdvancedFilters from './BasicAndAdvancedFilters.vue'
@@ -379,25 +405,50 @@ import ReuseTypeFilter from './Filter/ReuseTypeFilter.vue'
379
405
 
380
406
  const props = withDefaults(defineProps<{
381
407
  config?: GlobalSearchConfig
382
- placeholder?: string
408
+ placeholder?: string | null
409
+ hideSearchInput?: boolean
410
+ autoFocus?: boolean
383
411
  }>(), {
384
412
  config: getDefaultGlobalSearchConfig,
413
+ hideSearchInput: false,
414
+ autoFocus: true,
385
415
  })
386
416
 
417
+ const emit = defineEmits<{
418
+ resultsCount: [total: number]
419
+ }>()
420
+
387
421
  // defineModel's default is static and can't depend on props, so we cast and initialize manually
388
- const currentType = defineModel<SearchType>('type') as Ref<SearchType>
389
- if (!currentType.value) currentType.value = props.config[0]?.class ?? 'datasets'
422
+ const currentType = defineModel<string>('type') as Ref<string>
423
+ if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
390
424
 
391
425
  const { t } = useTranslation()
392
426
  const componentsConfig = useComponentsConfig()
427
+ const route = useRoute()
428
+
429
+ // Custom filter registry for useSearchFilter composable
430
+ const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
431
+ // Per-filter watch stoppers: each registered filter gets its own watcher so a
432
+ // value change resets page to 1, but registration itself does not (the value
433
+ // came from the URL, not from a user action).
434
+ const customFilterStops = new Map<string, () => void>()
393
435
 
394
436
  // Initial type is used to determine which fetch should be SSR (non-lazy)
395
437
  const initialType = currentType.value
396
438
 
397
439
  const currentTypeConfig = computed(() =>
398
- props.config.find(c => c.class === currentType.value),
440
+ props.config.find(c => configKey(c) === currentType.value),
399
441
  )
400
442
 
443
+ // Precedence: prop → per-type config → strategy default.
444
+ // null at any level means "no placeholder".
445
+ const resolvedPlaceholder = computed(() => {
446
+ if (props.placeholder !== undefined) return props.placeholder ?? ''
447
+ const cfg = currentTypeConfig.value
448
+ if (cfg && 'placeholder' in cfg) return cfg.placeholder ?? ''
449
+ return strategies[cfg?.class ?? 'datasets'].placeholder
450
+ })
451
+
401
452
  const activeBasicFilters = computed(() =>
402
453
  (currentTypeConfig.value?.basicFilters ?? []) as string[],
403
454
  )
@@ -431,13 +482,35 @@ const activeFilters = computed(() => [
431
482
  ...(currentTypeConfig.value?.advancedFilters ?? []),
432
483
  ] as string[])
433
484
 
434
- const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0)
485
+ const slots = useSlots()
486
+ const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
435
487
 
436
488
  // URL query params
437
489
  const q = useRouteQuery<string>('q', '')
438
490
  const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsConfig.searchDebounce ?? 300)
491
+ // When the search input is hidden, the parent owns the input and is expected
492
+ // to debounce user typing itself (otherwise typing would land in the URL
493
+ // instantly via v-model and stack two debounces). Bypass the internal debounce
494
+ // so URL-driven q changes hit the fetch params immediately.
495
+ const qForParams = computed(() => props.hideSearchInput ? q.value : qDebounced.value)
439
496
  const page = useRouteQuery('page', 1, { transform: Number })
440
497
  const sort = useRouteQuery<string | undefined>('sort')
498
+ const effectiveSort = computed({
499
+ get: () => sort.value ?? currentTypeConfig.value?.defaultSort,
500
+ set: (value) => { sort.value = value },
501
+ })
502
+
503
+ provide(searchFilterContextKey, {
504
+ register(urlParam, entry) {
505
+ customFilterRegistry.set(urlParam, entry)
506
+ customFilterStops.set(urlParam, watch(entry.ref, () => page.value = 1))
507
+ },
508
+ unregister(urlParam) {
509
+ customFilterStops.get(urlParam)?.()
510
+ customFilterStops.delete(urlParam)
511
+ customFilterRegistry.delete(urlParam)
512
+ },
513
+ })
441
514
 
442
515
  // Filter values
443
516
  const organizationId = useRouteQuery<string | undefined>('organization')
@@ -479,63 +552,134 @@ const allFilters: Record<string, Ref<unknown>> = {
479
552
  type: reuseType,
480
553
  }
481
554
 
482
- // Reset sort and filters when changing type if they're not valid for the new type
555
+ // Reset page, sort and filters when changing type. Every reset below goes
556
+ // through useRouteQuery, so VueUse coalesces them into a single router.replace
557
+ // (its shared _queriesQueue flushes once on nextTick). Custom filters are cleared
558
+ // here too, rather than in useSearchFilter's onunmount, so their URL change joins
559
+ // that same batch instead of racing it with a separate router.replace.
483
560
  watch(currentType, () => {
561
+ // page=1 is the default and is dropped from the URL, so only reset when needed.
562
+ if (page.value !== 1) page.value = 1
563
+
484
564
  // Reset sort if not valid
485
565
  const validSortValues = activeSortOptions.value.map(o => o.value as string)
486
566
  if (sort.value && !validSortValues.includes(sort.value)) {
487
567
  sort.value = undefined
488
568
  }
489
569
 
490
- // Reset filters that are not enabled for the new type
570
+ // Reset built-in filters that are not enabled for the new type
491
571
  for (const [filterName, filterRef] of Object.entries(allFilters)) {
492
572
  if (filterRef.value !== undefined && !activeFilters.value.includes(filterName)) {
493
573
  filterRef.value = undefined
494
574
  }
495
575
  }
496
- })
497
576
 
498
- // Check which types are enabled
499
- const datasetsEnabled = computed(() => props.config.some(c => c.class === 'datasets'))
500
- const dataservicesEnabled = computed(() => props.config.some(c => c.class === 'dataservices'))
501
- const reusesEnabled = computed(() => props.config.some(c => c.class === 'reuses'))
502
- const organizationsEnabled = computed(() => props.config.some(c => c.class === 'organizations'))
577
+ // Reset type-scoped custom filters that don't apply to the new type
578
+ for (const entry of customFilterRegistry.values()) {
579
+ if (entry.typeKeys && !entry.typeKeys.includes(currentType.value) && isCustomFilterActive(entry)) {
580
+ entry.ref.value = entry.defaultValue
581
+ }
582
+ }
583
+ })
503
584
 
504
585
  // Create stable params for each type
505
586
  const stableParamsOptions = {
506
587
  allFilters,
507
- q: qDebounced,
588
+ customFilterRegistry,
589
+ q: qForParams,
508
590
  sort,
509
591
  page,
510
592
  pageSize,
511
593
  }
512
594
 
513
- const datasetsParams = useStableQueryParams({
514
- ...stableParamsOptions,
515
- typeConfig: props.config.find(c => c.class === 'datasets'),
516
- })
517
- const dataservicesParams = useStableQueryParams({
518
- ...stableParamsOptions,
519
- typeConfig: props.config.find(c => c.class === 'dataservices'),
520
- })
521
- const reusesParams = useStableQueryParams({
522
- ...stableParamsOptions,
523
- typeConfig: props.config.find(c => c.class === 'reuses'),
524
- })
525
- const organizationsParams = useStableQueryParams({
526
- ...stableParamsOptions,
527
- typeConfig: props.config.find(c => c.class === 'organizations'),
528
- })
595
+ // Discriminated union: each variant carries its own response type so a `class`
596
+ // narrow gives the precise shape of `data.value` (no cast needed).
597
+ type SearchEntry = {
598
+ [K in SearchType]: {
599
+ class: K
600
+ data: Ref<SearchResponseByClass[K] | null>
601
+ status: Ref<AsyncDataRequestStatus>
602
+ }
603
+ }[SearchType]
604
+
605
+ // One strategy per class consolidates everything that varies by class:
606
+ // metadata (icon/name/placeholder), endpoint, and a typed fetch factory.
607
+ type SearchStrategy<C extends SearchType> = {
608
+ url: string
609
+ icon: Component
610
+ name: string
611
+ placeholder: string
612
+ fetch: (
613
+ params: Ref<Record<string, unknown>>,
614
+ server: boolean,
615
+ ) => Promise<Extract<SearchEntry, { class: C }>>
616
+ }
617
+
618
+ function makeStrategy<C extends SearchType>(
619
+ cls: C,
620
+ meta: Omit<SearchStrategy<C>, 'fetch'>,
621
+ ): SearchStrategy<C> {
622
+ return {
623
+ ...meta,
624
+ fetch: async (params, server) => {
625
+ const { data, status } = await useFetch<SearchResponseByClass[C]>(
626
+ meta.url,
627
+ { params, lazy: true, server },
628
+ )
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 }>
632
+ },
633
+ }
634
+ }
635
+
636
+ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
637
+ datasets: makeStrategy('datasets', {
638
+ url: '/api/2/datasets/search/',
639
+ icon: RiDatabase2Line,
640
+ name: t('Jeux de données'),
641
+ placeholder: t('ex. élections présidentielles'),
642
+ }),
643
+ dataservices: makeStrategy('dataservices', {
644
+ url: '/api/2/dataservices/search/',
645
+ icon: RiTerminalLine,
646
+ name: t('API'),
647
+ placeholder: t('ex: SIRENE'),
648
+ }),
649
+ reuses: makeStrategy('reuses', {
650
+ url: '/api/2/reuses/search/',
651
+ icon: RiLineChartLine,
652
+ name: t('Réutilisations'),
653
+ placeholder: t('Rechercher une réutilisation de données'),
654
+ }),
655
+ organizations: makeStrategy('organizations', {
656
+ url: '/api/2/organizations/search/',
657
+ icon: RiBuilding2Line,
658
+ name: t('Organisations'),
659
+ placeholder: t('Rechercher une organisation'),
660
+ }),
661
+ topics: makeStrategy('topics', {
662
+ url: '/api/2/topics/search/',
663
+ icon: RiBookShelfLine,
664
+ name: t('Thématiques'),
665
+ placeholder: t('Rechercher une thématique'),
666
+ }),
667
+ }
529
668
 
530
- // URLs that return null when type is not enabled
531
- const datasetsUrl = computed(() => datasetsEnabled.value ? '/api/2/datasets/search/' : null)
532
- const dataservicesUrl = computed(() => dataservicesEnabled.value ? '/api/2/dataservices/search/' : null)
533
- const reusesUrl = computed(() => reusesEnabled.value ? '/api/2/reuses/search/' : null)
534
- const organizationsUrl = computed(() => organizationsEnabled.value ? '/api/2/organizations/search/' : null)
669
+ // One params + fetch per config entry, keyed by configKey
670
+ const resultsMap: Record<string, SearchEntry> = {}
671
+ for (const c of props.config) {
672
+ const key = configKey(c)
673
+ const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
674
+ resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
675
+ }
535
676
 
536
- // Reset page on filter/sort change
677
+ // Reset page on filter/sort change. Custom filters (registered via
678
+ // useSearchFilter) have their own watchers set up in `provide`, so they're
679
+ // intentionally excluded here to avoid resetting the page when a filter
680
+ // registers with its URL-derived value.
537
681
  const filtersForReset = computed(() => ({
538
- q: qDebounced.value,
682
+ q: qForParams.value,
539
683
  organization: organizationId.value,
540
684
  organization_badge: organizationType.value,
541
685
  tag: tag.value,
@@ -573,6 +717,7 @@ const hasFilters = computed(() => {
573
717
  || lastUpdateRange.value
574
718
  || producerType.value
575
719
  || reuseType.value
720
+ || Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
576
721
  })
577
722
 
578
723
  const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
@@ -593,79 +738,35 @@ function resetFilters() {
593
738
  lastUpdateRange.value = undefined
594
739
  producerType.value = undefined
595
740
  reuseType.value = undefined
741
+ for (const entry of customFilterRegistry.values()) {
742
+ entry.ref.value = entry.defaultValue
743
+ }
596
744
  q.value = ''
597
745
  flushQ()
598
746
  }
599
747
 
600
- // API calls only for enabled types (useFetch skips when URL is null)
601
- // Only the initial type is fetched during SSR, others are client-side only
602
- const { data: datasetsResults, status: datasetsStatus } = await useFetch<DatasetSearchResponse<Dataset>>(
603
- datasetsUrl,
604
- { params: datasetsParams, lazy: true, server: initialType === 'datasets' },
605
- )
606
- const { data: dataservicesResults, status: dataservicesStatus } = await useFetch<DataserviceSearchResponse<Dataservice>>(
607
- dataservicesUrl,
608
- { params: dataservicesParams, lazy: true, server: initialType === 'dataservices' },
609
- )
610
- const { data: reusesResults, status: reusesStatus } = await useFetch<ReuseSearchResponse<Reuse>>(
611
- reusesUrl,
612
- { params: reusesParams, lazy: true, server: initialType === 'reuses' },
613
- )
614
- const { data: organizationsResults, status: organizationsStatus } = await useFetch<OrganizationSearchResponse<Organization>>(
615
- organizationsUrl,
616
- { params: organizationsParams, lazy: true, server: initialType === 'organizations' },
617
- )
748
+ const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
749
+ const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
618
750
 
619
- const typesMeta = {
620
- datasets: {
621
- icon: RiDatabase2Line,
622
- name: t('Jeux de données'),
623
- placeholder: t('ex. élections présidentielles'),
624
- results: datasetsResults,
625
- status: datasetsStatus,
626
- },
627
- dataservices: {
628
- icon: RiTerminalLine,
629
- name: t('API'),
630
- placeholder: t('ex: SIRENE'),
631
- results: dataservicesResults,
632
- status: dataservicesStatus,
633
- },
634
- reuses: {
635
- icon: RiLineChartLine,
636
- name: t('Réutilisations'),
637
- placeholder: t('Rechercher une réutilisation de données'),
638
- results: reusesResults,
639
- status: reusesStatus,
640
- },
641
- organizations: {
642
- icon: RiBuilding2Line,
643
- name: t('Organisations'),
644
- placeholder: t('Rechercher une organisation'),
645
- results: organizationsResults,
646
- status: organizationsStatus,
647
- },
648
- } as const
649
-
650
- const searchResults = computed(() => typesMeta[currentType.value].results.value)
651
- const searchResultsStatus = computed(() => typesMeta[currentType.value].status.value)
751
+ watch(searchResults, (results) => {
752
+ if (results) emit('resultsCount', results.total)
753
+ }, { immediate: true })
652
754
 
653
755
  // RSS feed URL for datasets
654
756
  const rssUrl = computed(() => {
655
- if (currentType.value !== 'datasets') return null
757
+ if (currentTypeConfig.value?.class !== 'datasets') return null
656
758
 
657
759
  const params = new URLSearchParams()
658
- const datasetsConfig = props.config.find(c => c.class === 'datasets')
659
760
 
660
761
  // Add hidden filters first
661
- if (datasetsConfig?.hiddenFilters) {
662
- for (const hf of datasetsConfig.hiddenFilters) {
762
+ if (currentTypeConfig.value?.hiddenFilters) {
763
+ for (const hf of currentTypeConfig.value.hiddenFilters) {
663
764
  if (hf?.value) params.set(hf.key as string, String(hf.value))
664
765
  }
665
766
  }
666
767
 
667
768
  // Add active filters
668
- if (qDebounced.value) params.set('q', qDebounced.value)
769
+ if (qForParams.value) params.set('q', qForParams.value)
669
770
  if (organizationId.value) params.set('organization', organizationId.value)
670
771
  if (organizationType.value) params.set('organization_badge', organizationType.value)
671
772
  if (tag.value) params.set('tag', tag.value)
@@ -677,8 +778,12 @@ const rssUrl = computed(() => {
677
778
  if (badge.value) params.set('badge', badge.value)
678
779
  if (topic.value) params.set('topic', topic.value)
679
780
 
781
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
782
+ params.set(apiParam, value)
783
+ }, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
784
+
680
785
  // Add sort if set
681
- if (sort.value) params.set('sort', sort.value)
786
+ if (effectiveSort.value) params.set('sort', effectiveSort.value)
682
787
 
683
788
  const queryString = params.toString()
684
789
  const basePath = '/api/1/datasets/recent.atom'
@@ -694,14 +799,15 @@ function getFacets(key: string): FacetItem[] | undefined {
694
799
  }
695
800
 
696
801
  // Scroll handling
697
- const searchRef = useTemplateRef('search')
802
+ const resultsRef = useTemplateRef('results')
698
803
 
699
- function scrollToTop() {
700
- searchRef.value?.scrollIntoView({ behavior: 'smooth' })
701
- }
804
+ // Every criteria lives in the URL, custom filters included, so watching the
805
+ // query covers them all: whenever the result list is replaced, bring its top
806
+ // back into view rather than leaving the reader in the middle of a list they
807
+ // have not seen yet.
808
+ watch(() => route.query, () => scrollToBlockTop(resultsRef.value))
702
809
 
703
810
  function changePage(newPage: number) {
704
811
  page.value = newPage
705
- scrollToTop()
706
812
  }
707
813
  </script>
@@ -13,8 +13,8 @@
13
13
  type="search"
14
14
  name="q"
15
15
  class="input max-h-12 m-0 rounded-tl shadow-input-blue"
16
- :aria-label="placeholder || t('Rechercher...')"
17
- :placeholder="placeholder || t('Rechercher...')"
16
+ :aria-label="placeholder === null ? t('Rechercher...') : placeholder ?? t('Rechercher...')"
17
+ :placeholder="placeholder === null ? undefined : placeholder ?? t('Rechercher...')"
18
18
  >
19
19
  <BrandedButton
20
20
  class="rounded-l-none rounded-br-none rounded-tr min-h-12"
@@ -37,8 +37,8 @@ import BrandedButton from '../BrandedButton.vue'
37
37
 
38
38
  const q = defineModel<string>({ required: true })
39
39
 
40
- withDefaults(defineProps<{
41
- placeholder?: string
40
+ const props = withDefaults(defineProps<{
41
+ placeholder?: string | null
42
42
  autoFocus?: boolean
43
43
  }>(), {
44
44
  autoFocus: true,
@@ -57,6 +57,7 @@ const focus = () => {
57
57
  }
58
58
 
59
59
  onMounted(async () => {
60
+ if (!props.autoFocus) return
60
61
  await nextTick()
61
62
  focus()
62
63
  })
@@ -11,7 +11,10 @@
11
11
  :class="{ 'rotate-180': open }"
12
12
  />
13
13
  </button>
14
- <div v-if="open || !isMobile">
14
+ <!-- Collapsing with CSS rather than `v-if` on a JS media query: the server has no
15
+ viewport, so it always renders the desktop state and a narrow client would drop
16
+ the whole panel during hydration. -->
17
+ <div :class="{ 'hidden md:block': !open }">
15
18
  <p
16
19
  :id="titleId"
17
20
  class="text-sm font-bold leading-tight mb-6 hidden md:block"
@@ -26,13 +29,11 @@
26
29
  <script setup lang="ts">
27
30
  import { ref, useId } from 'vue'
28
31
  import { RiArrowDownSLine } from '@remixicon/vue'
29
- import { useMediaQuery } from '@vueuse/core'
30
32
 
31
33
  defineProps<{
32
34
  buttonText: string
33
35
  }>()
34
36
 
35
37
  const titleId = useId()
36
- const isMobile = useMediaQuery('(max-width: 767px)')
37
38
  const open = ref(false)
38
39
  </script>