@datagouv/components-next 1.0.2-dev.9 → 1.0.2-dev.90

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 (96) 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-CyZRNADr.js +30 -0
  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-C9iaPSmQ.js +40 -0
  7. package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
  8. package/dist/MapContainer.client-BuoZ69XO.js +101 -0
  9. package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
  10. package/dist/{PdfPreview.client-BVjPxlPu.js → PdfPreview.client-MI0bDghc.js} +822 -865
  11. package/dist/{Pmtiles.client-CRJ56yX2.js → Pmtiles.client-CaKEYQBc.js} +574 -579
  12. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BKqb6TMw.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-BVAeNK4n.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 +166 -148
  21. package/dist/components.css +1 -1
  22. package/dist/{index-BZsAZ7iw.js → index-BBdS8QKx.js} +32886 -27183
  23. package/dist/{main-qc4CO9Kn.js → main-Dk_66g-3.js} +91331 -75844
  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-CCOV_ohP.js → vue3-xml-viewer.common-B8dNNkOU.js} +1 -1
  27. package/package.json +18 -11
  28. package/src/components/ActivityList/ActivityList.vue +0 -2
  29. package/src/components/Chart/ChartViewer.vue +226 -0
  30. package/src/components/Chart/ChartViewerWrapper.vue +170 -0
  31. package/src/components/Form/Listbox.vue +101 -0
  32. package/src/components/Form/SearchableSelect.vue +2 -1
  33. package/src/components/InfiniteLoader.vue +53 -0
  34. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  35. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  36. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  37. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  38. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  39. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  40. package/src/components/OpenApiViewer/openapi.ts +150 -0
  41. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  42. package/src/components/Pagination.vue +8 -5
  43. package/src/components/ReadMore.vue +1 -1
  44. package/src/components/ResourceAccordion/Datafair.client.vue +4 -10
  45. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -121
  46. package/src/components/ResourceAccordion/MapContainer.client.vue +5 -14
  47. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  48. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -103
  49. package/src/components/ResourceAccordion/Pmtiles.client.vue +5 -10
  50. package/src/components/ResourceAccordion/Preview.vue +16 -21
  51. package/src/components/ResourceAccordion/PreviewLoader.vue +1 -2
  52. package/src/components/ResourceAccordion/PreviewUnavailable.vue +22 -0
  53. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  54. package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -7
  55. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -115
  56. package/src/components/ResourceExplorer/ResourceExplorer.vue +81 -13
  57. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  58. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -11
  59. package/src/components/Search/GlobalSearch.vue +191 -110
  60. package/src/components/Search/SearchInput.vue +5 -4
  61. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  62. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  63. package/src/components/TabularExplorer/TabularExplorer.vue +870 -0
  64. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  65. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  66. package/src/components/TabularExplorer/types.ts +83 -0
  67. package/src/composables/useHasTabularData.ts +6 -0
  68. package/src/composables/useResourceCapabilities.ts +1 -1
  69. package/src/composables/useSearchFilter.ts +118 -0
  70. package/src/composables/useStableQueryParams.ts +31 -3
  71. package/src/config.ts +3 -0
  72. package/src/functions/api.ts +34 -33
  73. package/src/functions/api.types.ts +1 -0
  74. package/src/functions/charts.ts +68 -0
  75. package/src/functions/datasets.ts +0 -17
  76. package/src/functions/resources.ts +56 -1
  77. package/src/functions/tabular.ts +60 -0
  78. package/src/functions/tabularApi.ts +138 -11
  79. package/src/main.ts +55 -7
  80. package/src/types/dataservices.ts +2 -0
  81. package/src/types/pages.ts +0 -5
  82. package/src/types/posts.ts +2 -2
  83. package/src/types/reports.ts +5 -1
  84. package/src/types/search.ts +52 -1
  85. package/src/types/site.ts +5 -3
  86. package/src/types/users.ts +2 -1
  87. package/src/types/visualizations.ts +89 -0
  88. package/assets/swagger-themes/newspaper.css +0 -1670
  89. package/dist/Datafair.client-0UYUu5yf.js +0 -35
  90. package/dist/JsonPreview.client-BrTMBWHZ.js +0 -87
  91. package/dist/MapContainer.client-CUmKyByc.js +0 -107
  92. package/dist/Swagger.client-2Yn7iF0A.js +0 -4
  93. package/dist/XmlPreview.client-DxqlVnKu.js +0 -79
  94. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  95. package/src/functions/pagination.ts +0 -9
  96. /package/assets/illustrations/{_microscope.svg → microscope.svg} +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="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>
