@datagouv/components-next 1.0.2-dev.11 → 1.0.2-dev.110

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 (103) hide show
  1. package/assets/main.css +4 -0
  2. package/dist/{Control-DuZJdKV_.js → Control-ZFh5ta_U.js} +1 -1
  3. package/dist/{Datafair.client-8haHXl47.js → Datafair.client-rf4T1IkA.js} +1 -1
  4. package/dist/{Event--kp8kMdJ.js → Event-DSQcW7OF.js} +24 -24
  5. package/dist/{Image-34hvypZI.js → Image-BijNEG0p.js} +6 -6
  6. package/dist/JsonPreview.client-dzar6iuh.js +40 -0
  7. package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
  8. package/dist/{MapContainer.client-l6HuXTHR.js → MapContainer.client-D-MoRNhG.js} +37 -38
  9. package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
  10. package/dist/{PdfPreview.client-4OueK-2Z.js → PdfPreview.client-DoDYLmJD.js} +822 -850
  11. package/dist/{Pmtiles.client-4j3VTYkz.js → Pmtiles.client-Dzm01Zfm.js} +1 -1
  12. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js +61 -0
  13. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-hJQIqcZm.js} +2 -2
  14. package/dist/{Tile-DbNFNPfU.js → Tile-Dcl7oIVu.js} +35 -35
  15. package/dist/{TileImage-BsXBxMtq.js → TileImage-BJeHipMX.js} +4 -4
  16. package/dist/{View-BR92hTWP.js → View-xp_P_OHw.js} +412 -401
  17. package/dist/XmlPreview.client-cOhwff6P.js +34 -0
  18. package/dist/{common-PJfpC179.js → common-BjQlan3k.js} +36 -36
  19. package/dist/components-next.css +6 -6
  20. package/dist/components-next.js +165 -142
  21. package/dist/components.css +1 -1
  22. package/dist/{index-CVTIoZQ0.js → index-NofRBuyf.js} +32886 -27183
  23. package/dist/main-Iz1ZCL6k.js +73606 -0
  24. package/dist/{proj-DsetBcW7.js → proj-CsNo9yH1.js} +532 -512
  25. package/dist/{tilecoord-Db24Px13.js → tilecoord-A0fLnBZr.js} +28 -28
  26. package/dist/{vue3-xml-viewer.common-CWer_T5-.js → vue3-xml-viewer.common-tVI9uXUz.js} +1 -1
  27. package/package.json +25 -11
  28. package/src/chart.ts +5 -0
  29. package/src/components/ActivityList/ActivityList.vue +3 -2
  30. package/src/components/Chart/ChartViewer.vue +226 -0
  31. package/src/components/Chart/ChartViewerWrapper.vue +170 -0
  32. package/src/components/DataserviceCard.vue +3 -0
  33. package/src/components/DatasetCard.vue +9 -4
  34. package/src/components/Form/Listbox.vue +101 -0
  35. package/src/components/Form/SearchableSelect.vue +2 -1
  36. package/src/components/InfiniteLoader.vue +53 -0
  37. package/src/components/ObjectCardHeader.vue +11 -4
  38. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  39. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  40. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  41. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  42. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  43. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  44. package/src/components/OpenApiViewer/openapi.ts +150 -0
  45. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  46. package/src/components/Pagination.vue +8 -5
  47. package/src/components/RadioInput.vue +7 -2
  48. package/src/components/ReadMore.vue +1 -1
  49. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  50. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  51. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  52. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
  53. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  54. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
  55. package/src/components/ResourceAccordion/Preview.vue +11 -11
  56. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  57. package/src/components/ResourceAccordion/ResourceAccordion.vue +10 -109
  58. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  59. package/src/components/ResourceExplorer/ResourceExplorer.vue +14 -10
  60. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  61. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +46 -147
  62. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  63. package/src/components/ReuseCard.vue +12 -4
  64. package/src/components/Search/GlobalSearch.vue +201 -113
  65. package/src/components/Search/SearchInput.vue +5 -4
  66. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  67. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  68. package/src/components/TabularExplorer/TabularExplorer.vue +973 -0
  69. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  70. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  71. package/src/components/TabularExplorer/types.ts +83 -0
  72. package/src/composables/useHasTabularData.ts +13 -0
  73. package/src/composables/useMetrics.ts +1 -1
  74. package/src/composables/useSearchFilter.ts +118 -0
  75. package/src/composables/useStableQueryParams.ts +38 -6
  76. package/src/composables/useTabularProfile.ts +70 -0
  77. package/src/config.ts +20 -3
  78. package/src/functions/activities.ts +3 -3
  79. package/src/functions/api.ts +9 -37
  80. package/src/functions/api.types.ts +1 -0
  81. package/src/functions/charts.ts +68 -0
  82. package/src/functions/datasets.ts +0 -17
  83. package/src/functions/metrics.ts +6 -4
  84. package/src/functions/resources.ts +56 -1
  85. package/src/functions/tabular.ts +60 -0
  86. package/src/functions/tabularApi.ts +138 -11
  87. package/src/main.ts +90 -9
  88. package/src/types/dataservices.ts +2 -0
  89. package/src/types/pages.ts +0 -5
  90. package/src/types/posts.ts +2 -2
  91. package/src/types/reports.ts +5 -1
  92. package/src/types/search.ts +63 -1
  93. package/src/types/site.ts +5 -3
  94. package/src/types/ui.ts +2 -0
  95. package/src/types/users.ts +2 -1
  96. package/src/types/visualizations.ts +89 -0
  97. package/assets/swagger-themes/newspaper.css +0 -1670
  98. package/dist/JsonPreview.client-D53pj9Cw.js +0 -72
  99. package/dist/Swagger.client-DPBmsH9q.js +0 -4
  100. package/dist/XmlPreview.client-XElkoA4F.js +0 -64
  101. package/dist/main-BbT-LUXy.js +0 -105854
  102. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  103. package/src/functions/pagination.ts +0 -9
