@datagouv/components-next 1.0.2-dev.19 → 1.0.2-dev.190

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 (156) 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-VsEj9A00.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-cv32--L0.js +36 -0
  8. package/dist/JsonPreview.client-JmyzznjY.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-BxGjbFJV.js} +41 -42
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-CkrjnmJP.js} +835 -856
  13. package/dist/Pmtiles.client-EAekMgpr.js +26333 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-B0OeAb39.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-WcOMRUAD.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 +194 -149
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-DLsJxwiN.js} +21842 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-D0FPslrZ.js} +185 -187
  26. package/dist/{main-vqhkVhVS.js → main-HzoTSLAM.js} +41511 -73246
  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-iq0K0tIG.js} +16 -16
  32. package/package.json +47 -38
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +3 -2
  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 +9 -0
  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 +9 -4
  45. package/src/components/DatasetQuality.vue +11 -11
  46. package/src/components/DatasetQualityInline.vue +2 -2
  47. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  48. package/src/components/Form/Listbox.vue +101 -0
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/InfiniteLoader.vue +53 -0
  51. package/src/components/ObjectCardHeader.vue +11 -4
  52. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  53. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  54. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  55. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  56. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  57. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  58. package/src/components/OpenApiViewer/openapi.ts +150 -0
  59. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  60. package/src/components/Pagination.vue +23 -58
  61. package/src/components/RadioInput.vue +7 -2
  62. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  63. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  64. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  65. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  66. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  67. package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
  68. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  69. package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
  70. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  71. package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
  72. package/src/components/ResourceAccordion/Preview.vue +11 -11
  73. package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
  74. package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
  75. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  76. package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
  77. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
  78. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
  79. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
  80. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  81. package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
  82. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
  83. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  84. package/src/components/ResourceListItem.vue +154 -0
  85. package/src/components/ReuseCard.vue +14 -6
  86. package/src/components/ReuseDetails.vue +2 -2
  87. package/src/components/ReuseHorizontalCard.vue +2 -2
  88. package/src/components/SchemaCard.vue +10 -1
  89. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  90. package/src/components/Search/GlobalSearch.vue +220 -117
  91. package/src/components/Search/SearchInput.vue +5 -4
  92. package/src/components/Search/Sidemenu.vue +4 -3
  93. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  94. package/src/components/TabularExplorer/TabularCell.vue +50 -0
  95. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  96. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  97. package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
  98. package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
  99. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  100. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  101. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  102. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  103. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  104. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  105. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  106. package/src/components/TabularExplorer/types.ts +81 -0
  107. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  108. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  109. package/src/composables/useActiveDescendant.ts +5 -0
  110. package/src/composables/useDatasetResources.ts +164 -0
  111. package/src/composables/useHasTabularData.ts +15 -1
  112. package/src/composables/useMetrics.ts +1 -1
  113. package/src/composables/useResizable.ts +46 -0
  114. package/src/composables/useResourceCapabilities.ts +48 -40
  115. package/src/composables/useSearchFilter.ts +120 -0
  116. package/src/composables/useStableQueryParams.ts +38 -6
  117. package/src/composables/useTabularProfile.ts +70 -0
  118. package/src/composables/useTranslation.ts +24 -9
  119. package/src/config.ts +21 -3
  120. package/src/functions/activities.ts +3 -3
  121. package/src/functions/api.ts +20 -42
  122. package/src/functions/api.types.ts +1 -0
  123. package/src/functions/charts.ts +99 -0
  124. package/src/functions/datasets.ts +0 -17
  125. package/src/functions/dates.ts +3 -2
  126. package/src/functions/helpers.ts +15 -0
  127. package/src/functions/metrics.ts +63 -60
  128. package/src/functions/paginate.ts +46 -0
  129. package/src/functions/resourceCapabilities.ts +34 -0
  130. package/src/functions/resources.ts +136 -1
  131. package/src/functions/schemas.ts +27 -0
  132. package/src/functions/tabular.ts +214 -0
  133. package/src/functions/tabularApi.ts +96 -11
  134. package/src/main.ts +117 -14
  135. package/src/types/dataservices.ts +17 -0
  136. package/src/types/organizations.ts +10 -0
  137. package/src/types/pages.ts +0 -5
  138. package/src/types/posts.ts +2 -2
  139. package/src/types/reports.ts +5 -1
  140. package/src/types/resources.ts +0 -1
  141. package/src/types/reuses.ts +12 -1
  142. package/src/types/search.ts +63 -1
  143. package/src/types/site.ts +5 -3
  144. package/src/types/ui.ts +2 -0
  145. package/src/types/users.ts +2 -1
  146. package/src/types/visualizations.ts +93 -0
  147. package/assets/swagger-themes/newspaper.css +0 -1670
  148. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  149. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  150. package/dist/Swagger.client-B4y0hXME.js +0 -4
  151. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  152. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  153. package/src/functions/pagination.ts +0 -9
  154. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  155. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  156. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -5,13 +5,15 @@