@@ -221,7 +232,7 @@
221
232
  <transition mode="out-in">
222
233
  <LoadingBlock
223
234
  v-slot="{ data: results }"
224
- :status="searchResultsStatus"
235
+ :status="searchResultsStatus!"
225
236
  :data="searchResults"
226
237
  >
227
238
  <div v-if="results && results.data.length">
@@ -231,7 +242,7 @@
231
242
  :key="result.id"
232
243
  class="p-0"
233
244
  >
234
- <template v-if="currentType === 'datasets'">
245
+ <template v-if="currentTypeConfig?.class === 'datasets'">
235
246
  <slot
236
247
  name="dataset"
237
248
  :dataset="result"
@@ -239,7 +250,7 @@
239
250
  <DatasetCard :dataset="(result as Dataset)" />
240
251
  </slot>
241
252
  </template>
242
- <template v-else-if="currentType === 'dataservices'">
253
+ <template v-else-if="currentTypeConfig?.class === 'dataservices'">
243
254
  <slot
244
255
  name="dataservice"
245
256
  :dataservice="result"
@@ -247,7 +258,7 @@
247
258
  <DataserviceCard :dataservice="(result as Dataservice)" />
248
259
  </slot>
249
260
  </template>
250
- <template v-else-if="currentType === 'reuses'">
261
+ <template v-else-if="currentTypeConfig?.class === 'reuses'">
251
262
  <slot
252
263
  name="reuse"
253
264
  :reuse="result"
@@ -255,7 +266,7 @@
255
266
  <ReuseHorizontalCard :reuse="(result as Reuse)" />
256
267
  </slot>
257
268
  </template>
258
- <template v-else-if="currentType === 'organizations'">
269
+ <template v-else-if="currentTypeConfig?.class === 'organizations'">
259
270
  <slot
260
271
  name="organization"
261
272
  :organization="result"
@@ -263,6 +274,14 @@
263
274
  <OrganizationHorizontalCard :organization="(result as Organization)" />
264
275
  </slot>
265
276
  </template>
277
+ <template v-else-if="currentTypeConfig?.class === 'topics'">
278
+ <slot
279
+ name="topic"
280
+ :topic="result"
281
+ >
282
+ <TopicCard :topic="(result as TopicV2)" />
283
+ </slot>
284
+ </template>
266
285
  </li>
267
286
  </ul>
268
287
  <Pagination
@@ -271,7 +290,6 @@
271
290
  :page-size
272
291
  :total-results="results.total"
273
292
  class="mt-4"
274
- :link="getLink"
275
293
  @change="changePage"
276
294
  />
277
295
  </div>
@@ -333,21 +351,23 @@
333
351
  </template>
334
352
 
335
353
  <script setup lang="ts">
336
- import { computed, watch, useTemplateRef, type Ref } from 'vue'
354
+ import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type Ref } from 'vue'
337
355
  import { useRouteQuery } from '@vueuse/router'
