@datagouv/components-next 1.0.2-dev.16 → 1.0.2-dev.161
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/README.md +7 -6
- package/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-BtQqFBTQ.js → Datafair.client-Bz1l4SLK.js} +4 -4
- package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
- package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
- package/dist/ImagePreview.client-CogDiX0n.js +36 -0
- package/dist/JsonPreview.client-XwLb74J8.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
- package/dist/{MapContainer.client-DB6p6KF8.js → MapContainer.client-DJm7fYDx.js} +38 -39
- package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-Dr4VcIur.js → PdfPreview.client-CoLz0aZp.js} +823 -855
- package/dist/Pmtiles.client-bKmHL2Qg.js +26324 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-CxXJ7rC5.js +58 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
- package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
- package/dist/XmlPreview.client-Baetgqam.js +34 -0
- package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +182 -146
- package/dist/components.css +2 -2
- package/dist/{index-D3rieddI.js → index-Crp28Eov.js} +21834 -15810
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-CRXb3b2x.js} +185 -187
- package/dist/main-Cmq8fQdM.js +73989 -0
- package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-CJXShyPB.js → vue3-xml-viewer.common-H2czi46-.js} +16 -16
- package/package.json +50 -37
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +3 -2
- package/src/components/Chart/ChartViewer.vue +320 -0
- package/src/components/Chart/ChartViewerWrapper.vue +182 -0
- package/src/components/DataserviceCard.vue +9 -0
- package/src/components/DataserviceQuality.vue +82 -0
- package/src/components/DataserviceQualityInline.vue +31 -0
- package/src/components/DataserviceQualityTooltipContent.vue +79 -0
- package/src/components/DatasetCard.vue +9 -4
- package/src/components/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- 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/ObjectCardHeader.vue +11 -4
- 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/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
- package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
- package/src/components/ResourceAccordion/Preview.vue +11 -11
- package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +19 -113
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
- package/src/components/ResourceExplorer/ResourceExplorer.vue +22 -19
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +64 -148
- package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
- package/src/components/ReuseCard.vue +14 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +219 -117
- 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 +1132 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +353 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
- package/src/components/TabularExplorer/types.ts +86 -0
- package/src/composables/useHasTabularData.ts +15 -1
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useResourceCapabilities.ts +28 -15
- package/src/composables/useSearchFilter.ts +120 -0
- package/src/composables/useStableQueryParams.ts +38 -6
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/config.ts +21 -3
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +20 -42
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +95 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/dates.ts +3 -2
- package/src/functions/metrics.ts +6 -4
- package/src/functions/resources.ts +117 -1
- package/src/functions/tabular.ts +171 -0
- package/src/functions/tabularApi.ts +96 -11
- package/src/main.ts +107 -14
- package/src/types/dataservices.ts +17 -0
- package/src/types/organizations.ts +10 -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/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +63 -1
- package/src/types/site.ts +5 -3
- package/src/types/ui.ts +2 -0
- package/src/types/users.ts +2 -1
- package/src/types/visualizations.ts +93 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/JsonPreview.client-DojrmGyU.js +0 -72
- package/dist/Pmtiles.client-DbMjfK00.js +0 -25185
- package/dist/Swagger.client-Db_xYKJv.js +0 -4
- package/dist/XmlPreview.client-CHxSFhd7.js +0 -64
- package/dist/main-D70Xt4a7.js +0 -105854
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
- /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
- /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
@submit.prevent
|
|
6
6
|
>
|
|
7
7
|
<div
|
|
8
|
+
v-if="!hideSearchInput"
|
|
8
9
|
ref="search"
|
|
9
10
|
class="flex flex-wrap items-center justify-between"
|
|
10
11
|
data-cy="search"
|
|
11
12
|
>
|
|
12
13
|
<SearchInput
|
|
13
14
|
v-model="q"
|
|
14
|
-
:placeholder="
|
|
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="typeConfig.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>
|
|
@@ -189,10 +200,13 @@
|
|
|
189
200
|
<div class="fr-col">
|
|
190
201
|
<select
|
|
191
202
|
id="sort-search"
|
|
192
|
-
v-model="
|
|
203
|
+
v-model="effectiveSort"
|
|
193
204
|
class="fr-select text-sm shadow-input-blue!"
|
|
194
205
|
>
|
|
195
|
-
<option
|
|
206
|
+
<option
|
|
207
|
+
v-if="!currentTypeConfig?.defaultSort"
|
|
208
|
+
:value="undefined"
|
|
209
|
+
>
|
|
196
210
|
{{ t('Pertinence') }}
|
|
197
211
|
</option>
|
|
198
212
|
<option
|
|
@@ -221,7 +235,7 @@
|
|
|
221
235
|
<transition mode="out-in">
|
|
222
236
|
<LoadingBlock
|
|
223
237
|
v-slot="{ data: results }"
|
|
224
|
-
:status="searchResultsStatus"
|
|
238
|
+
:status="searchResultsStatus!"
|
|
225
239
|
:data="searchResults"
|
|
226
240
|
>
|
|
227
241
|
<div v-if="results && results.data.length">
|
|
@@ -231,7 +245,7 @@
|
|
|
231
245
|
:key="result.id"
|
|
232
246
|
class="p-0"
|
|
233
247
|
>
|
|
234
|
-
<template v-if="
|
|
248
|
+
<template v-if="currentTypeConfig?.class === 'datasets'">
|
|
235
249
|
<slot
|
|
236
250
|
name="dataset"
|
|
237
251
|
:dataset="result"
|
|
@@ -239,7 +253,7 @@
|
|
|
239
253
|
<DatasetCard :dataset="(result as Dataset)" />
|
|
240
254
|
</slot>
|
|
241
255
|
</template>
|
|
242
|
-
<template v-else-if="
|
|
256
|
+
<template v-else-if="currentTypeConfig?.class === 'dataservices'">
|
|
243
257
|
<slot
|
|
244
258
|
name="dataservice"
|
|
245
259
|
:dataservice="result"
|
|
@@ -247,15 +261,15 @@
|
|
|
247
261
|
<DataserviceCard :dataservice="(result as Dataservice)" />
|
|
248
262
|
</slot>
|
|
249
263
|
</template>
|
|
250
|
-
<template v-else-if="
|
|
264
|
+
<template v-else-if="currentTypeConfig?.class === 'reuses'">
|
|
251
265
|
<slot
|
|
252
266
|
name="reuse"
|
|
253
267
|
:reuse="result"
|
|
254
268
|
>
|
|
255
|
-
<ReuseHorizontalCard :reuse="(result as
|
|
269
|
+
<ReuseHorizontalCard :reuse="(result as ReuseV2)" />
|
|
256
270
|
</slot>
|
|
257
271
|
</template>
|
|
258
|
-
<template v-else-if="
|
|
272
|
+
<template v-else-if="currentTypeConfig?.class === 'organizations'">
|
|
259
273
|
<slot
|
|
260
274
|
name="organization"
|
|
261
275
|
:organization="result"
|
|
@@ -263,6 +277,14 @@
|
|
|
263
277
|
<OrganizationHorizontalCard :organization="(result as Organization)" />
|
|
264
278
|
</slot>
|
|
265
279
|
</template>
|
|
280
|
+
<template v-else-if="currentTypeConfig?.class === 'topics'">
|
|
281
|
+
<slot
|
|
282
|
+
name="topic"
|
|
283
|
+
:topic="result"
|
|
284
|
+
>
|
|
285
|
+
<TopicCard :topic="(result as TopicV2)" />
|
|
286
|
+
</slot>
|
|
287
|
+
</template>
|
|
266
288
|
</li>
|
|
267
289
|
</ul>
|
|
268
290
|
<Pagination
|
|
@@ -271,7 +293,6 @@
|
|
|
271
293
|
:page-size
|
|
272
294
|
:total-results="results.total"
|
|
273
295
|
class="mt-4"
|
|
274
|
-
:link="getLink"
|
|
275
296
|
@change="changePage"
|
|
276
297
|
/>
|
|
277
298
|
</div>
|
|
@@ -333,21 +354,23 @@
|
|
|
333
354
|
</template>
|
|
334
355
|
|
|
335
356
|
<script setup lang="ts">
|
|
336
|
-
import { computed, watch, useTemplateRef, type Ref } from 'vue'
|
|
357
|
+
import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Component, type Ref } from 'vue'
|
|
337
358
|
import { useRouteQuery } from '@vueuse/router'
|
|
338
|
-
import { RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
|
|
359
|
+
import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
|
|
339
360
|
import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
|
|
340
361
|
import { useTranslation } from '../../composables/useTranslation'
|
|
341
362
|
import { useDebouncedRef } from '../../composables/useDebouncedRef'
|
|
363
|
+
import { configKey, forEachActiveCustomFilter, isCustomFilterActive, searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
|
|
342
364
|
import { useStableQueryParams } from '../../composables/useStableQueryParams'
|
|
343
365
|
import { useComponentsConfig } from '../../config'
|
|
344
366
|
import { useFetch } from '../../functions/api'
|
|
345
|
-
import {
|
|
367
|
+
import type { AsyncDataRequestStatus } from '../../functions/api.types'
|
|
346
368
|
import type { Dataset } from '../../types/datasets'
|
|
347
369
|
import type { Dataservice } from '../../types/dataservices'
|
|
348
370
|
import type { Organization } from '../../types/organizations'
|
|
349
|
-
import type {
|
|
350
|
-
import type {
|
|
371
|
+
import type { ReuseV2 } from '../../types/reuses'
|
|
372
|
+
import type { TopicV2 } from '../../types/topics'
|
|
373
|
+
import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
|
|
351
374
|
import { getDefaultGlobalSearchConfig } from '../../types/search'
|
|
352
375
|
import BrandedButton from '../BrandedButton.vue'
|
|
353
376
|
import LoadingBlock from '../LoadingBlock.vue'
|
|
@@ -358,6 +381,7 @@ import DatasetCard from '../DatasetCard.vue'
|
|
|
358
381
|
import DataserviceCard from '../DataserviceCard.vue'
|
|
359
382
|
import OrganizationHorizontalCard from '../OrganizationHorizontalCard.vue'
|
|
360
383
|
import ReuseHorizontalCard from '../ReuseHorizontalCard.vue'
|
|
384
|
+
import TopicCard from '../TopicCard.vue'
|
|
361
385
|
import SearchInput from './SearchInput.vue'
|
|
362
386
|
import Sidemenu from './Sidemenu.vue'
|
|
363
387
|
import BasicAndAdvancedFilters from './BasicAndAdvancedFilters.vue'
|
|
@@ -379,25 +403,49 @@ import ReuseTypeFilter from './Filter/ReuseTypeFilter.vue'
|
|
|
379
403
|
|
|
380
404
|
const props = withDefaults(defineProps<{
|
|
381
405
|
config?: GlobalSearchConfig
|
|
382
|
-
placeholder?: string
|
|
406
|
+
placeholder?: string | null
|
|
407
|
+
hideSearchInput?: boolean
|
|
408
|
+
autoFocus?: boolean
|
|
383
409
|
}>(), {
|
|
384
410
|
config: getDefaultGlobalSearchConfig,
|
|
411
|
+
hideSearchInput: false,
|
|
412
|
+
autoFocus: true,
|
|
385
413
|
})
|
|
386
414
|
|
|
415
|
+
const emit = defineEmits<{
|
|
416
|
+
resultsCount: [total: number]
|
|
417
|
+
}>()
|
|
418
|
+
|
|
387
419
|
// defineModel's default is static and can't depend on props, so we cast and initialize manually
|
|
388
|
-
const currentType = defineModel<
|
|
389
|
-
if (!currentType.value) currentType.value = props.config[0]
|
|
420
|
+
const currentType = defineModel<string>('type') as Ref<string>
|
|
421
|
+
if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
|
|
390
422
|
|
|
391
423
|
const { t } = useTranslation()
|
|
392
424
|
const componentsConfig = useComponentsConfig()
|
|
393
425
|
|
|
426
|
+
// Custom filter registry for useSearchFilter composable
|
|
427
|
+
const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
|
|
428
|
+
// Per-filter watch stoppers: each registered filter gets its own watcher so a
|
|
429
|
+
// value change resets page to 1, but registration itself does not (the value
|
|
430
|
+
// came from the URL, not from a user action).
|
|
431
|
+
const customFilterStops = new Map<string, () => void>()
|
|
432
|
+
|
|
394
433
|
// Initial type is used to determine which fetch should be SSR (non-lazy)
|
|
395
434
|
const initialType = currentType.value
|
|
396
435
|
|
|
397
436
|
const currentTypeConfig = computed(() =>
|
|
398
|
-
props.config.find(c => c
|
|
437
|
+
props.config.find(c => configKey(c) === currentType.value),
|
|
399
438
|
)
|
|
400
439
|
|
|
440
|
+
// Precedence: prop → per-type config → strategy default.
|
|
441
|
+
// null at any level means "no placeholder".
|
|
442
|
+
const resolvedPlaceholder = computed(() => {
|
|
443
|
+
if (props.placeholder !== undefined) return props.placeholder ?? ''
|
|
444
|
+
const cfg = currentTypeConfig.value
|
|
445
|
+
if (cfg && 'placeholder' in cfg) return cfg.placeholder ?? ''
|
|
446
|
+
return strategies[cfg?.class ?? 'datasets'].placeholder
|
|
447
|
+
})
|
|
448
|
+
|
|
401
449
|
const activeBasicFilters = computed(() =>
|
|
402
450
|
(currentTypeConfig.value?.basicFilters ?? []) as string[],
|
|
403
451
|
)
|
|
@@ -431,13 +479,35 @@ const activeFilters = computed(() => [
|
|
|
431
479
|
...(currentTypeConfig.value?.advancedFilters ?? []),
|
|
432
480
|
] as string[])
|
|
433
481
|
|
|
434
|
-
const
|
|
482
|
+
const slots = useSlots()
|
|
483
|
+
const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
|
|
435
484
|
|
|
436
485
|
// URL query params
|
|
437
486
|
const q = useRouteQuery<string>('q', '')
|
|
438
487
|
const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsConfig.searchDebounce ?? 300)
|
|
488
|
+
// When the search input is hidden, the parent owns the input and is expected
|
|
489
|
+
// to debounce user typing itself (otherwise typing would land in the URL
|
|
490
|
+
// instantly via v-model and stack two debounces). Bypass the internal debounce
|
|
491
|
+
// so URL-driven q changes hit the fetch params immediately.
|
|
492
|
+
const qForParams = computed(() => props.hideSearchInput ? q.value : qDebounced.value)
|
|
439
493
|
const page = useRouteQuery('page', 1, { transform: Number })
|
|
440
494
|
const sort = useRouteQuery<string | undefined>('sort')
|
|
495
|
+
const effectiveSort = computed({
|
|
496
|
+
get: () => sort.value ?? currentTypeConfig.value?.defaultSort,
|
|
497
|
+
set: (value) => { sort.value = value },
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
provide(searchFilterContextKey, {
|
|
501
|
+
register(urlParam, entry) {
|
|
502
|
+
customFilterRegistry.set(urlParam, entry)
|
|
503
|
+
customFilterStops.set(urlParam, watch(entry.ref, () => page.value = 1))
|
|
504
|
+
},
|
|
505
|
+
unregister(urlParam) {
|
|
506
|
+
customFilterStops.get(urlParam)?.()
|
|
507
|
+
customFilterStops.delete(urlParam)
|
|
508
|
+
customFilterRegistry.delete(urlParam)
|
|
509
|
+
},
|
|
510
|
+
})
|
|
441
511
|
|
|
442
512
|
// Filter values
|
|
443
513
|
const organizationId = useRouteQuery<string | undefined>('organization')
|
|
@@ -479,63 +549,134 @@ const allFilters: Record<string, Ref<unknown>> = {
|
|
|
479
549
|
type: reuseType,
|
|
480
550
|
}
|
|
481
551
|
|
|
482
|
-
// Reset sort and filters when changing type
|
|
552
|
+
// Reset page, sort and filters when changing type. Every reset below goes
|
|
553
|
+
// through useRouteQuery, so VueUse coalesces them into a single router.replace
|
|
554
|
+
// (its shared _queriesQueue flushes once on nextTick). Custom filters are cleared
|
|
555
|
+
// here too, rather than in useSearchFilter's onunmount, so their URL change joins
|
|
556
|
+
// that same batch instead of racing it with a separate router.replace.
|
|
483
557
|
watch(currentType, () => {
|
|
558
|
+
// page=1 is the default and is dropped from the URL, so only reset when needed.
|
|
559
|
+
if (page.value !== 1) page.value = 1
|
|
560
|
+
|
|
484
561
|
// Reset sort if not valid
|
|
485
562
|
const validSortValues = activeSortOptions.value.map(o => o.value as string)
|
|
486
563
|
if (sort.value && !validSortValues.includes(sort.value)) {
|
|
487
564
|
sort.value = undefined
|
|
488
565
|
}
|
|
489
566
|
|
|
490
|
-
// Reset filters that are not enabled for the new type
|
|
567
|
+
// Reset built-in filters that are not enabled for the new type
|
|
491
568
|
for (const [filterName, filterRef] of Object.entries(allFilters)) {
|
|
492
569
|
if (filterRef.value !== undefined && !activeFilters.value.includes(filterName)) {
|
|
493
570
|
filterRef.value = undefined
|
|
494
571
|
}
|
|
495
572
|
}
|
|
496
|
-
})
|
|
497
573
|
|
|
498
|
-
//
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
574
|
+
// Reset type-scoped custom filters that don't apply to the new type
|
|
575
|
+
for (const entry of customFilterRegistry.values()) {
|
|
576
|
+
if (entry.typeKeys && !entry.typeKeys.includes(currentType.value) && isCustomFilterActive(entry)) {
|
|
577
|
+
entry.ref.value = entry.defaultValue
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
})
|
|
503
581
|
|
|
504
582
|
// Create stable params for each type
|
|
505
583
|
const stableParamsOptions = {
|
|
506
584
|
allFilters,
|
|
507
|
-
|
|
585
|
+
customFilterRegistry,
|
|
586
|
+
q: qForParams,
|
|
508
587
|
sort,
|
|
509
588
|
page,
|
|
510
589
|
pageSize,
|
|
511
590
|
}
|
|
512
591
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
592
|
+
// Discriminated union: each variant carries its own response type so a `class`
|
|
593
|
+
// narrow gives the precise shape of `data.value` (no cast needed).
|
|
594
|
+
type SearchEntry = {
|
|
595
|
+
[K in SearchType]: {
|
|
596
|
+
class: K
|
|
597
|
+
data: Ref<SearchResponseByClass[K] | null>
|
|
598
|
+
status: Ref<AsyncDataRequestStatus>
|
|
599
|
+
}
|
|
600
|
+
}[SearchType]
|
|
601
|
+
|
|
602
|
+
// One strategy per class consolidates everything that varies by class:
|
|
603
|
+
// metadata (icon/name/placeholder), endpoint, and a typed fetch factory.
|
|
604
|
+
type SearchStrategy<C extends SearchType> = {
|
|
605
|
+
url: string
|
|
606
|
+
icon: Component
|
|
607
|
+
name: string
|
|
608
|
+
placeholder: string
|
|
609
|
+
fetch: (
|
|
610
|
+
params: Ref<Record<string, unknown>>,
|
|
611
|
+
server: boolean,
|
|
612
|
+
) => Promise<Extract<SearchEntry, { class: C }>>
|
|
613
|
+
}
|
|
529
614
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
615
|
+
function makeStrategy<C extends SearchType>(
|
|
616
|
+
cls: C,
|
|
617
|
+
meta: Omit<SearchStrategy<C>, 'fetch'>,
|
|
618
|
+
): SearchStrategy<C> {
|
|
619
|
+
return {
|
|
620
|
+
...meta,
|
|
621
|
+
fetch: async (params, server) => {
|
|
622
|
+
const { data, status } = await useFetch<SearchResponseByClass[C]>(
|
|
623
|
+
meta.url,
|
|
624
|
+
{ params, lazy: true, server },
|
|
625
|
+
)
|
|
626
|
+
// Tautologically equivalent to Extract<SearchEntry, { class: C }>, but TS
|
|
627
|
+
// cannot prove it on a generic C, so we assert.
|
|
628
|
+
return { class: cls, data, status } as Extract<SearchEntry, { class: C }>
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
}
|
|
535
632
|
|
|
536
|
-
|
|
633
|
+
const strategies: { [K in SearchType]: SearchStrategy<K> } = {
|
|
634
|
+
datasets: makeStrategy('datasets', {
|
|
635
|
+
url: '/api/2/datasets/search/',
|
|
636
|
+
icon: RiDatabase2Line,
|
|
637
|
+
name: t('Jeux de données'),
|
|
638
|
+
placeholder: t('ex. élections présidentielles'),
|
|
639
|
+
}),
|
|
640
|
+
dataservices: makeStrategy('dataservices', {
|
|
641
|
+
url: '/api/2/dataservices/search/',
|
|
642
|
+
icon: RiTerminalLine,
|
|
643
|
+
name: t('API'),
|
|
644
|
+
placeholder: t('ex: SIRENE'),
|
|
645
|
+
}),
|
|
646
|
+
reuses: makeStrategy('reuses', {
|
|
647
|
+
url: '/api/2/reuses/search/',
|
|
648
|
+
icon: RiLineChartLine,
|
|
649
|
+
name: t('Réutilisations'),
|
|
650
|
+
placeholder: t('Rechercher une réutilisation de données'),
|
|
651
|
+
}),
|
|
652
|
+
organizations: makeStrategy('organizations', {
|
|
653
|
+
url: '/api/2/organizations/search/',
|
|
654
|
+
icon: RiBuilding2Line,
|
|
655
|
+
name: t('Organisations'),
|
|
656
|
+
placeholder: t('Rechercher une organisation'),
|
|
657
|
+
}),
|
|
658
|
+
topics: makeStrategy('topics', {
|
|
659
|
+
url: '/api/2/topics/search/',
|
|
660
|
+
icon: RiBookShelfLine,
|
|
661
|
+
name: t('Thématiques'),
|
|
662
|
+
placeholder: t('Rechercher une thématique'),
|
|
663
|
+
}),
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// One params + fetch per config entry, keyed by configKey
|
|
667
|
+
const resultsMap: Record<string, SearchEntry> = {}
|
|
668
|
+
for (const c of props.config) {
|
|
669
|
+
const key = configKey(c)
|
|
670
|
+
const params = useStableQueryParams({ ...stableParamsOptions, typeConfig: c })
|
|
671
|
+
resultsMap[key] = await strategies[c.class].fetch(params, initialType === key)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Reset page on filter/sort change. Custom filters (registered via
|
|
675
|
+
// useSearchFilter) have their own watchers set up in `provide`, so they're
|
|
676
|
+
// intentionally excluded here to avoid resetting the page when a filter
|
|
677
|
+
// registers with its URL-derived value.
|
|
537
678
|
const filtersForReset = computed(() => ({
|
|
538
|
-
q:
|
|
679
|
+
q: qForParams.value,
|
|
539
680
|
organization: organizationId.value,
|
|
540
681
|
organization_badge: organizationType.value,
|
|
541
682
|
tag: tag.value,
|
|
@@ -573,6 +714,7 @@ const hasFilters = computed(() => {
|
|
|
573
714
|
|| lastUpdateRange.value
|
|
574
715
|
|| producerType.value
|
|
575
716
|
|| reuseType.value
|
|
717
|
+
|| Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
|
|
576
718
|
})
|
|
577
719
|
|
|
578
720
|
const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
|
|
@@ -593,79 +735,35 @@ function resetFilters() {
|
|
|
593
735
|
lastUpdateRange.value = undefined
|
|
594
736
|
producerType.value = undefined
|
|
595
737
|
reuseType.value = undefined
|
|
738
|
+
for (const entry of customFilterRegistry.values()) {
|
|
739
|
+
entry.ref.value = entry.defaultValue
|
|
740
|
+
}
|
|
596
741
|
q.value = ''
|
|
597
742
|
flushQ()
|
|
598
743
|
}
|
|
599
744
|
|
|
600
|
-
|
|
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
|
-
)
|
|
618
|
-
|
|
619
|
-
const typesMeta = {
|
|
620
|
-
datasets: {
|
|
621
|
-
icon: RiDatabase2Line,
|
|
622
|
-
name: t('Jeux de données'),
|
|
623
|
-
placeholder: t('ex. élections présidentielles'),
|
|
624
|
-
results: datasetsResults,
|
|
625
|
-
status: datasetsStatus,
|
|
626
|
-
},
|
|
627
|
-
dataservices: {
|
|
628
|
-
icon: RiTerminalLine,
|
|
629
|
-
name: t('API'),
|
|
630
|
-
placeholder: t('ex: SIRENE'),
|
|
631
|
-
results: dataservicesResults,
|
|
632
|
-
status: dataservicesStatus,
|
|
633
|
-
},
|
|
634
|
-
reuses: {
|
|
635
|
-
icon: RiLineChartLine,
|
|
636
|
-
name: t('Réutilisations'),
|
|
637
|
-
placeholder: t('Rechercher une réutilisation de données'),
|
|
638
|
-
results: reusesResults,
|
|
639
|
-
status: reusesStatus,
|
|
640
|
-
},
|
|
641
|
-
organizations: {
|
|
642
|
-
icon: RiBuilding2Line,
|
|
643
|
-
name: t('Organisations'),
|
|
644
|
-
placeholder: t('Rechercher une organisation'),
|
|
645
|
-
results: organizationsResults,
|
|
646
|
-
status: organizationsStatus,
|
|
647
|
-
},
|
|
648
|
-
} as const
|
|
745
|
+
const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
|
|
746
|
+
const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
|
|
649
747
|
|
|
650
|
-
|
|
651
|
-
|
|
748
|
+
watch(searchResults, (results) => {
|
|
749
|
+
if (results) emit('resultsCount', results.total)
|
|
750
|
+
}, { immediate: true })
|
|
652
751
|
|
|
653
752
|
// RSS feed URL for datasets
|
|
654
753
|
const rssUrl = computed(() => {
|
|
655
|
-
if (
|
|
754
|
+
if (currentTypeConfig.value?.class !== 'datasets') return null
|
|
656
755
|
|
|
657
756
|
const params = new URLSearchParams()
|
|
658
|
-
const datasetsConfig = props.config.find(c => c.class === 'datasets')
|
|
659
757
|
|
|
660
758
|
// Add hidden filters first
|
|
661
|
-
if (
|
|
662
|
-
for (const hf of
|
|
759
|
+
if (currentTypeConfig.value?.hiddenFilters) {
|
|
760
|
+
for (const hf of currentTypeConfig.value.hiddenFilters) {
|
|
663
761
|
if (hf?.value) params.set(hf.key as string, String(hf.value))
|
|
664
762
|
}
|
|
665
763
|
}
|
|
666
764
|
|
|
667
765
|
// Add active filters
|
|
668
|
-
if (
|
|
766
|
+
if (qForParams.value) params.set('q', qForParams.value)
|
|
669
767
|
if (organizationId.value) params.set('organization', organizationId.value)
|
|
670
768
|
if (organizationType.value) params.set('organization_badge', organizationType.value)
|
|
671
769
|
if (tag.value) params.set('tag', tag.value)
|
|
@@ -677,8 +775,12 @@ const rssUrl = computed(() => {
|
|
|
677
775
|
if (badge.value) params.set('badge', badge.value)
|
|
678
776
|
if (topic.value) params.set('topic', topic.value)
|
|
679
777
|
|
|
778
|
+
forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
|
|
779
|
+
params.set(apiParam, value)
|
|
780
|
+
}, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
|
|
781
|
+
|
|
680
782
|
// Add sort if set
|
|
681
|
-
if (
|
|
783
|
+
if (effectiveSort.value) params.set('sort', effectiveSort.value)
|
|
682
784
|
|
|
683
785
|
const queryString = params.toString()
|
|
684
786
|
const basePath = '/api/1/datasets/recent.atom'
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
type="search"
|
|
14
14
|
name="q"
|
|
15
15
|
class="input max-h-12 m-0 rounded-tl shadow-input-blue"
|
|
16
|
-
:aria-label="placeholder
|
|
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>
|