5
5
  @submit.prevent
6
6
  >
7
7
  <div
8
+ v-if="!hideSearchInput"
8
9
  ref="search"
9
10
  class="flex flex-wrap items-center justify-between"
10
11
  data-cy="search"
11
12
  >
12
13
  <SearchInput
13
14
  v-model="q"
14
- :placeholder="placeholder || typesMeta[currentType].placeholder"
15
+ :placeholder="resolvedPlaceholder"
16
+ :auto-focus
15
17
  />
16
18
  </div>
17
19
  <div class="grid grid-cols-12 mt-2 md:mt-5">
@@ -30,23 +32,27 @@
30
32
  >
31
33
  <RadioInput
32
34
  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"
35
+ :key="configKey(typeConfig)"
36
+ :value="configKey(typeConfig)"
37
+ :count="resultsMap[configKey(typeConfig)]?.data.value?.total"
38
+ :loading="resultsMap[configKey(typeConfig)]?.status.value === 'pending' || resultsMap[configKey(typeConfig)]?.status.value === 'idle'"
39
+ :icon="typeConfig.icon ?? strategies[typeConfig.class].icon"
38
40
  >
39
- {{ typeConfig.name || typesMeta[typeConfig.class].name }}
41
+ {{ typeConfig.name || strategies[typeConfig.class].name }}
40
42
  </RadioInput>
41
43
  </RadioGroup>
42
44
  </Sidemenu>
43
45
  </div>
44
46
 
45
- <div v-if="activeFilters.length > 0">
47
+ <div v-if="activeFilters.length > 0 || $slots['custom-filters-top'] || $slots['custom-filters-bottom']">
46
48
  <Sidemenu :button-text="t('Filtres')">
47
49
  <template #title>
48
50
  {{ t('Filtres') }}
49
51
  </template>
52
+ <slot
53
+ name="custom-filters-top"
54
+ :current-type="currentType"
55
+ />
50
56
  <BasicAndAdvancedFilters
51
57
  v-slot="{ isEnabled, getOrder }"
52
58
  :basic-filters="activeBasicFilters"
@@ -123,7 +129,7 @@
123
129
  v-model="producerType"
124
130
  :facets="getFacets('producer_type')"
125
131
  :loading="searchResultsStatus === 'pending'"
126
- :exclude="currentType === 'organizations' ? ['user'] : []"
132
+ :exclude="currentTypeConfig?.class === 'organizations' ? ['user'] : []"
127
133
  :style="{ order: getOrder('producer_type') }"
128
134
  />
129
135
  <DatasetBadgeFilter
@@ -146,6 +152,10 @@
146
152
  :get-order="getOrder"
147
153
  />
148
154
  </BasicAndAdvancedFilters>
155
+ <slot
156
+ name="custom-filters-bottom"
157
+ :current-type="currentType"
158
+ />
149
159
  <div
150
160
  v-if="hasFilters"
151
161
  class="mt-6 text-center"
@@ -175,6 +185,7 @@
175
185
  <p
176
186
  class="fr-col-auto my-0"
177
187
  role="status"
188
+ data-testid="search-result-count"
178
189
  >
179
190
  {{ t("{count} résultats | {count} résultat | {count} résultats", searchResults.total) }}
180
191
  </p>
@@ -189,10 +200,13 @@
189
200
  <div class="fr-col">
190
201
  <select
191
202
  id="sort-search"
192
- v-model="sort"
203
+ v-model="effectiveSort"
193
204
  class="fr-select text-sm shadow-input-blue!"
194
205
  >
195
- <option :value="undefined">
206
+ <option
207
+ v-if="!currentTypeConfig?.defaultSort"
208
+ :value="undefined"
209
+ >
196
210
  {{ t('Pertinence') }}