338
- import { RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
356
+ import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
339
357
  import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
340
358
  import { useTranslation } from '../../composables/useTranslation'
341
359
  import { useDebouncedRef } from '../../composables/useDebouncedRef'
360
+ import { configKey, forEachActiveCustomFilter, isCustomFilterActive, searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
342
361
  import { useStableQueryParams } from '../../composables/useStableQueryParams'
343
362
  import { useComponentsConfig } from '../../config'
344
363
  import { useFetch } from '../../functions/api'
345
- import { getLink } from '../../functions/pagination'
364
+ import type { AsyncDataRequestStatus } from '../../functions/api.types'
346
365
  import type { Dataset } from '../../types/datasets'
347
366
  import type { Dataservice } from '../../types/dataservices'
348
367
  import type { Organization } from '../../types/organizations'
349
368
  import type { Reuse } from '../../types/reuses'
350
- import type { GlobalSearchConfig, SearchType, SortOption, DatasetSearchResponse, DataserviceSearchResponse, ReuseSearchResponse, OrganizationSearchResponse, FacetItem } from '../../types/search'
369
+ import type { TopicV2 } from '../../types/topics'
370
+ import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
351
371
  import { getDefaultGlobalSearchConfig } from '../../types/search'
352
372
  import BrandedButton from '../BrandedButton.vue'
353
373
  import LoadingBlock from '../LoadingBlock.vue'
@@ -358,6 +378,7 @@ import DatasetCard from '../DatasetCard.vue'
358
378
  import DataserviceCard from '../DataserviceCard.vue'
359
379
  import OrganizationHorizontalCard from '../OrganizationHorizontalCard.vue'
360
380
  import ReuseHorizontalCard from '../ReuseHorizontalCard.vue'
381
+ import TopicCard from '../TopicCard.vue'
361
382
  import SearchInput from './SearchInput.vue'
362
383
  import Sidemenu from './Sidemenu.vue'
363
384
  import BasicAndAdvancedFilters from './BasicAndAdvancedFilters.vue'
@@ -379,25 +400,49 @@ import ReuseTypeFilter from './Filter/ReuseTypeFilter.vue'
379
400
 
380
401
  const props = withDefaults(defineProps<{
381
402
  config?: GlobalSearchConfig
382
- placeholder?: string
403
+ placeholder?: string | null
404
+ hideSearchInput?: boolean
405
+ autoFocus?: boolean
383
406
  }>(), {
384
407
  config: getDefaultGlobalSearchConfig,
408
+ hideSearchInput: false,
409
+ autoFocus: true,
385
410
  })
386
411
 
412
+ const emit = defineEmits<{
413
+ resultsCount: [total: number]
414
+ }>()
415
+
387
416
  // 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'
417
+ const currentType = defineModel<string>('type') as Ref<string>
418
+ if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
390
419
 
391
420
  const { t } = useTranslation()
392
421
  const componentsConfig = useComponentsConfig()
393
422
 
423
+ // Custom filter registry for useSearchFilter composable
424
+ const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
425
+ // Per-filter watch stoppers: each registered filter gets its own watcher so a
426
+ // value change resets page to 1, but registration itself does not (the value
427
+ // came from the URL, not from a user action).
428
+ const customFilterStops = new Map<string, () => void>()
429
+
394
430
  // Initial type is used to determine which fetch should be SSR (non-lazy)
395
431
  const initialType = currentType.value
396
432
 
397
433
  const currentTypeConfig = computed(() =>
398
- props.config.find(c => c.class === currentType.value),
434
+ props.config.find(c => configKey(c) === currentType.value),
399
435
  )
400
436
 
437
+ // Precedence: prop → per-type config → strategy default.
438
+ // null at any level means "no placeholder".
439
+ const resolvedPlaceholder = computed(() => {
440
+ if (props.placeholder !== undefined) return props.placeholder ?? ''
441
+ const cfg = currentTypeConfig.value
442
+ if (cfg && 'placeholder' in cfg) return cfg.placeholder ?? ''
443
+ return strategies[cfg?.class ?? 'datasets'].placeholder
444
+ })
445
+
401
446
  const activeBasicFilters = computed(() =>
402
447
  (currentTypeConfig.value?.basicFilters ?? []) as string[],
403
448
  )
@@ -431,14 +476,32 @@ const activeFilters = computed(() => [
431
476
  ...(currentTypeConfig.value?.advancedFilters ?? []),
432
477
  ] as string[])
433
478
 
434
- const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0)
479
+ const slots = useSlots()
480
+ const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
435
481
 
436
482
  // URL query params
437
483
  const q = useRouteQuery<string>('q', '')
438
484
  const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsConfig.searchDebounce ?? 300)
485
+ // When the search input is hidden, the parent owns the input and is expected
486
+ // to debounce user typing itself (otherwise typing would land in the URL
487
+ // instantly via v-model and stack two debounces). Bypass the internal debounce
488
+ // so URL-driven q changes hit the fetch params immediately.
489
+ const qForParams = computed(() => props.hideSearchInput ? q.value : qDebounced.value)
439
490
  const page = useRouteQuery('page', 1, { transform: Number })
440
491
  const sort = useRouteQuery<string | undefined>('sort')
441
492
 
