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

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 (83) hide show
  1. package/assets/main.css +4 -0
  2. package/dist/Datafair.client-BzW-ctDf.js +30 -0
  3. package/dist/JsonPreview.client-BfMSzR07.js +40 -0
  4. package/dist/{MapContainer.client-CUmKyByc.js → MapContainer.client-CLs-im9i.js} +34 -39
  5. package/dist/{PdfPreview.client-BVjPxlPu.js → PdfPreview.client-C13PQCU_.js} +822 -865
  6. package/dist/{Pmtiles.client-CRJ56yX2.js → Pmtiles.client-CL7PXXDl.js} +574 -579
  7. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C6XnsZ-7.js +61 -0
  8. package/dist/XmlPreview.client-KaENrbbG.js +34 -0
  9. package/dist/components-next.css +3 -3
  10. package/dist/components-next.js +166 -148
  11. package/dist/components.css +1 -1
  12. package/dist/{index-BZsAZ7iw.js → index-C7WVVGgD.js} +1 -1
  13. package/dist/{main-qc4CO9Kn.js → main-K-42Oe8-.js} +91315 -75834
  14. package/dist/{vue3-xml-viewer.common-CCOV_ohP.js → vue3-xml-viewer.common-sHPSE-jD.js} +1 -1
  15. package/package.json +17 -10
  16. package/src/components/ActivityList/ActivityList.vue +0 -2
  17. package/src/components/Chart/ChartViewer.vue +226 -0
  18. package/src/components/Chart/ChartViewerWrapper.vue +170 -0
  19. package/src/components/Form/Listbox.vue +101 -0
  20. package/src/components/Form/SearchableSelect.vue +2 -1
  21. package/src/components/InfiniteLoader.vue +53 -0
  22. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  23. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  24. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  25. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  26. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  27. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  28. package/src/components/OpenApiViewer/openapi.ts +150 -0
  29. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  30. package/src/components/Pagination.vue +8 -5
  31. package/src/components/ReadMore.vue +1 -1
  32. package/src/components/ResourceAccordion/Datafair.client.vue +4 -10
  33. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -121
  34. package/src/components/ResourceAccordion/MapContainer.client.vue +4 -11
  35. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  36. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -103
  37. package/src/components/ResourceAccordion/Pmtiles.client.vue +5 -10
  38. package/src/components/ResourceAccordion/Preview.vue +16 -21
  39. package/src/components/ResourceAccordion/PreviewLoader.vue +1 -2
  40. package/src/components/ResourceAccordion/PreviewUnavailable.vue +22 -0
  41. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  42. package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -7
  43. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -115
  44. package/src/components/ResourceExplorer/ResourceExplorer.vue +81 -13
  45. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  46. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -11
  47. package/src/components/Search/GlobalSearch.vue +173 -108
  48. package/src/components/Search/SearchInput.vue +3 -3
  49. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  50. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  51. package/src/components/TabularExplorer/TabularExplorer.vue +870 -0
  52. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  53. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  54. package/src/components/TabularExplorer/types.ts +83 -0
  55. package/src/composables/useHasTabularData.ts +6 -0
  56. package/src/composables/useResourceCapabilities.ts +1 -1
  57. package/src/composables/useSearchFilter.ts +118 -0
  58. package/src/composables/useStableQueryParams.ts +31 -3
  59. package/src/config.ts +3 -0
  60. package/src/functions/api.ts +34 -33
  61. package/src/functions/api.types.ts +1 -0
  62. package/src/functions/charts.ts +68 -0
  63. package/src/functions/datasets.ts +0 -17
  64. package/src/functions/resources.ts +56 -1
  65. package/src/functions/tabular.ts +60 -0
  66. package/src/functions/tabularApi.ts +138 -11
  67. package/src/main.ts +55 -7
  68. package/src/types/dataservices.ts +2 -0
  69. package/src/types/pages.ts +0 -5
  70. package/src/types/posts.ts +2 -2
  71. package/src/types/reports.ts +5 -1
  72. package/src/types/search.ts +52 -1
  73. package/src/types/site.ts +5 -3
  74. package/src/types/users.ts +2 -1
  75. package/src/types/visualizations.ts +89 -0
  76. package/assets/swagger-themes/newspaper.css +0 -1670
  77. package/dist/Datafair.client-0UYUu5yf.js +0 -35
  78. package/dist/JsonPreview.client-BrTMBWHZ.js +0 -87
  79. package/dist/Swagger.client-2Yn7iF0A.js +0 -4
  80. package/dist/XmlPreview.client-DxqlVnKu.js +0 -79
  81. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  82. package/src/functions/pagination.ts +0 -9
  83. /package/assets/illustrations/{_microscope.svg → microscope.svg} +0 -0