197
211
  </option>
198
212
  <option
@@ -214,6 +228,7 @@
214
228
  size="sm"
215
229
  :icon="RiRssLine"
216
230
  icon-only
231
+ external
217
232
  target="_blank"
218
233
  />
219
234
  </div>
@@ -221,7 +236,7 @@
221
236
  <transition mode="out-in">
222
237
  <LoadingBlock
223
238
  v-slot="{ data: results }"
224
- :status="searchResultsStatus"
239
+ :status="searchResultsStatus!"
225
240
  :data="searchResults"
226
241
  >
227
242
  <div v-if="results && results.data.length">
@@ -231,7 +246,7 @@
231
246
  :key="result.id"
232
247
  class="p-0"
233
248
  >
234
- <template v-if="currentType === 'datasets'">
249
+ <template v-if="currentTypeConfig?.class === 'datasets'">
235
250
  <slot
236
251
  name="dataset"
237
252
  :dataset="result"
@@ -239,7 +254,7 @@
239
254
  <DatasetCard :dataset="(result as Dataset)" />
240
255
  </slot>
241
256
  </template>
242
- <template v-else-if="currentType === 'dataservices'">
257
+ <template v-else-if="currentTypeConfig?.class === 'dataservices'">
243
258
  <slot
244
259
  name="dataservice"
245
260
  :dataservice="result"
@@ -247,15 +262,15 @@
247
262
  <DataserviceCard :dataservice="(result as Dataservice)" />
248
263
  </slot>
249
264
  </template>
250
- <template v-else-if="currentType === 'reuses'">
265
+ <template v-else-if="currentTypeConfig?.class === 'reuses'">
251
266
  <slot
252
267
  name="reuse"
253
268
  :reuse="result"
254
269
  >
255
- <ReuseHorizontalCard :reuse="(result as Reuse)" />
270
+ <ReuseHorizontalCard :reuse="(result as ReuseV2)" />
256
271
  </slot>
257
272
  </template>
258
- <template v-else-if="currentType === 'organizations'">
273
+ <template v-else-if="currentTypeConfig?.class === 'organizations'">
259
274
  <slot
260
275
  name="organization"
261
276
  :organization="result"
@@ -263,6 +278,14 @@
263
278
  <OrganizationHorizontalCard :organization="(result as Organization)" />
264
279
  </slot>
265
280
  </template>
281
+ <template v-else-if="currentTypeConfig?.class === 'topics'">
282
+ <slot
283
+ name="topic"
284
+ :topic="result"
285
+ >
286
+ <TopicCard :topic="(result as TopicV2)" />
287
+ </slot>
288
+ </template>
266
289
  </li>
267
290
  </ul>
268
291
  <Pagination
@@ -271,7 +294,6 @@
271
294
  :page-size
272
295
  :total-results="results.total"
273
296
  class="mt-4"
274
- :link="getLink"
275
297
  @change="changePage"
276
298
  />
277
299
  </div>
@@ -333,21 +355,23 @@
333
355
  </template>
334
356
 
335
357
  <script setup lang="ts">
336
- import { computed, watch, useTemplateRef, type Ref } from 'vue'
358
+ import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type Ref } from 'vue'
337
359
  import { useRouteQuery } from '@vueuse/router'