@@ -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,7 +261,7 @@
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"
@@ -255,7 +269,7 @@
255
269
  <ReuseHorizontalCard :reuse="(result as Reuse)" />
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
371
  import type { Reuse } from '../../types/reuses'
350
- import type { GlobalSearchConfig, SearchType, SortOption, DatasetSearchResponse, DataserviceSearchResponse, ReuseSearchResponse, OrganizationSearchResponse, FacetItem } from '../../types/search'
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')
@@ -495,47 +565,104 @@ watch(currentType, () => {
495
565
  }
496
566
  })
497
567
 
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'))
503
-
504
568
  // Create stable params for each type
505
569
  const stableParamsOptions = {
506
570
  allFilters,
507
- q: qDebounced,
571
+ customFilterRegistry,
572
+ q: qForParams,
508
573
  sort,
509
574
  page,
510
575
  pageSize,
511
576
  }
512
577
 
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
- })
578
+ // Discriminated union: each variant carries its own response type so a `class`
579
+ // narrow gives the precise shape of `data.value` (no cast needed).
580
+ type SearchEntry = {
581
+ [K in SearchType]: {
582
+ class: K
583
+ data: Ref<SearchResponseByClass[K] | null>
584
+ status: Ref<AsyncDataRequestStatus>
585
+ }
586
+ }[SearchType]
587
+
588
+ // One strategy per class consolidates everything that varies by class:
589
+ // metadata (icon/name/placeholder), endpoint, and a typed fetch factory.
590
+ type SearchStrategy<C extends SearchType> = {
591
+ url: string
592
+ icon: Component
593
+ name: string
594
+ placeholder: string
595
+ fetch: (
596
+ params: Ref<Record<string, unknown>>,
597
+ server: boolean,
598
+ ) => Promise<Extract<SearchEntry, { class: C }>>
599
+ }
529
600
 
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)
601
+ function makeStrategy<C extends SearchType>(
602
+ cls: C,
603
+ meta: Omit<SearchStrategy<C>, 'fetch'>,
604
+ ): SearchStrategy<C> {
605
+ return {
606
+ ...meta,
607
+ fetch: async (params, server) => {
608
+ const { data, status } = await useFetch<SearchResponseByClass[C]>(
609
+ meta.url,
610
+ { params, lazy: true, server },
611
+ )
612
+ // Tautologically equivalent to Extract<SearchEntry, { class: C }>, but TS
613
+ // cannot prove it on a generic C, so we assert.
614
+ return { class: cls, data, status } as Extract<SearchEntry, { class: C }>
615
+ },
616
+ }
617
+ }
535
618
 