@@ -5,13 +5,14 @@
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"
15
16
  />
16
17
  </div>
17
18
  <div class="grid grid-cols-12 mt-2 md:mt-5">
@@ -30,23 +31,27 @@
30
31
  >
31
32
  <RadioInput
32
33
  v-for="typeConfig in config"
33
- :key="typeConfig.class"
34
- :value="typeConfig.class"
35
- :count="typesMeta[typeConfig.class].results.value?.total"
36
- :loading="typesMeta[typeConfig.class].status.value === 'pending' || typesMeta[typeConfig.class].status.value === 'idle'"
37
- :icon="typesMeta[typeConfig.class].icon"
34
+ :key="configKey(typeConfig)"
35
+ :value="configKey(typeConfig)"
36
+ :count="resultsMap[configKey(typeConfig)]?.data.value?.total"
37
+ :loading="resultsMap[configKey(typeConfig)]?.status.value === 'pending' || resultsMap[configKey(typeConfig)]?.status.value === 'idle'"
38
+ :icon="strategies[typeConfig.class].icon"
38
39
  >
39
- {{ typeConfig.name || typesMeta[typeConfig.class].name }}
40
+ {{ typeConfig.name || strategies[typeConfig.class].name }}
40
41
  </RadioInput>
41
42
  </RadioGroup>
42
43
  </Sidemenu>
43
44
  </div>
44
45
 
45
- <div v-if="activeFilters.length > 0">
46
+ <div v-if="activeFilters.length > 0 || $slots['custom-filters-top'] || $slots['custom-filters-bottom']">
46
47
  <Sidemenu :button-text="t('Filtres')">
47
48
  <template #title>
48
49
  {{ t('Filtres') }}
49
50
  </template>
51
+ <slot
52
+ name="custom-filters-top"
53
+ :current-type="currentType"
54
+ />
50
55
  <BasicAndAdvancedFilters
51
56
  v-slot="{ isEnabled, getOrder }"
52
57
  :basic-filters="activeBasicFilters"
@@ -123,7 +128,7 @@
123
128
  v-model="producerType"
124
129
  :facets="getFacets('producer_type')"
125
130
  :loading="searchResultsStatus === 'pending'"
126
- :exclude="currentType === 'organizations' ? ['user'] : []"
131
+ :exclude="currentTypeConfig?.class === 'organizations' ? ['user'] : []"
127
132
  :style="{ order: getOrder('producer_type') }"
128
133
  />
129
134
  <DatasetBadgeFilter
@@ -146,6 +151,10 @@
146
151
  :get-order="getOrder"
147
152
  />
148
153
  </BasicAndAdvancedFilters>
154
+ <slot
155
+ name="custom-filters-bottom"
156
+ :current-type="currentType"
157
+ />
149
158
  <div
150
159
  v-if="hasFilters"
151
160
  class="mt-6 text-center"
@@ -175,6 +184,7 @@
175
184
  <p
176
185
  class="fr-col-auto my-0"
177
186
  role="status"
187
+ data-testid="search-result-count"
178
188
  >
179
189
  {{ t("{count} résultats | {count} résultat | {count} résultats", searchResults.total) }}
180
190
  </p>
@@ -221,7 +231,7 @@
221
231
  <transition mode="out-in">
222
232
  <LoadingBlock
223
233
  v-slot="{ data: results }"
224
- :status="searchResultsStatus"
234
+ :status="searchResultsStatus!"
225
235
  :data="searchResults"
226
236
  >
227
237
  <div v-if="results && results.data.length">
@@ -231,7 +241,7 @@
231
241
  :key="result.id"
232
242
  class="p-0"
233
243
  >
234
- <template v-if="currentType === 'datasets'">
244
+ <template v-if="currentTypeConfig?.class === 'datasets'">
235
245
  <slot
236
246
  name="dataset"
237
247
  :dataset="result"
@@ -239,7 +249,7 @@
239
249
  <DatasetCard :dataset="(result as Dataset)" />
240
250
  </slot>
241
251
  </template>
242
- <template v-else-if="currentType === 'dataservices'">
252
+ <template v-else-if="currentTypeConfig?.class === 'dataservices'">
243
253
  <slot
244
254
  name="dataservice"
245
255
  :dataservice="result"
@@ -247,7 +257,7 @@
247
257
  <DataserviceCard :dataservice="(result as Dataservice)" />
248
258
  </slot>
249
259
  </template>