493
+ provide(searchFilterContextKey, {
494
+ register(urlParam, entry) {
495
+ customFilterRegistry.set(urlParam, entry)
496
+ customFilterStops.set(urlParam, watch(entry.ref, () => page.value = 1))
497
+ },
498
+ unregister(urlParam) {
499
+ customFilterStops.get(urlParam)?.()
500
+ customFilterStops.delete(urlParam)
501
+ customFilterRegistry.delete(urlParam)
502
+ },
503
+ })
504
+
442
505
  // Filter values
443
506
  const organizationId = useRouteQuery<string | undefined>('organization')
444
507
  const organizationType = useRouteQuery<string | undefined>('organization_badge')
@@ -495,47 +558,104 @@ watch(currentType, () => {
495
558
  }
496
559
  })
497
560
 
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
561
  // Create stable params for each type
505
562
  const stableParamsOptions = {
506
563
  allFilters,
507
- q: qDebounced,
564
+ customFilterRegistry,
565
+ q: qForParams,
508
566
  sort,
509
567
  page,
510
568
  pageSize,
511
569
  }
512
570
 
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
- })
571
+ // Discriminated union: each variant carries its own response type so a `class`
572
+ // narrow gives the precise shape of `data.value` (no cast needed).
573
+ type SearchEntry = {
574
+ [K in SearchType]: {
575
+ class: K
576
+ data: Ref<SearchResponseByClass[K] | null>
577
+ status: Ref<AsyncDataRequestStatus>
578
+ }
579
+ }[SearchType]
580
+
581
+ // One strategy per class consolidates everything that varies by class:
582
+ // metadata (icon/name/placeholder), endpoint, and a typed fetch factory.
583
+ type SearchStrategy<C extends SearchType> = {
584
+ url: string
585
+ icon: Component
586
+ name: string
587
+ placeholder: string
588
+ fetch: (
589
+ params: Ref<Record<string, unknown>>,
590
+ server: boolean,
591
+ ) => Promise<Extract<SearchEntry, { class: C }>>
592
+ }
593
+
594
+ function makeStrategy<C extends SearchType>(
595
+ cls: C,
596
+ meta: Omit<SearchStrategy<C>, 'fetch'>,
597
+ ): SearchStrategy<C> {
598
+ return {
599
+ ...meta,
600
+ fetch: async (params, server) => {
601
+ const { data, status } = await useFetch<SearchResponseByClass[C]>(
602
+ meta.url,
603
+ { params, lazy: true, server },
604
+ )
605
+ // Tautologically equivalent to Extract<SearchEntry, { class: C }>, but TS
606
+ // cannot prove it on a generic C, so we assert.
607
+ return { class: cls, data, status } as Extract<SearchEntry, { class: C }>
608
+ },
609
+ }
610
+ }
611
+
612
+ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
613
+ datasets: makeStrategy('datasets', {
614
+ url: '/api/2/datasets/search/',
615
+ icon: RiDatabase2Line,
616
+ name: t('Jeux de données'),
617
+ placeholder: t('ex. élections présidentielles'),
618
+ }),
619
+ dataservices: makeStrategy('dataservices', {
620
+ url: '/api/2/dataservices/search/',
621
+ icon: RiTerminalLine,
622
+ name: t('API'),
623
+ placeholder: t('ex: SIRENE'),
624
+ }),
625
+ reuses: makeStrategy('reuses', {
626
+ url: '/api/2/reuses/search/',
627
+ icon: RiLineChartLine,
628
+ name: t('Réutilisations'),
629
+ placeholder: t('Rechercher une réutilisation de données'),
630
+ }),
631
+ organizations: makeStrategy('organizations', {
632
+ url: '/api/2/organizations/search/',
633
+ icon: RiBuilding2Line,
634
+ name: t('Organisations'),
635
+ placeholder: t('Rechercher une organisation'),
636
+ }),
637
+ topics: makeStrategy('topics', {
638
+ url: '/api/2/topics/search/',
639
+ icon: RiBookShelfLine,
640
+ name: t('Thématiques'),
641
+ placeholder: t('Rechercher une thématique'),
642
+ }),
643
+ }
529
644
 
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)
645
+ // One params + fetch per config entry, keyed by configKey
646
+ const resultsMap: Record<string, SearchEntry> = {}
647
+ for (const c of props.config) {
648
+ const key = configKey(c)
649
+ const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
650
+ resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
651
+ }
535
652
 
