@datagouv/components-next 1.0.2-dev.17 → 1.0.2-dev.170

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 (124) 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-B-OG5WtK.js} +4 -4
  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-D0c-ifUi.js +36 -0
  8. package/dist/JsonPreview.client-Dwh9sW9M.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-C2AZ7sOB.js} +38 -39
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-8NzNfW8b.js} +823 -855
  13. package/dist/Pmtiles.client-BFH4rTB7.js +26324 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BOgE441d.js +58 -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-DMOUO508.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 +183 -146
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-C9obT9DJ.js} +21834 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-B4AZU0vr.js} +185 -187
  26. package/dist/{main-vqhkVhVS.js → main-DNoYWfit.js} +40723 -73240
  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-Cp7Cu5ME.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/Chart/ChartViewer.vue +320 -0
  36. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  37. package/src/components/DataserviceCard.vue +9 -0
  38. package/src/components/DataserviceQuality.vue +82 -0
  39. package/src/components/DataserviceQualityInline.vue +31 -0
  40. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  41. package/src/components/DatasetCard.vue +9 -4
  42. package/src/components/DatasetQuality.vue +11 -11
  43. package/src/components/DatasetQualityInline.vue +2 -2
  44. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  45. package/src/components/Form/Listbox.vue +101 -0
  46. package/src/components/InfiniteLoader.vue +53 -0
  47. package/src/components/ObjectCardHeader.vue +11 -4
  48. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  49. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  50. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  51. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  52. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  53. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  54. package/src/components/OpenApiViewer/openapi.ts +150 -0
  55. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  56. package/src/components/Pagination.vue +8 -5
  57. package/src/components/RadioInput.vue +7 -2
  58. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  59. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  60. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  61. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  62. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
  63. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  64. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
  65. package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
  66. package/src/components/ResourceAccordion/Preview.vue +11 -11
  67. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  68. package/src/components/ResourceAccordion/ResourceAccordion.vue +19 -113
  69. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  70. package/src/components/ResourceExplorer/ResourceExplorer.vue +22 -19
  71. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  72. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +64 -148
  73. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  74. package/src/components/ReuseCard.vue +14 -6
  75. package/src/components/ReuseDetails.vue +2 -2
  76. package/src/components/ReuseHorizontalCard.vue +2 -2
  77. package/src/components/Search/GlobalSearch.vue +219 -117
  78. package/src/components/Search/SearchInput.vue +5 -4
  79. package/src/components/TabularExplorer/TabularCell.vue +52 -0
  80. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  81. package/src/components/TabularExplorer/TabularExplorer.vue +1225 -0
  82. package/src/components/TabularExplorer/TabularFilterContent.vue +370 -0
  83. package/src/components/TabularExplorer/TabularFilterPopover.vue +107 -0
  84. package/src/components/TabularExplorer/types.ts +86 -0
  85. package/src/composables/useHasTabularData.ts +15 -1
  86. package/src/composables/useMetrics.ts +1 -1
  87. package/src/composables/useResourceCapabilities.ts +28 -15
  88. package/src/composables/useSearchFilter.ts +120 -0
  89. package/src/composables/useStableQueryParams.ts +38 -6
  90. package/src/composables/useTabularProfile.ts +70 -0
  91. package/src/config.ts +21 -3
  92. package/src/functions/activities.ts +3 -3
  93. package/src/functions/api.ts +20 -42
  94. package/src/functions/api.types.ts +1 -0
  95. package/src/functions/charts.ts +99 -0
  96. package/src/functions/datasets.ts +0 -17
  97. package/src/functions/dates.ts +3 -2
  98. package/src/functions/metrics.ts +6 -4
  99. package/src/functions/resources.ts +117 -1
  100. package/src/functions/tabular.ts +214 -0
  101. package/src/functions/tabularApi.ts +96 -11
  102. package/src/main.ts +109 -14
  103. package/src/types/dataservices.ts +17 -0
  104. package/src/types/organizations.ts +10 -0
  105. package/src/types/pages.ts +0 -5
  106. package/src/types/posts.ts +2 -2
  107. package/src/types/reports.ts +5 -1
  108. package/src/types/resources.ts +0 -1
  109. package/src/types/reuses.ts +12 -1
  110. package/src/types/search.ts +63 -1
  111. package/src/types/site.ts +5 -3
  112. package/src/types/ui.ts +2 -0
  113. package/src/types/users.ts +2 -1
  114. package/src/types/visualizations.ts +93 -0
  115. package/assets/swagger-themes/newspaper.css +0 -1670
  116. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  117. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  118. package/dist/Swagger.client-B4y0hXME.js +0 -4
  119. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  120. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  121. package/src/functions/pagination.ts +0 -9
  122. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  123. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  124. /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