250
- <template v-else-if="currentType === 'reuses'">
260
+ <template v-else-if="currentTypeConfig?.class === 'reuses'">
251
261
  <slot
252
262
  name="reuse"
253
263
  :reuse="result"
@@ -255,7 +265,7 @@
255
265
  <ReuseHorizontalCard :reuse="(result as Reuse)" />
256
266
  </slot>
257
267
  </template>
258
- <template v-else-if="currentType === 'organizations'">
268
+ <template v-else-if="currentTypeConfig?.class === 'organizations'">
259
269
  <slot
260
270
  name="organization"
261
271
  :organization="result"
@@ -263,6 +273,14 @@
263
273
  <OrganizationHorizontalCard :organization="(result as Organization)" />
264
274
  </slot>
265
275
  </template>
276
+ <template v-else-if="currentTypeConfig?.class === 'topics'">
277
+ <slot
278
+ name="topic"
279
+ :topic="result"
280
+ >
281
+ <TopicCard :topic="(result as TopicV2)" />
282
+ </slot>
283
+ </template>
266
284
  </li>
267
285
  </ul>
268
286
  <Pagination
@@ -271,7 +289,6 @@
271
289
  :page-size
272
290
  :total-results="results.total"
273
291
  class="mt-4"
274
- :link="getLink"
275
292
  @change="changePage"
276
293
  />
277
294
  </div>
@@ -333,21 +350,23 @@
333
350
  </template>
334
351
 
335
352
  <script setup lang="ts">
336
- import { computed, watch, useTemplateRef, type Ref } from 'vue'
353
+ import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type Ref } from 'vue'
337
354
  import { useRouteQuery } from '@vueuse/router'
338
- import { RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
355
+ import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
339
356
  import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
340
357
  import { useTranslation } from '../../composables/useTranslation'
341
358
  import { useDebouncedRef } from '../../composables/useDebouncedRef'
359
+ import { configKey, forEachActiveCustomFilter, isCustomFilterActive, searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
342
360
  import { useStableQueryParams } from '../../composables/useStableQueryParams'
343
361
  import { useComponentsConfig } from '../../config'
344
362
  import { useFetch } from '../../functions/api'
345
- import { getLink } from '../../functions/pagination'
363
+ import type { AsyncDataRequestStatus } from '../../functions/api.types'
346
364
  import type { Dataset } from '../../types/datasets'
347
365
  import type { Dataservice } from '../../types/dataservices'
348
366
  import type { Organization } from '../../types/organizations'
349
367
  import type { Reuse } from '../../types/reuses'
350
- import type { GlobalSearchConfig, SearchType, SortOption, DatasetSearchResponse, DataserviceSearchResponse, ReuseSearchResponse, OrganizationSearchResponse, FacetItem } from '../../types/search'
368
+ import type { TopicV2 } from '../../types/topics'
369
+ import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
351
370
  import { getDefaultGlobalSearchConfig } from '../../types/search'
352
371
  import BrandedButton from '../BrandedButton.vue'
353
372
  import LoadingBlock from '../LoadingBlock.vue'
@@ -358,6 +377,7 @@ import DatasetCard from '../DatasetCard.vue'
358
377
  import DataserviceCard from '../DataserviceCard.vue'
359
378
  import OrganizationHorizontalCard from '../OrganizationHorizontalCard.vue'
360
379
  import ReuseHorizontalCard from '../ReuseHorizontalCard.vue'
380
+ import TopicCard from '../TopicCard.vue'
361
381
  import SearchInput from './SearchInput.vue'
362
382
  import Sidemenu from './Sidemenu.vue'
363
383
  import BasicAndAdvancedFilters from './BasicAndAdvancedFilters.vue'
@@ -379,25 +399,43 @@ import ReuseTypeFilter from './Filter/ReuseTypeFilter.vue'
379
399
 
380
400
  const props = withDefaults(defineProps<{
381
401
  config?: GlobalSearchConfig
382
- placeholder?: string
402
+ placeholder?: string | null
403
+ hideSearchInput?: boolean
383
404
  }>(), {
384
405
  config: getDefaultGlobalSearchConfig,
406
+ hideSearchInput: false,
385
407
  })
386
408
 
387
409
  // 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'
410
+ const currentType = defineModel<string>('type') as Ref<string>
411
+ if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
390
412
 
391
413
  const { t } = useTranslation()
392
414
  const componentsConfig = useComponentsConfig()
393
415
 
416
+ // Custom filter registry for useSearchFilter composable
417
+ const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
418
+ // Per-filter watch stoppers: each registered filter gets its own watcher so a
419
+ // value change resets page to 1, but registration itself does not (the value
420
+ // came from the URL, not from a user action).
421
+ const customFilterStops = new Map<string, () => void>()
422
+
394
423
  // Initial type is used to determine which fetch should be SSR (non-lazy)
395
424
  const initialType = currentType.value
396
425
 
397
426
  const currentTypeConfig = computed(() =>
398
- props.config.find(c => c.class === currentType.value),
427
+ props.config.find(c => configKey(c) === currentType.value),
399
428
  )
400
429
 
430
+ // Precedence: prop → per-type config → strategy default.
431
+ // null at any level means "no placeholder".
432
+ const resolvedPlaceholder = computed(() => {
433
+ if (props.placeholder !== undefined) return props.placeholder ?? ''
434
+ const cfg = currentTypeConfig.value
435
+ if (cfg && 'placeholder' in cfg) return cfg.placeholder ?? ''
436
+ return strategies[cfg?.class ?? 'datasets'].placeholder
437
+ })
438
+
401
439
  const activeBasicFilters = computed(() =>
402
440
  (currentTypeConfig.value?.basicFilters ?? []) as string[],
403
441
  )
@@ -431,7 +469,8 @@ const activeFilters = computed(() => [
431
469
  ...(currentTypeConfig.value?.advancedFilters ?? []),
432
470
  ] as string[])
433
471
 
434
- const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0)
472
+ const slots = useSlots()
473
+ const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
435
474
 
436
475
  // URL query params
437
476
  const q = useRouteQuery<string>('q', '')
@@ -439,6 +478,18 @@ const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsCo
439
478
  const page = useRouteQuery('page', 1, { transform: Number })
440
479
  const sort = useRouteQuery<string | undefined>('sort')
441
480
 
481
+ provide(searchFilterContextKey, {
482
+ register(urlParam, entry) {
483
+ customFilterRegistry.set(urlParam, entry)
484
+ customFilterStops.set(urlParam, watch(entry.ref, () => page.value = 1))
485
+ },
486
+ unregister(urlParam) {
487
+ customFilterStops.get(urlParam)?.()
488
+ customFilterStops.delete(urlParam)
489
+ customFilterRegistry.delete(urlParam)
490
+ },
491
+ })
492
+
442
493
  // Filter values
443
494
  const organizationId = useRouteQuery<string | undefined>('organization')
444
495
  const organizationType = useRouteQuery<string | undefined>('organization_badge')
@@ -495,45 +546,102 @@ watch(currentType, () => {
495
546
  }
496
547
  })