536
- // Reset page on filter/sort change
653
+ // Reset page on filter/sort change. Custom filters (registered via
654
+ // useSearchFilter) have their own watchers set up in `provide`, so they're
655
+ // intentionally excluded here to avoid resetting the page when a filter
656
+ // registers with its URL-derived value.
537
657
  const filtersForReset = computed(() => ({
538
- q: qDebounced.value,
658
+ q: qForParams.value,
539
659
  organization: organizationId.value,
540
660
  organization_badge: organizationType.value,
541
661
  tag: tag.value,
@@ -573,6 +693,7 @@ const hasFilters = computed(() => {
573
693
  || lastUpdateRange.value
574
694
  || producerType.value
575
695
  || reuseType.value
696
+ || Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
576
697
  })
577
698
 
578
699
  const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
@@ -593,79 +714,35 @@ function resetFilters() {
593
714
  lastUpdateRange.value = undefined
594
715
  producerType.value = undefined
595
716
  reuseType.value = undefined
717
+ for (const entry of customFilterRegistry.values()) {
718
+ entry.ref.value = entry.defaultValue
719
+ }
596
720
  q.value = ''
597
721
  flushQ()
598
722
  }
599
723
 
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
- )
724
+ const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
725
+ const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
618
726
 
619
- const typesMeta = {
620
- datasets: {
621
- icon: RiDatabase2Line,
622
- name: t('Jeux de données'),
623
- placeholder: t('ex. élections présidentielles'),
624
- results: datasetsResults,
625
- status: datasetsStatus,
626
- },
627
- dataservices: {
628
- icon: RiTerminalLine,
629
- name: t('API'),
630
- placeholder: t('ex: SIRENE'),
631
- results: dataservicesResults,
632
- status: dataservicesStatus,
633
- },
634
- reuses: {
635
- icon: RiLineChartLine,
636
- name: t('Réutilisations'),
637
- placeholder: t('Rechercher une réutilisation de données'),
638
- results: reusesResults,
639
- status: reusesStatus,
640
- },
641
- organizations: {
642
- icon: RiBuilding2Line,
643
- name: t('Organisations'),
644
- placeholder: t('Rechercher une organisation'),
645
- results: organizationsResults,
646
- status: organizationsStatus,
647
- },
648
- } as const
649
-
650
- const searchResults = computed(() => typesMeta[currentType.value].results.value)
651
- const searchResultsStatus = computed(() => typesMeta[currentType.value].status.value)
727
+ watch(searchResults, (results) => {
728
+ if (results) emit('resultsCount', results.total)
729
+ }, { immediate: true })
652
730
 
653
731
  // RSS feed URL for datasets
654
732
  const rssUrl = computed(() => {
655
- if (currentType.value !== 'datasets') return null
733
+ if (currentTypeConfig.value?.class !== 'datasets') return null
656
734
 
657
735
  const params = new URLSearchParams()
658
- const datasetsConfig = props.config.find(c => c.class === 'datasets')
659
736
 
660
737
  // Add hidden filters first
661
- if (datasetsConfig?.hiddenFilters) {
662
- for (const hf of datasetsConfig.hiddenFilters) {
738
+ if (currentTypeConfig.value?.hiddenFilters) {
739
+ for (const hf of currentTypeConfig.value.hiddenFilters) {
663
740
  if (hf?.value) params.set(hf.key as string, String(hf.value))
664
741
  }
665
742
  }
666
743
 
667
744
  // Add active filters
668
- if (qDebounced.value) params.set('q', qDebounced.value)
745
+ if (qForParams.value) params.set('q', qForParams.value)
669
746
  if (organizationId.value) params.set('organization', organizationId.value)
670
747
  if (organizationType.value) params.set('organization_badge', organizationType.value)
671
748
  if (tag.value) params.set('tag', tag.value)
@@ -677,6 +754,10 @@ const rssUrl = computed(() => {
677
754
  if (badge.value) params.set('badge', badge.value)
678
755
  if (topic.value) params.set('topic', topic.value)
679
756
 
757
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
758
+ params.set(apiParam, value)
759
+ }, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
760
+
680
761
  // Add sort if set
681
762
  if (sort.value) params.set('sort', sort.value)
682
763
 
@@ -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>