@@ -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,23 @@
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 { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
339
360
  import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
340
361
  import { useTranslation } from '../../composables/useTranslation'
341
362
  import { useDebouncedRef } from '../../composables/useDebouncedRef'
363
+ import { configKey, forEachActiveCustomFilter, isCustomFilterActive, searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
342
364
  import { useStableQueryParams } from '../../composables/useStableQueryParams'
343
365
  import { useComponentsConfig } from '../../config'
344
366
  import { useFetch } from '../../functions/api'
345
- import { getLink } from '../../functions/pagination'
367
+ import type { AsyncDataRequestStatus } from '../../functions/api.types'
346
368
  import type { Dataset } from '../../types/datasets'
347
369
  import type { Dataservice } from '../../types/dataservices'
348
370
  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'
371
+ import type { ReuseV2 } from '../../types/reuses'
372
+ import type { TopicV2 } from '../../types/topics'
373
+ import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
351
374
  import { getDefaultGlobalSearchConfig } from '../../types/search'
352
375
  import BrandedButton from '../BrandedButton.vue'
353
376
  import LoadingBlock from '../LoadingBlock.vue'
@@ -358,6 +381,7 @@ import DatasetCard from '../DatasetCard.vue'
358
381
  import DataserviceCard from '../DataserviceCard.vue'
359
382
  import OrganizationHorizontalCard from '../OrganizationHorizontalCard.vue'
360
383
  import ReuseHorizontalCard from '../ReuseHorizontalCard.vue'
384
+ import TopicCard from '../TopicCard.vue'
361
385
  import SearchInput from './SearchInput.vue'
362
386
  import Sidemenu from './Sidemenu.vue'
363
387
  import BasicAndAdvancedFilters from './BasicAndAdvancedFilters.vue'
@@ -379,25 +403,49 @@ import ReuseTypeFilter from './Filter/ReuseTypeFilter.vue'
379
403
 
380
404
  const props = withDefaults(defineProps<{
381
405
  config?: GlobalSearchConfig
382
- placeholder?: string
406
+ placeholder?: string | null
407
+ hideSearchInput?: boolean
408
+ autoFocus?: boolean
383
409
  }>(), {
384
410
  config: getDefaultGlobalSearchConfig,
411
+ hideSearchInput: false,
412
+ autoFocus: true,
385
413
  })
386
414
 
415
+ const emit = defineEmits<{
416
+ resultsCount: [total: number]
417
+ }>()
418
+
387
419
  // 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'
420
+ const currentType = defineModel<string>('type') as Ref<string>
421
+ if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
390
422
 
391
423
  const { t } = useTranslation()
392
424
  const componentsConfig = useComponentsConfig()
393
425
 
426
+ // Custom filter registry for useSearchFilter composable
427
+ const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
428
+ // Per-filter watch stoppers: each registered filter gets its own watcher so a
429
+ // value change resets page to 1, but registration itself does not (the value
430
+ // came from the URL, not from a user action).
431
+ const customFilterStops = new Map<string, () => void>()
432
+
394
433
  // Initial type is used to determine which fetch should be SSR (non-lazy)
395
434
  const initialType = currentType.value
396
435
 
397
436
  const currentTypeConfig = computed(() =>
398
- props.config.find(c => c.class === currentType.value),
437
+ props.config.find(c => configKey(c) === currentType.value),
399
438
  )
400
439
 
440
+ // Precedence: prop → per-type config → strategy default.
441
+ // null at any level means "no placeholder".
442
+ const resolvedPlaceholder = computed(() => {
443
+ if (props.placeholder !== undefined) return props.placeholder ?? ''
444
+ const cfg = currentTypeConfig.value
445
+ if (cfg && 'placeholder' in cfg) return cfg.placeholder ?? ''
446
+ return strategies[cfg?.class ?? 'datasets'].placeholder
447
+ })
448
+
401
449
  const activeBasicFilters = computed(() =>
402
450
  (currentTypeConfig.value?.basicFilters ?? []) as string[],
403
451
  )
@@ -431,13 +479,35 @@ const activeFilters = computed(() => [
431
479
  ...(currentTypeConfig.value?.advancedFilters ?? []),
432
480
  ] as string[])
433
481
 
434
- const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0)
482
+ const slots = useSlots()
483
+ const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
435
484
 
436
485
  // URL query params
437
486
  const q = useRouteQuery<string>('q', '')
438
487
  const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsConfig.searchDebounce ?? 300)