536
- // Reset page on filter/sort change
619
+ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
620
+ datasets: makeStrategy('datasets', {
621
+ url: '/api/2/datasets/search/',
622
+ icon: RiDatabase2Line,
623
+ name: t('Jeux de données'),
624
+ placeholder: t('ex. élections présidentielles'),
625
+ }),
626
+ dataservices: makeStrategy('dataservices', {
627
+ url: '/api/2/dataservices/search/',
628
+ icon: RiTerminalLine,
629
+ name: t('API'),
630
+ placeholder: t('ex: SIRENE'),
631
+ }),
632
+ reuses: makeStrategy('reuses', {
633
+ url: '/api/2/reuses/search/',
634
+ icon: RiLineChartLine,
635
+ name: t('Réutilisations'),
636
+ placeholder: t('Rechercher une réutilisation de données'),
637
+ }),
638
+ organizations: makeStrategy('organizations', {
639
+ url: '/api/2/organizations/search/',
640
+ icon: RiBuilding2Line,
641
+ name: t('Organisations'),
642
+ placeholder: t('Rechercher une organisation'),
643
+ }),
644
+ topics: makeStrategy('topics', {
645
+ url: '/api/2/topics/search/',
646
+ icon: RiBookShelfLine,
647
+ name: t('Thématiques'),
648
+ placeholder: t('Rechercher une thématique'),
649
+ }),
650
+ }
651
+
652
+ // One params + fetch per config entry, keyed by configKey
653
+ const resultsMap: Record<string, SearchEntry> = {}
654
+ for (const c of props.config) {
655
+ const key = configKey(c)
656
+ const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
657
+ resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
658
+ }
659
+
660
+ // Reset page on filter/sort change. Custom filters (registered via
661
+ // useSearchFilter) have their own watchers set up in `provide`, so they're
662
+ // intentionally excluded here to avoid resetting the page when a filter
663
+ // registers with its URL-derived value.
537
664
  const filtersForReset = computed(() => ({
538
- q: qDebounced.value,
665
+ q: qForParams.value,
539
666
  organization: organizationId.value,
540
667
  organization_badge: organizationType.value,
541
668
  tag: tag.value,
@@ -573,6 +700,7 @@ const hasFilters = computed(() => {
573
700
  || lastUpdateRange.value
574
701
  || producerType.value
575
702
  || reuseType.value
703
+ || Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
576
704
  })
577
705
 
578
706
  const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
@@ -593,79 +721,35 @@ function resetFilters() {
593
721
  lastUpdateRange.value = undefined
594
722
  producerType.value = undefined
595
723
  reuseType.value = undefined
724
+ for (const entry of customFilterRegistry.values()) {
725
+ entry.ref.value = entry.defaultValue
726
+ }
596
727
  q.value = ''
597
728
  flushQ()
598
729
  }
599
730
 
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
731
+ const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
732
+ const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
649
733
 
650
- const searchResults = computed(() => typesMeta[currentType.value].results.value)
651
- const searchResultsStatus = computed(() => typesMeta[currentType.value].status.value)
734
+ watch(searchResults, (results) => {
735
+ if (results) emit('resultsCount', results.total)
736
+ }, { immediate: true })
652
737
 
653
738
  // RSS feed URL for datasets
654
739
  const rssUrl = computed(() => {
655
- if (currentType.value !== 'datasets') return null
740
+ if (currentTypeConfig.value?.class !== 'datasets') return null
656
741
 
657
742
  const params = new URLSearchParams()
658
- const datasetsConfig = props.config.find(c => c.class === 'datasets')
659
743
 
660
744
  // Add hidden filters first
661
- if (datasetsConfig?.hiddenFilters) {
662
- for (const hf of datasetsConfig.hiddenFilters) {
745
+ if (currentTypeConfig.value?.hiddenFilters) {
746
+ for (const hf of currentTypeConfig.value.hiddenFilters) {
663
747
  if (hf?.value) params.set(hf.key as string, String(hf.value))
664
748
  }
665
749
  }
666
750
 
667
751
  // Add active filters
668
- if (qDebounced.value) params.set('q', qDebounced.value)
752
+ if (qForParams.value) params.set('q', qForParams.value)
669
753
  if (organizationId.value) params.set('organization', organizationId.value)
670
754
  if (organizationType.value) params.set('organization_badge', organizationType.value)
671
755
  if (tag.value) params.set('tag', tag.value)
@@ -677,8 +761,12 @@ const rssUrl = computed(() => {
677
761
  if (badge.value) params.set('badge', badge.value)
678
762
  if (topic.value) params.set('topic', topic.value)
679
763
 
764
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
765
+ params.set(apiParam, value)
766
+ }, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
767
+
680
768
  // Add sort if set
681
- if (sort.value) params.set('sort', sort.value)
769
+ if (effectiveSort.value) params.set('sort', effectiveSort.value)
682
770
 
683
771
  const queryString = params.toString()
684
772
  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,51 @@
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'"
25
+ :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
26
+ >{{ formatNumber(value) }}</span>
27
+ <span
28
+ v-else-if="columnType === 'date'"
29
+ :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
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
+ }>()
48
+
49
+ const { t } = useTranslation()
50
+ const { formatNumber, formatCellDate } = useFormatTabular()
51
+ </script>