338
- import { RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
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 type { AsyncDataRequestStatus } from '../../functions/api.types'
346
369
  import type { Dataset } from '../../types/datasets'
347
370
  import type { Dataservice } from '../../types/dataservices'
348
371
  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'
372
+ import type { ReuseV2 } from '../../types/reuses'
373
+ import type { TopicV2 } from '../../types/topics'
374
+ import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
351
375
  import { getDefaultGlobalSearchConfig } from '../../types/search'
352
376
  import BrandedButton from '../BrandedButton.vue'
353
377
  import LoadingBlock from '../LoadingBlock.vue'
@@ -358,6 +382,7 @@ import DatasetCard from '../DatasetCard.vue'
358
382
  import DataserviceCard from '../DataserviceCard.vue'
359
383
  import OrganizationHorizontalCard from '../OrganizationHorizontalCard.vue'
360
384
  import ReuseHorizontalCard from '../ReuseHorizontalCard.vue'
385
+ import TopicCard from '../TopicCard.vue'
361
386
  import SearchInput from './SearchInput.vue'
362
387
  import Sidemenu from './Sidemenu.vue'
363
388
  import BasicAndAdvancedFilters from './BasicAndAdvancedFilters.vue'
@@ -379,25 +404,49 @@ import ReuseTypeFilter from './Filter/ReuseTypeFilter.vue'
379
404
 
380
405
  const props = withDefaults(defineProps<{
381
406
  config?: GlobalSearchConfig
382
- placeholder?: string
407
+ placeholder?: string | null
408
+ hideSearchInput?: boolean
409
+ autoFocus?: boolean
383
410
  }>(), {
384
411
  config: getDefaultGlobalSearchConfig,
412
+ hideSearchInput: false,
413
+ autoFocus: true,
385
414
  })
386
415
 
416
+ const emit = defineEmits<{
417
+ resultsCount: [total: number]
418
+ }>()
419
+
387
420
  // 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'
421
+ const currentType = defineModel<string>('type') as Ref<string>
422
+ if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
390
423
 
391
424
  const { t } = useTranslation()
392
425
  const componentsConfig = useComponentsConfig()
393
426
 
427
+ // Custom filter registry for useSearchFilter composable
428
+ const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
429
+ // Per-filter watch stoppers: each registered filter gets its own watcher so a
430
+ // value change resets page to 1, but registration itself does not (the value
431
+ // came from the URL, not from a user action).
432
+ const customFilterStops = new Map<string, () => void>()
433
+
394
434
  // Initial type is used to determine which fetch should be SSR (non-lazy)
395
435
  const initialType = currentType.value
396
436
 
397
437
  const currentTypeConfig = computed(() =>
398
- props.config.find(c => c.class === currentType.value),
438
+ props.config.find(c => configKey(c) === currentType.value),
399
439
  )
400
440
 
441
+ // Precedence: prop → per-type config → strategy default.
442
+ // null at any level means "no placeholder".
443
+ const resolvedPlaceholder = computed(() => {
444
+ if (props.placeholder !== undefined) return props.placeholder ?? ''
445
+ const cfg = currentTypeConfig.value
446
+ if (cfg && 'placeholder' in cfg) return cfg.placeholder ?? ''
447
+ return strategies[cfg?.class ?? 'datasets'].placeholder
448
+ })
449
+
401
450
  const activeBasicFilters = computed(() =>
402
451
  (currentTypeConfig.value?.basicFilters ?? []) as string[],
403
452
  )
@@ -431,13 +480,35 @@ const activeFilters = computed(() => [
431
480
  ...(currentTypeConfig.value?.advancedFilters ?? []),
432
481
  ] as string[])
433
482
 
434
- const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0)
483
+ const slots = useSlots()
484
+ const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
435
485
 
436
486
  // URL query params
437
487
  const q = useRouteQuery<string>('q', '')
438
488
  const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsConfig.searchDebounce ?? 300)
489
+ // When the search input is hidden, the parent owns the input and is expected
490
+ // to debounce user typing itself (otherwise typing would land in the URL
491
+ // instantly via v-model and stack two debounces). Bypass the internal debounce
492
+ // so URL-driven q changes hit the fetch params immediately.
493
+ const qForParams = computed(() => props.hideSearchInput ? q.value : qDebounced.value)
439
494
  const page = useRouteQuery('page', 1, { transform: Number })
440
495
  const sort = useRouteQuery<string | undefined>('sort')
496
+ const effectiveSort = computed({
497
+ get: () => sort.value ?? currentTypeConfig.value?.defaultSort,
498
+ set: (value) => { sort.value = value },
499
+ })
500
+
501
+ provide(searchFilterContextKey, {
502
+ register(urlParam, entry) {
503
+ customFilterRegistry.set(urlParam, entry)
504
+ customFilterStops.set(urlParam, watch(entry.ref, () => page.value = 1))
505
+ },
506
+ unregister(urlParam) {
507
+ customFilterStops.get(urlParam)?.()
508
+ customFilterStops.delete(urlParam)
509
+ customFilterRegistry.delete(urlParam)
510
+ },
511
+ })
441
512
 
442
513
  // Filter values
443
514
  const organizationId = useRouteQuery<string | undefined>('organization')
@@ -479,63 +550,134 @@ const allFilters: Record<string, Ref<unknown>> = {
479
550
  type: reuseType,
480
551
  }
481
552
 