488
+ // When the search input is hidden, the parent owns the input and is expected
489
+ // to debounce user typing itself (otherwise typing would land in the URL
490
+ // instantly via v-model and stack two debounces). Bypass the internal debounce
491
+ // so URL-driven q changes hit the fetch params immediately.
492
+ const qForParams = computed(() => props.hideSearchInput ? q.value : qDebounced.value)
439
493
  const page = useRouteQuery('page', 1, { transform: Number })
440
494
  const sort = useRouteQuery<string | undefined>('sort')
495
+ const effectiveSort = computed({
496
+ get: () => sort.value ?? currentTypeConfig.value?.defaultSort,
497
+ set: (value) => { sort.value = value },
498
+ })
499
+
500
+ provide(searchFilterContextKey, {
501
+ register(urlParam, entry) {
502
+ customFilterRegistry.set(urlParam, entry)
503
+ customFilterStops.set(urlParam, watch(entry.ref, () => page.value = 1))
504
+ },
505
+ unregister(urlParam) {
506
+ customFilterStops.get(urlParam)?.()
507
+ customFilterStops.delete(urlParam)
508
+ customFilterRegistry.delete(urlParam)
509
+ },
510
+ })
441
511
 
442
512
  // Filter values
443
513
  const organizationId = useRouteQuery<string | undefined>('organization')
@@ -479,63 +549,134 @@ const allFilters: Record<string, Ref<unknown>> = {
479
549
  type: reuseType,
480
550
  }
481
551
 
482
- // Reset sort and filters when changing type if they're not valid for the new type
552
+ // Reset page, sort and filters when changing type. Every reset below goes
553
+ // through useRouteQuery, so VueUse coalesces them into a single router.replace
554
+ // (its shared _queriesQueue flushes once on nextTick). Custom filters are cleared
555
+ // here too, rather than in useSearchFilter's onunmount, so their URL change joins
556
+ // that same batch instead of racing it with a separate router.replace.
483
557
  watch(currentType, () => {
558
+ // page=1 is the default and is dropped from the URL, so only reset when needed.
559
+ if (page.value !== 1) page.value = 1
560
+
484
561
  // Reset sort if not valid
485
562
  const validSortValues = activeSortOptions.value.map(o => o.value as string)
486
563
  if (sort.value && !validSortValues.includes(sort.value)) {
487
564
  sort.value = undefined
488
565
  }
489
566
 
490
- // Reset filters that are not enabled for the new type
567
+ // Reset built-in filters that are not enabled for the new type
491
568
  for (const [filterName, filterRef] of Object.entries(allFilters)) {
492
569
  if (filterRef.value !== undefined && !activeFilters.value.includes(filterName)) {
493
570
  filterRef.value = undefined
494
571
  }
495
572
  }
496
- })
497
573
 
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'))
574
+ // Reset type-scoped custom filters that don't apply to the new type
575
+ for (const entry of customFilterRegistry.values()) {
576
+ if (entry.typeKeys && !entry.typeKeys.includes(currentType.value) && isCustomFilterActive(entry)) {
577
+ entry.ref.value = entry.defaultValue
578
+ }
579
+ }
580
+ })
503
581
 
504
582
  // Create stable params for each type
505
583
  const stableParamsOptions = {
506
584
  allFilters,
507
- q: qDebounced,
585
+ customFilterRegistry,
586
+ q: qForParams,
508
587
  sort,
509
588
  page,
510
589
  pageSize,
511
590
  }