497
548
 
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
549
  // Create stable params for each type
505
550
  const stableParamsOptions = {
506
551
  allFilters,
552
+ customFilterRegistry,
507
553
  q: qDebounced,
508
554
  sort,
509
555
  page,
510
556
  pageSize,
511
557
  }
512
558
 
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
- })
559
+ // Discriminated union: each variant carries its own response type so a `class`
560
+ // narrow gives the precise shape of `data.value` (no cast needed).
561
+ type SearchEntry = {
562
+ [K in SearchType]: {
563
+ class: K
564
+ data: Ref<SearchResponseByClass[K] | null>
565
+ status: Ref<AsyncDataRequestStatus>
566
+ }
567
+ }[SearchType]
568
+
569
+ // One strategy per class consolidates everything that varies by class:
570
+ // metadata (icon/name/placeholder), endpoint, and a typed fetch factory.
571
+ type SearchStrategy<C extends SearchType> = {
572
+ url: string
573
+ icon: Component
574
+ name: string
575
+ placeholder: string
576
+ fetch: (
577
+ params: Ref<Record<string, unknown>>,
578
+ server: boolean,
579
+ ) => Promise<Extract<SearchEntry, { class: C }>>
580
+ }
581
+
582
+ function makeStrategy<C extends SearchType>(
583
+ cls: C,
584
+ meta: Omit<SearchStrategy<C>, 'fetch'>,
585
+ ): SearchStrategy<C> {
586
+ return {
587
+ ...meta,
588
+ fetch: async (params, server) => {
589
+ const { data, status } = await useFetch<SearchResponseByClass[C]>(
590
+ meta.url,
591
+ { params, lazy: true, server },
592
+ )
593
+ // Tautologically equivalent to Extract<SearchEntry, { class: C }>, but TS
594
+ // cannot prove it on a generic C, so we assert.
595
+ return { class: cls, data, status } as Extract<SearchEntry, { class: C }>
596
+ },
597
+ }
598
+ }
599
+
600
+ const strategies: { [K in SearchType]: SearchStrategy<K> } = {
601
+ datasets: makeStrategy('datasets', {
602
+ url: '/api/2/datasets/search/',
603
+ icon: RiDatabase2Line,
604
+ name: t('Jeux de données'),
605
+ placeholder: t('ex. élections présidentielles'),
606
+ }),
607
+ dataservices: makeStrategy('dataservices', {
608
+ url: '/api/2/dataservices/search/',
609
+ icon: RiTerminalLine,
610
+ name: t('API'),
611
+ placeholder: t('ex: SIRENE'),
612
+ }),
613
+ reuses: makeStrategy('reuses', {
614
+ url: '/api/2/reuses/search/',
615
+ icon: RiLineChartLine,
616
+ name: t('Réutilisations'),
617
+ placeholder: t('Rechercher une réutilisation de données'),
618
+ }),
619
+ organizations: makeStrategy('organizations', {
620
+ url: '/api/2/organizations/search/',
621
+ icon: RiBuilding2Line,
622
+ name: t('Organisations'),
623
+ placeholder: t('Rechercher une organisation'),
624
+ }),
625
+ topics: makeStrategy('topics', {
626
+ url: '/api/2/topics/search/',
627
+ icon: RiBookShelfLine,
628
+ name: t('Thématiques'),
629
+ placeholder: t('Rechercher une thématique'),
630
+ }),
631
+ }
529
632
 
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)
633
+ // One params + fetch per config entry, keyed by configKey
634
+ const resultsMap: Record<string, SearchEntry> = {}
635
+ for (const c of props.config) {
636
+ const key = configKey(c)
637
+ const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
638
+ resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
639
+ }
535
640
 