482
- // Reset sort and filters when changing type if they're not valid for the new type
553
+ // Reset page, sort and filters when changing type. Every reset below goes
554
+ // through useRouteQuery, so VueUse coalesces them into a single router.replace
555
+ // (its shared _queriesQueue flushes once on nextTick). Custom filters are cleared
556
+ // here too, rather than in useSearchFilter's onunmount, so their URL change joins
557
+ // that same batch instead of racing it with a separate router.replace.
483
558
  watch(currentType, () => {
559
+ // page=1 is the default and is dropped from the URL, so only reset when needed.
560
+ if (page.value !== 1) page.value = 1
561
+
484
562
  // Reset sort if not valid
485
563
  const validSortValues = activeSortOptions.value.map(o => o.value as string)
486
564
  if (sort.value && !validSortValues.includes(sort.value)) {
487
565
  sort.value = undefined
488
566
  }
489
567
 
490
- // Reset filters that are not enabled for the new type
568
+ // Reset built-in filters that are not enabled for the new type
491
569
  for (const [filterName, filterRef] of Object.entries(allFilters)) {
492
570
  if (filterRef.value !== undefined && !activeFilters.value.includes(filterName)) {
493
571
  filterRef.value = undefined
494
572
  }
495
573
  }
496
- })
497
574
 
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'))
575
+ // Reset type-scoped custom filters that don't apply to the new type
576
+ for (const entry of customFilterRegistry.values()) {
577
+ if (entry.typeKeys && !entry.typeKeys.includes(currentType.value) && isCustomFilterActive(entry)) {
578
+ entry.ref.value = entry.defaultValue
579
+ }
580
+ }
581
+ })
503
582
 
504
583
  // Create stable params for each type
505
584
  const stableParamsOptions = {
506
585
  allFilters,
507
- q: qDebounced,
586
+ customFilterRegistry,
587
+ q: qForParams,
508
588
  sort,
509
589
  page,
510
590
  pageSize,
511
591
  }
512
592
 
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
- })
593
+ // Discriminated union: each variant carries its own response type so a `class`
594
+ // narrow gives the precise shape of `data.value` (no cast needed).
595
+ type SearchEntry = {
596
+ [K in SearchType]: {
597
+ class: K
598
+ data: Ref<SearchResponseByClass[K] | null>
599
+ status: Ref<AsyncDataRequestStatus>
600
+ }
601
+ }[SearchType]
602
+
603
+ // One strategy per class consolidates everything that varies by class:
604
+ // metadata (icon/name/placeholder), endpoint, and a typed fetch factory.
605
+ type SearchStrategy<C extends SearchType> = {
606
+ url: string
607
+ icon: Component
608
+ name: string
609
+ placeholder: string
610
+ fetch: (
611
+ params: Ref<Record<string, unknown>>,
612
+ server: boolean,
613
+ ) => Promise<Extract<SearchEntry, { class: C }>>
614
+ }
529
615
 
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)
616
+ function makeStrategy<C extends SearchType>(
617
+ cls: C,
618
+ meta: Omit<SearchStrategy<C>, 'fetch'>,
619
+ ): SearchStrategy<C> {
620
+ return {
621
+ ...meta,
622
+ fetch: async (params, server) => {
623
+ const { data, status } = await useFetch<SearchResponseByClass[C]>(
624
+ meta.url,
625
+ { params, lazy: true, server },
626
+ )
627
+ // Tautologically equivalent to Extract<SearchEntry, { class: C }>, but TS
628
+ // cannot prove it on a generic C, so we assert.
629
+ return { class: cls, data, status } as Extract<SearchEntry, { class: C }>
630
+ },
631
+ }
632
+ }
535
633
 