512
591
 
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
- })
592
+ // Discriminated union: each variant carries its own response type so a `class`
593
+ // narrow gives the precise shape of `data.value` (no cast needed).
594
+ type SearchEntry = {
595
+ [K in SearchType]: {
596
+ class: K
597
+ data: Ref<SearchResponseByClass[K] | null>
598
+ status: Ref<AsyncDataRequestStatus>
599
+ }
600
+ }[SearchType]
601
+
602
+ // One strategy per class consolidates everything that varies by class:
603
+ // metadata (icon/name/placeholder), endpoint, and a typed fetch factory.
604
+ type SearchStrategy<C extends SearchType> = {
605
+ url: string
606
+ icon: Component
607
+ name: string
608
+ placeholder: string
609
+ fetch: (
610
+ params: Ref<Record<string, unknown>>,
611
+ server: boolean,
612
+ ) => Promise<Extract<SearchEntry, { class: C }>>
613
+ }
529
614
 
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)
615
+ function makeStrategy<C extends SearchType>(
616
+ cls: C,
617
+ meta: Omit<SearchStrategy<C>, 'fetch'>,
618
+ ): SearchStrategy<C> {
619
+ return {
620
+ ...meta,
621
+ fetch: async (params, server) => {
622
+ const { data, status } = await useFetch<SearchResponseByClass[C]>(
623
+ meta.url,
624
+ { params, lazy: true, server },
625
+ )
626
+ // Tautologically equivalent to Extract<SearchEntry, { class: C }>, but TS
627
+ // cannot prove it on a generic C, so we assert.
628
+ return { class: cls, data, status } as Extract<SearchEntry, { class: C }>
629
+ },
630
+ }
631
+ }
535
632
 
