@datagouv/components-next 1.0.2-dev.9 → 1.0.2-dev.91
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.
- package/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-ZFh5ta_U.js} +1 -1
- package/dist/Datafair.client-CyZRNADr.js +30 -0
- package/dist/{Event--kp8kMdJ.js → Event-DSQcW7OF.js} +24 -24
- package/dist/{Image-34hvypZI.js → Image-BijNEG0p.js} +6 -6
- package/dist/JsonPreview.client-C9iaPSmQ.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
- package/dist/MapContainer.client-BuoZ69XO.js +101 -0
- package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
- package/dist/{PdfPreview.client-BVjPxlPu.js → PdfPreview.client-MI0bDghc.js} +822 -865
- package/dist/{Pmtiles.client-CRJ56yX2.js → Pmtiles.client-CaKEYQBc.js} +574 -579
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BKqb6TMw.js +61 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-hJQIqcZm.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-Dcl7oIVu.js} +35 -35
- package/dist/{TileImage-BsXBxMtq.js → TileImage-BJeHipMX.js} +4 -4
- package/dist/{View-BR92hTWP.js → View-xp_P_OHw.js} +412 -401
- package/dist/XmlPreview.client-BVAeNK4n.js +34 -0
- package/dist/{common-PJfpC179.js → common-BjQlan3k.js} +36 -36
- package/dist/components-next.css +6 -6
- package/dist/components-next.js +166 -148
- package/dist/components.css +1 -1
- package/dist/{index-BZsAZ7iw.js → index-BBdS8QKx.js} +32886 -27183
- package/dist/{main-qc4CO9Kn.js → main-Dk_66g-3.js} +91331 -75844
- package/dist/{proj-DsetBcW7.js → proj-CsNo9yH1.js} +532 -512
- package/dist/{tilecoord-Db24Px13.js → tilecoord-A0fLnBZr.js} +28 -28
- package/dist/{vue3-xml-viewer.common-CCOV_ohP.js → vue3-xml-viewer.common-B8dNNkOU.js} +1 -1
- package/package.json +18 -11
- package/src/components/ActivityList/ActivityList.vue +0 -2
- package/src/components/Chart/ChartViewer.vue +226 -0
- package/src/components/Chart/ChartViewerWrapper.vue +170 -0
- package/src/components/Form/Listbox.vue +101 -0
- package/src/components/Form/SearchableSelect.vue +2 -1
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
- package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
- package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
- package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
- package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
- package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
- package/src/components/OpenApiViewer/openapi.ts +150 -0
- package/src/components/OrganizationNameWithCertificate.vue +3 -2
- package/src/components/Pagination.vue +8 -5
- package/src/components/ReadMore.vue +1 -1
- package/src/components/ResourceAccordion/Datafair.client.vue +4 -10
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -121
- package/src/components/ResourceAccordion/MapContainer.client.vue +5 -14
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -103
- package/src/components/ResourceAccordion/Pmtiles.client.vue +5 -10
- package/src/components/ResourceAccordion/Preview.vue +16 -21
- package/src/components/ResourceAccordion/PreviewLoader.vue +1 -2
- package/src/components/ResourceAccordion/PreviewUnavailable.vue +22 -0
- package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -7
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -115
- package/src/components/ResourceExplorer/ResourceExplorer.vue +81 -13
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -11
- package/src/components/Search/GlobalSearch.vue +191 -110
- package/src/components/Search/SearchInput.vue +5 -4
- package/src/components/TabularExplorer/TabularCell.vue +51 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +870 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
- package/src/components/TabularExplorer/types.ts +83 -0
- package/src/composables/useHasTabularData.ts +6 -0
- package/src/composables/useResourceCapabilities.ts +1 -1
- package/src/composables/useSearchFilter.ts +118 -0
- package/src/composables/useStableQueryParams.ts +31 -3
- package/src/config.ts +3 -0
- package/src/functions/api.ts +34 -33
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +68 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/resources.ts +56 -1
- package/src/functions/tabular.ts +60 -0
- package/src/functions/tabularApi.ts +138 -11
- package/src/main.ts +55 -7
- package/src/types/dataservices.ts +2 -0
- package/src/types/pages.ts +0 -5
- package/src/types/posts.ts +2 -2
- package/src/types/reports.ts +5 -1
- package/src/types/search.ts +52 -1
- package/src/types/site.ts +5 -3
- package/src/types/users.ts +2 -1
- package/src/types/visualizations.ts +89 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/Datafair.client-0UYUu5yf.js +0 -35
- package/dist/JsonPreview.client-BrTMBWHZ.js +0 -87
- package/dist/MapContainer.client-CUmKyByc.js +0 -107
- package/dist/Swagger.client-2Yn7iF0A.js +0 -4
- package/dist/XmlPreview.client-DxqlVnKu.js +0 -79
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /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="
|
|
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
|
|
34
|
-
:value="typeConfig
|
|
35
|
-
:count="
|
|
36
|
-
:loading="
|
|
37
|
-
: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 ||
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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 {
|
|
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 {
|
|
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<
|
|
389
|
-
if (!currentType.value) currentType.value = props.config[0]
|
|
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
|
|
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
|
|
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
|
-
|
|
564
|
+
customFilterRegistry,
|
|
565
|
+
q: qForParams,
|
|
508
566
|
sort,
|
|
509
567
|
page,
|
|
510
568
|
pageSize,
|
|
511
569
|
}
|
|
512
570
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
//
|
|
531
|
-
const
|
|
532
|
-
const
|
|
533
|
-
const
|
|
534
|
-
const
|
|
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:
|
|
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
|
-
|
|
601
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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 (
|
|
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 (
|
|
662
|
-
for (const hf of
|
|
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 (
|
|
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
|
|
17
|
-
:placeholder="placeholder
|
|
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>
|