536
- // Reset page on filter/sort change
634
+ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
635
+ datasets: makeStrategy('datasets', {
636
+ url: '/api/2/datasets/search/',
637
+ icon: RiDatabase2Line,
638
+ name: t('Jeux de données'),
639
+ placeholder: t('ex. élections présidentielles'),
640
+ }),
641
+ dataservices: makeStrategy('dataservices', {
642
+ url: '/api/2/dataservices/search/',
643
+ icon: RiTerminalLine,
644
+ name: t('API'),
645
+ placeholder: t('ex: SIRENE'),
646
+ }),
647
+ reuses: makeStrategy('reuses', {
648
+ url: '/api/2/reuses/search/',
649
+ icon: RiLineChartLine,
650
+ name: t('Réutilisations'),
651
+ placeholder: t('Rechercher une réutilisation de données'),
652
+ }),
653
+ organizations: makeStrategy('organizations', {
654
+ url: '/api/2/organizations/search/',
655
+ icon: RiBuilding2Line,
656
+ name: t('Organisations'),
657
+ placeholder: t('Rechercher une organisation'),
658
+ }),
659
+ topics: makeStrategy('topics', {
660
+ url: '/api/2/topics/search/',
661
+ icon: RiBookShelfLine,
662
+ name: t('Thématiques'),
663
+ placeholder: t('Rechercher une thématique'),
664
+ }),
665
+ }
666
+
667
+ // One params + fetch per config entry, keyed by configKey
668
+ const resultsMap: Record<string, SearchEntry> = {}
669
+ for (const c of props.config) {
670
+ const key = configKey(c)
671
+ const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
672
+ resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
673
+ }
674
+
675
+ // Reset page on filter/sort change. Custom filters (registered via
676
+ // useSearchFilter) have their own watchers set up in `provide`, so they're
677
+ // intentionally excluded here to avoid resetting the page when a filter
678
+ // registers with its URL-derived value.
537
679
  const filtersForReset = computed(() => ({
538
- q: qDebounced.value,
680
+ q: qForParams.value,
539
681
  organization: organizationId.value,
540
682
  organization_badge: organizationType.value,
541
683
  tag: tag.value,
@@ -573,6 +715,7 @@ const hasFilters = computed(() => {
573
715
  || lastUpdateRange.value
574
716
  || producerType.value
575
717
  || reuseType.value
718
+ || Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
576
719
  })
577
720
 
578
721
  const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
@@ -593,79 +736,35 @@ function resetFilters() {
593
736
  lastUpdateRange.value = undefined
594
737
  producerType.value = undefined
595
738
  reuseType.value = undefined
739
+ for (const entry of customFilterRegistry.values()) {
740
+ entry.ref.value = entry.defaultValue
741
+ }
596
742
  q.value = ''
597
743
  flushQ()
598
744
  }
599
745
 
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
- )
618
-
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
746
+ const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
747
+ const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
649
748
 
650
- const searchResults = computed(() => typesMeta[currentType.value].results.value)
651
- const searchResultsStatus = computed(() => typesMeta[currentType.value].status.value)
749
+ watch(searchResults, (results) => {
750
+ if (results) emit('resultsCount', results.total)
751
+ }, { immediate: true })
652
752
 
653
753
  // RSS feed URL for datasets
654
754
  const rssUrl = computed(() => {
655
- if (currentType.value !== 'datasets') return null
755
+ if (currentTypeConfig.value?.class !== 'datasets') return null
656
756
 
657
757
  const params = new URLSearchParams()
658
- const datasetsConfig = props.config.find(c => c.class === 'datasets')
659
758
 
660
759
  // Add hidden filters first
661
- if (datasetsConfig?.hiddenFilters) {
662
- for (const hf of datasetsConfig.hiddenFilters) {
760
+ if (currentTypeConfig.value?.hiddenFilters) {
761
+ for (const hf of currentTypeConfig.value.hiddenFilters) {
663
762
  if (hf?.value) params.set(hf.key as string, String(hf.value))
664
763
  }
665
764
  }
666
765
 
667
766
  // Add active filters
668
- if (qDebounced.value) params.set('q', qDebounced.value)
767
+ if (qForParams.value) params.set('q', qForParams.value)
669
768
  if (organizationId.value) params.set('organization', organizationId.value)
670
769
  if (organizationType.value) params.set('organization_badge', organizationType.value)
671
770
  if (tag.value) params.set('tag', tag.value)
@@ -677,8 +776,12 @@ const rssUrl = computed(() => {
677
776
  if (badge.value) params.set('badge', badge.value)
678
777
  if (topic.value) params.set('topic', topic.value)
679
778
 
779
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
780
+ params.set(apiParam, value)
781
+ }, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
782
+
680
783
  // Add sort if set
681
- if (sort.value) params.set('sort', sort.value)
784
+ if (effectiveSort.value) params.set('sort', effectiveSort.value)
682
785
 
683
786
  const queryString = params.toString()
684
787
  const basePath = '/api/1/datasets/recent.atom'
@@ -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>