536
- // Reset page on filter/sort change
633
+ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
634
+ datasets: makeStrategy('datasets', {
635
+ url: '/api/2/datasets/search/',
636
+ icon: RiDatabase2Line,
637
+ name: t('Jeux de données'),
638
+ placeholder: t('ex. élections présidentielles'),
639
+ }),
640
+ dataservices: makeStrategy('dataservices', {
641
+ url: '/api/2/dataservices/search/',
642
+ icon: RiTerminalLine,
643
+ name: t('API'),
644
+ placeholder: t('ex: SIRENE'),
645
+ }),
646
+ reuses: makeStrategy('reuses', {
647
+ url: '/api/2/reuses/search/',
648
+ icon: RiLineChartLine,
649
+ name: t('Réutilisations'),
650
+ placeholder: t('Rechercher une réutilisation de données'),
651
+ }),
652
+ organizations: makeStrategy('organizations', {
653
+ url: '/api/2/organizations/search/',
654
+ icon: RiBuilding2Line,
655
+ name: t('Organisations'),
656
+ placeholder: t('Rechercher une organisation'),
657
+ }),
658
+ topics: makeStrategy('topics', {
659
+ url: '/api/2/topics/search/',
660
+ icon: RiBookShelfLine,
661
+ name: t('Thématiques'),
662
+ placeholder: t('Rechercher une thématique'),
663
+ }),
664
+ }
665
+
666
+ // One params + fetch per config entry, keyed by configKey
667
+ const resultsMap: Record<string, SearchEntry> = {}
668
+ for (const c of props.config) {
669
+ const key = configKey(c)
670
+ const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
671
+ resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
672
+ }
673
+
674
+ // Reset page on filter/sort change. Custom filters (registered via
675
+ // useSearchFilter) have their own watchers set up in `provide`, so they're
676
+ // intentionally excluded here to avoid resetting the page when a filter
677
+ // registers with its URL-derived value.
537
678
  const filtersForReset = computed(() => ({
538
- q: qDebounced.value,
679
+ q: qForParams.value,
539
680
  organization: organizationId.value,
540
681
  organization_badge: organizationType.value,
541
682
  tag: tag.value,
@@ -573,6 +714,7 @@ const hasFilters = computed(() => {
573
714
  || lastUpdateRange.value
574
715
  || producerType.value
575
716
  || reuseType.value
717
+ || Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
576
718
  })
577
719
 
578
720
  const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
@@ -593,79 +735,35 @@ function resetFilters() {
593
735
  lastUpdateRange.value = undefined
594
736
  producerType.value = undefined
595
737
  reuseType.value = undefined
738
+ for (const entry of customFilterRegistry.values()) {
739
+ entry.ref.value = entry.defaultValue
740
+ }
596
741
  q.value = ''
597
742
  flushQ()
598
743
  }
599
744
 
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
745
+ const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
746
+ const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
649
747
 
650
- const searchResults = computed(() => typesMeta[currentType.value].results.value)
651
- const searchResultsStatus = computed(() => typesMeta[currentType.value].status.value)
748
+ watch(searchResults, (results) => {
749
+ if (results) emit('resultsCount', results.total)
750
+ }, { immediate: true })
652
751
 
653
752
  // RSS feed URL for datasets
654
753
  const rssUrl = computed(() => {
655
- if (currentType.value !== 'datasets') return null
754
+ if (currentTypeConfig.value?.class !== 'datasets') return null
656
755
 
657
756
  const params = new URLSearchParams()
658
- const datasetsConfig = props.config.find(c => c.class === 'datasets')
659
757
 
660
758
  // Add hidden filters first
661
- if (datasetsConfig?.hiddenFilters) {
662
- for (const hf of datasetsConfig.hiddenFilters) {
759
+ if (currentTypeConfig.value?.hiddenFilters) {
760
+ for (const hf of currentTypeConfig.value.hiddenFilters) {
663
761
  if (hf?.value) params.set(hf.key as string, String(hf.value))
664
762
  }
665
763
  }
666
764
 
667
765
  // Add active filters
668
- if (qDebounced.value) params.set('q', qDebounced.value)
766
+ if (qForParams.value) params.set('q', qForParams.value)
669
767
  if (organizationId.value) params.set('organization', organizationId.value)
670
768
  if (organizationType.value) params.set('organization_badge', organizationType.value)
671
769
  if (tag.value) params.set('tag', tag.value)
@@ -677,8 +775,12 @@ const rssUrl = computed(() => {
677
775
  if (badge.value) params.set('badge', badge.value)
678
776
  if (topic.value) params.set('topic', topic.value)
679
777
 
778
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
779
+ params.set(apiParam, value)
780
+ }, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
781
+
680
782
  // Add sort if set
681
- if (sort.value) params.set('sort', sort.value)
783
+ if (effectiveSort.value) params.set('sort', effectiveSort.value)
682
784
 
683
785
  const queryString = params.toString()
684
786
  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
  })
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <span
3
+ v-if="value == null || value === ''"
4
+ class="font-[Inconsolata,monospace] text-gray-low italic"
5
+ :class="compact ? 'text-xs' : 'text-sm'"
6
+ >null</span>
7
+ <span
8
+ v-else-if="columnType === 'boolean'"
9
+ class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs"
10
+ :class="isTruthy(value) ? 'bg-new-success-light text-new-success' : 'bg-new-warning-light text-new-error'"
11
+ >
12
+ <span
13
+ class="size-2 rounded-full"
14
+ :class="isTruthy(value) ? 'bg-new-success' : 'bg-new-error'"
15
+ />
16
+ {{ isTruthy(value) ? t('Vrai') : t('Faux') }}
17
+ </span>
18
+ <span
19
+ v-else-if="columnType === 'categorical'"
20
+ class="inline-block rounded font-medium px-2 py-0.5 text-xs max-w-full truncate"
21
+ :style="categoryBadgeStyle ? { backgroundColor: categoryBadgeStyle.backgroundColor, color: categoryBadgeStyle.color } : undefined"
22
+ >{{ value }}</span>
23
+ <span
24
+ v-else-if="columnType === 'number' || columnType === 'year'"
25
+ :class="compact ? 'font-mono tabular-nums text-xs' : ''"
26
+ >{{ columnType === 'year' || noNumberFormat ? value : formatNumber(value) }}</span>
27
+ <span
28
+ v-else-if="columnType === 'date'"
29
+ :class="compact ? 'font-mono tabular-nums text-xs' : ''"
30
+ >{{ formatCellDate(value) }}</span>
31
+ <span
32
+ v-else
33
+ class="text-gray-title truncate block text-xs"
34
+ >{{ typeof value === 'object' ? JSON.stringify(value) : value }}</span>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ import { useTranslation } from '../../composables/useTranslation'
39
+ import { useFormatTabular, isTruthy } from '../../functions/tabular'
40
+ import type { ColumnType, BadgeStyle } from './types'
41
+
42
+ defineProps<{
43
+ value: unknown
44
+ columnType: ColumnType
45
+ categoryBadgeStyle?: BadgeStyle
46
+ compact?: boolean
47
+ noNumberFormat?: boolean
48
+ }>()
49
+
50
+ const { t } = useTranslation()
51
+ const { formatNumber, formatCellDate } = useFormatTabular()
52
+ </script>