536
- // Reset page on filter/sort change
641
+ // Reset page on filter/sort change. Custom filters (registered via
642
+ // useSearchFilter) have their own watchers set up in `provide`, so they're
643
+ // intentionally excluded here to avoid resetting the page when a filter
644
+ // registers with its URL-derived value.
537
645
  const filtersForReset = computed(() => ({
538
646
  q: qDebounced.value,
539
647
  organization: organizationId.value,
@@ -573,6 +681,7 @@ const hasFilters = computed(() => {
573
681
  || lastUpdateRange.value
574
682
  || producerType.value
575
683
  || reuseType.value
684
+ || Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
576
685
  })
577
686
 
578
687
  const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
@@ -593,73 +702,25 @@ function resetFilters() {
593
702
  lastUpdateRange.value = undefined
594
703
  producerType.value = undefined
595
704
  reuseType.value = undefined
705
+ for (const entry of customFilterRegistry.values()) {
706
+ entry.ref.value = entry.defaultValue
707
+ }
596
708
  q.value = ''
597
709
  flushQ()
598
710
  }
599
711
 
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
649
-
650
- const searchResults = computed(() => typesMeta[currentType.value].results.value)
651
- const searchResultsStatus = computed(() => typesMeta[currentType.value].status.value)
712
+ const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
713
+ const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
652
714
 
653
715
  // RSS feed URL for datasets
654
716
  const rssUrl = computed(() => {
655
- if (currentType.value !== 'datasets') return null
717
+ if (currentTypeConfig.value?.class !== 'datasets') return null
656
718
 
657
719
  const params = new URLSearchParams()
658
- const datasetsConfig = props.config.find(c => c.class === 'datasets')
659
720
 
660
721
  // Add hidden filters first
661
- if (datasetsConfig?.hiddenFilters) {
662
- for (const hf of datasetsConfig.hiddenFilters) {
722
+ if (currentTypeConfig.value?.hiddenFilters) {
723
+ for (const hf of currentTypeConfig.value.hiddenFilters) {
663
724
  if (hf?.value) params.set(hf.key as string, String(hf.value))
664
725
  }
665
726
  }
@@ -677,6 +738,10 @@ const rssUrl = computed(() => {
677
738
  if (badge.value) params.set('badge', badge.value)
678
739
  if (topic.value) params.set('topic', topic.value)
679
740
 
741
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
742
+ params.set(apiParam, value)
743
+ }, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
744
+
680
745
  // Add sort if set
681
746
  if (sort.value) params.set('sort', sort.value)
682
747
 
@@ -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"
@@ -38,7 +38,7 @@ import BrandedButton from '../BrandedButton.vue'
38
38
  const q = defineModel<string>({ required: true })
39
39
 
40
40
  withDefaults(defineProps<{
41
- placeholder?: string
41
+ placeholder?: string | null
42
42
  autoFocus?: boolean
43
43
  }>(), {
44
44
  autoFocus: true,
@@ -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>