@datagouv/components-next 1.0.2-dev.16 → 1.0.2-dev.160
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-CTp0KeR2.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-CpOf0A1Y.js +36 -0
- package/dist/JsonPreview.client-DL3IBzvJ.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
- package/dist/{MapContainer.client-DB6p6KF8.js → MapContainer.client-DwyIXqCs.js} +38 -39
- package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-Dr4VcIur.js → PdfPreview.client-BoR_Mszg.js} +823 -855
- package/dist/Pmtiles.client-BpMMDkeP.js +26289 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-Dnz4oSUe.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-C3Y-2Eij.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-B8gyXZY_.js} +21832 -15810
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-BDNw7m8K.js} +185 -187
- package/dist/main-SY-oNn1W.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-CBGfdux9.js} +16 -16
- package/package.json +50 -36
- 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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<!-- Sort -->
|
|
4
|
+
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-black/10 text-xs">
|
|
5
|
+
<span class="text-gray-plain">{{ t('Trier') }}</span>
|
|
6
|
+
<BrandedButton
|
|
7
|
+
:color="sort?.column === column && sort?.direction === 'asc' ? 'primary' : 'tertiary'"
|
|
8
|
+
size="2xs"
|
|
9
|
+
:icon="RiArrowUpLine"
|
|
10
|
+
keep-margins-even-without-borders
|
|
11
|
+
@click="toggleSort('asc')"
|
|
12
|
+
>
|
|
13
|
+
{{ t('Croissant') }}
|
|
14
|
+
</BrandedButton>
|
|
15
|
+
<BrandedButton
|
|
16
|
+
:color="sort?.column === column && sort?.direction === 'desc' ? 'primary' : 'tertiary'"
|
|
17
|
+
size="2xs"
|
|
18
|
+
:icon="RiArrowDownLine"
|
|
19
|
+
keep-margins-even-without-borders
|
|
20
|
+
@click="toggleSort('desc')"
|
|
21
|
+
>
|
|
22
|
+
{{ t('Décroissant') }}
|
|
23
|
+
</BrandedButton>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Null filter with progress bar -->
|
|
27
|
+
<div
|
|
28
|
+
v-if="columnProfile && columnProfile.nb_missing_values > 0"
|
|
29
|
+
class="px-3 py-2 border-b border-black/10 space-y-1.5"
|
|
30
|
+
>
|
|
31
|
+
<div class="flex items-center gap-2">
|
|
32
|
+
<span class="text-xs text-gray-plain whitespace-nowrap">
|
|
33
|
+
<span class="font-mono tabular-nums">{{ columnProfile.nb_missing_values }}</span>
|
|
34
|
+
null
|
|
35
|
+
<span class="text-gray-low">({{ nullPercent }})</span>
|
|
36
|
+
</span>
|
|
37
|
+
<ProgressBar
|
|
38
|
+
:value="columnProfile.nb_missing_values"
|
|
39
|
+
:max="totalLines"
|
|
40
|
+
bar-class="bg-gray-low"
|
|
41
|
+
class="flex-1 !h-1.5 !min-w-0 !border-0 !bg-gray-default"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="flex items-center justify-end gap-0.5">
|
|
45
|
+
<BrandedButton
|
|
46
|
+
:color="nullFilter === 'only' ? 'primary' : 'tertiary'"
|
|
47
|
+
size="2xs"
|
|
48
|
+
keep-margins-even-without-borders
|
|
49
|
+
@click="toggleNullFilter('only')"
|
|
50
|
+
>
|
|
51
|
+
{{ t('seul.') }}
|
|
52
|
+
</BrandedButton>
|
|
53
|
+
<BrandedButton
|
|
54
|
+
:color="nullFilter === 'exclude' ? 'primary' : 'tertiary'"
|
|
55
|
+
size="2xs"
|
|
56
|
+
keep-margins-even-without-borders
|
|
57
|
+
@click="toggleNullFilter('exclude')"
|
|
58
|
+
>
|
|
59
|
+
{{ t('exclure') }}
|
|
60
|
+
</BrandedButton>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<!-- Search (contains) -->
|
|
65
|
+
<div class="px-3 py-2 border-b border-black/10">
|
|
66
|
+
<div class="relative">
|
|
67
|
+
<RiSearchLine
|
|
68
|
+
class="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-gray-medium"
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
/>
|
|
71
|
+
<input
|
|
72
|
+
v-model="search"
|
|
73
|
+
type="text"
|
|
74
|
+
class="w-full h-8 text-sm border border-transparent rounded-lg py-1 pl-8 pr-3 bg-[#f3f3f5] focus:outline-none focus:border-new-primary"
|
|
75
|
+
:placeholder="t('Rechercher...')"
|
|
76
|
+
>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<!-- Categorical values -->
|
|
81
|
+
<div
|
|
82
|
+
v-if="columnType === 'categorical' && columnProfile?.tops && filteredTops.length"
|
|
83
|
+
class="max-h-56 overflow-auto p-1"
|
|
84
|
+
>
|
|
85
|
+
<button
|
|
86
|
+
v-for="top in filteredTops"
|
|
87
|
+
:key="top.value"
|
|
88
|
+
class="flex w-full items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 cursor-pointer text-xs select-none"
|
|
89
|
+
:class="isValueSelected(top.value) && 'bg-gray-50'"
|
|
90
|
+
@click="toggleValue(top.value)"
|
|
91
|
+
>
|
|
92
|
+
<span
|
|
93
|
+
class="flex size-4 shrink-0 items-center justify-center rounded border"
|
|
94
|
+
:class="isValueSelected(top.value) ? 'border-new-primary bg-new-primary text-white' : 'border-gray-low'"
|
|
95
|
+
>
|
|
96
|
+
<RiCheckLine
|
|
97
|
+
v-if="isValueSelected(top.value)"
|
|
98
|
+
class="size-3"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
/>
|
|
101
|
+
</span>
|
|
102
|
+
<span class="flex-1 truncate text-left text-xs">
|
|
103
|
+
<span
|
|
104
|
+
v-if="categoryBadgeStyles?.[top.value]"
|
|
105
|
+
class="inline-block rounded font-medium px-2 py-0.5 text-xs"
|
|
106
|
+
:style="{ backgroundColor: categoryBadgeStyles![top.value]!.backgroundColor, color: categoryBadgeStyles![top.value]!.color }"
|
|
107
|
+
>{{ top.value }}</span>
|
|
108
|
+
<template v-else>
|
|
109
|
+
{{ top.value ?? 'null' }}
|
|
110
|
+
</template>
|
|
111
|
+
</span>
|
|
112
|
+
<span class="font-mono text-xs text-gray-low tabular-nums shrink-0">{{ top.count }}</span>
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<!-- Boolean filter -->
|
|
117
|
+
<div
|
|
118
|
+
v-if="columnType === 'boolean'"
|
|
119
|
+
class="px-3 py-3 space-y-1.5"
|
|
120
|
+
>
|
|
121
|
+
<button
|
|
122
|
+
class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-colors"
|
|
123
|
+
:class="booleanFilter === true ? 'bg-new-primary text-white' : 'bg-gray-some text-gray-title hover:bg-gray-default'"
|
|
124
|
+
@click="toggleBooleanFilter(true)"
|
|
125
|
+
>
|
|
126
|
+
<span
|
|
127
|
+
class="size-2.5 rounded-full shrink-0"
|
|
128
|
+
:class="booleanFilter === true ? 'bg-new-success-light' : 'bg-new-success'"
|
|
129
|
+
/>
|
|
130
|
+
<span class="flex-1 text-left">{{ t('Vrai') }}</span>
|
|
131
|
+
<span
|
|
132
|
+
v-if="booleanCounts"
|
|
133
|
+
class="font-mono tabular-nums text-xs"
|
|
134
|
+
:class="booleanFilter === true ? 'text-white/70' : 'text-gray-low'"
|
|
135
|
+
>{{ booleanCounts.trueCount }}</span>
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-colors"
|
|
139
|
+
:class="booleanFilter === false ? 'bg-new-primary text-white' : 'bg-gray-some text-gray-title hover:bg-gray-default'"
|
|
140
|
+
@click="toggleBooleanFilter(false)"
|
|
141
|
+
>
|
|
142
|
+
<span
|
|
143
|
+
class="size-2.5 rounded-full shrink-0"
|
|
144
|
+
:class="booleanFilter === false ? 'bg-new-warning-light' : 'bg-new-error'"
|
|
145
|
+
/>
|
|
146
|
+
<span class="flex-1 text-left">{{ t('Faux') }}</span>
|
|
147
|
+
<span
|
|
148
|
+
v-if="booleanCounts"
|
|
149
|
+
class="font-mono tabular-nums text-xs"
|
|
150
|
+
:class="booleanFilter === false ? 'text-white/70' : 'text-gray-low'"
|
|
151
|
+
>{{ booleanCounts.falseCount }}</span>
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<!-- Number range -->
|
|
156
|
+
<form
|
|
157
|
+
v-if="columnType === 'number' && columnProfile"
|
|
158
|
+
class="px-3 py-2 border-b border-black/10 space-y-2"
|
|
159
|
+
@submit.prevent="applyRange"
|
|
160
|
+
>
|
|
161
|
+
<div class="flex items-center gap-2 text-xs text-gray-plain">
|
|
162
|
+
<span class="tabular-nums">{{ formatNumber(profileMin) }}</span>
|
|
163
|
+
<span class="text-gray-medium">—</span>
|
|
164
|
+
<span class="tabular-nums">{{ formatNumber(profileMax) }}</span>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="flex items-center gap-2">
|
|
167
|
+
<input
|
|
168
|
+
v-model.number="rangeMin"
|
|
169
|
+
type="number"
|
|
170
|
+
step="any"
|
|
171
|
+
class="w-full h-7 text-xs border border-black/10 rounded px-2 bg-[#f3f3f5] focus:outline-none focus:border-new-primary tabular-nums"
|
|
172
|
+
:placeholder="String(profileMin)"
|
|
173
|
+
:min="profileMin"
|
|
174
|
+
:max="profileMax"
|
|
175
|
+
>
|
|
176
|
+
<span class="text-gray-medium text-xs">–</span>
|
|
177
|
+
<input
|
|
178
|
+
v-model.number="rangeMax"
|
|
179
|
+
type="number"
|
|
180
|
+
step="any"
|
|
181
|
+
class="w-full h-7 text-xs border border-black/10 rounded px-2 bg-[#f3f3f5] focus:outline-none focus:border-new-primary tabular-nums"
|
|
182
|
+
:placeholder="String(profileMax)"
|
|
183
|
+
:min="profileMin"
|
|
184
|
+
:max="profileMax"
|
|
185
|
+
>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="flex items-center gap-2">
|
|
188
|
+
<BrandedButton
|
|
189
|
+
color="primary"
|
|
190
|
+
size="2xs"
|
|
191
|
+
type="submit"
|
|
192
|
+
>
|
|
193
|
+
{{ t('Appliquer') }}
|
|
194
|
+
</BrandedButton>
|
|
195
|
+
<BrandedButton
|
|
196
|
+
color="tertiary"
|
|
197
|
+
size="2xs"
|
|
198
|
+
type="button"
|
|
199
|
+
keep-margins-even-without-borders
|
|
200
|
+
@click="clearRange"
|
|
201
|
+
>
|
|
202
|
+
{{ t('Effacer') }}
|
|
203
|
+
</BrandedButton>
|
|
204
|
+
</div>
|
|
205
|
+
</form>
|
|
206
|
+
</div>
|
|
207
|
+
</template>
|
|
208
|
+
|
|
209
|
+
<script setup lang="ts">
|
|
210
|
+
import { computed, ref } from 'vue'
|
|
211
|
+
import { watchDebounced } from '@vueuse/core'
|
|
212
|
+
import {
|
|
213
|
+
RiArrowUpLine,
|
|
214
|
+
RiArrowDownLine,
|
|
215
|
+
RiSearchLine,
|
|
216
|
+
RiCheckLine,
|
|
217
|
+
} from '@remixicon/vue'
|
|
218
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
219
|
+
import { useFormatTabular } from '../../functions/tabular'
|
|
220
|
+
import BrandedButton from '../BrandedButton.vue'
|
|
221
|
+
import ProgressBar from '../ProgressBar.vue'
|
|
222
|
+
import type { TabularColumnProfile, ColumnType, ColumnFilters, SortConfig, SortDirection, BadgeStyle } from './types'
|
|
223
|
+
|
|
224
|
+
const props = defineProps<{
|
|
225
|
+
column: string
|
|
226
|
+
columnType: ColumnType
|
|
227
|
+
columnProfile: TabularColumnProfile | null
|
|
228
|
+
nullPercent: string
|
|
229
|
+
totalLines: number
|
|
230
|
+
categoryBadgeStyles?: Record<string, BadgeStyle>
|
|
231
|
+
booleanCounts?: { trueCount: number, falseCount: number }
|
|
232
|
+
}>()
|
|
233
|
+
|
|
234
|
+
const sort = defineModel<SortConfig | null>('sort')
|
|
235
|
+
const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
|
|
236
|
+
|
|
237
|
+
const { t } = useTranslation()
|
|
238
|
+
const { formatNumber } = useFormatTabular()
|
|
239
|
+
|
|
240
|
+
const search = ref('')
|
|
241
|
+
|
|
242
|
+
watchDebounced(search, (q) => {
|
|
243
|
+
const existing = filters.value[props.column] ?? {}
|
|
244
|
+
if (q) {
|
|
245
|
+
filters.value = { ...filters.value, [props.column]: { ...existing, contains: q } }
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const { contains: _, ...rest } = existing
|
|
249
|
+
filters.value = { ...filters.value, [props.column]: rest }
|
|
250
|
+
}
|
|
251
|
+
}, { debounce: 300 })
|
|
252
|
+
|
|
253
|
+
// Null filter helpers
|
|
254
|
+
const nullFilter = computed(() => filters.value[props.column]?.null ?? null)
|
|
255
|
+
|
|
256
|
+
function toggleNullFilter(mode: 'only' | 'exclude') {
|
|
257
|
+
const existing = filters.value[props.column] ?? {}
|
|
258
|
+
if (existing.null === mode) {
|
|
259
|
+
const { null: _, ...rest } = existing
|
|
260
|
+
filters.value = { ...filters.value, [props.column]: rest }
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
filters.value = { ...filters.value, [props.column]: { ...existing, null: mode } }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Categorical filter helpers
|
|
268
|
+
const selectedValues = computed(() => filters.value[props.column]?.in ?? [])
|
|
269
|
+
|
|
270
|
+
const filteredTops = computed(() => {
|
|
271
|
+
if (!props.columnProfile?.tops) return []
|
|
272
|
+
if (!search.value) return props.columnProfile.tops
|
|
273
|
+
const q = search.value.toLowerCase()
|
|
274
|
+
return props.columnProfile.tops.filter(top =>
|
|
275
|
+
(top.value ?? '').toLowerCase().includes(q),
|
|
276
|
+
)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
function isValueSelected(value: string) {
|
|
280
|
+
return selectedValues.value.includes(value)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function toggleValue(value: string) {
|
|
284
|
+
const current = selectedValues.value
|
|
285
|
+
const next = current.includes(value)
|
|
286
|
+
? current.filter(v => v !== value)
|
|
287
|
+
: [...current, value]
|
|
288
|
+
const existing = filters.value[props.column] ?? {}
|
|
289
|
+
filters.value = { ...filters.value, [props.column]: { ...existing, in: next } }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Number range filter helpers
|
|
293
|
+
const profileMin = computed(() => props.columnProfile?.min ?? 0)
|
|
294
|
+
const profileMax = computed(() => props.columnProfile?.max ?? 100)
|
|
295
|
+
const rangeMin = ref<number | undefined>(undefined)
|
|
296
|
+
const rangeMax = ref<number | undefined>(undefined)
|
|
297
|
+
|
|
298
|
+
function applyRange() {
|
|
299
|
+
const existing = filters.value[props.column] ?? {}
|
|
300
|
+
const next = { ...existing }
|
|
301
|
+
if (Number.isFinite(rangeMin.value)) {
|
|
302
|
+
next.min = rangeMin.value
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
delete next.min
|
|
306
|
+
}
|
|
307
|
+
if (Number.isFinite(rangeMax.value)) {
|
|
308
|
+
next.max = rangeMax.value
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
delete next.max
|
|
312
|
+
}
|
|
313
|
+
filters.value = { ...filters.value, [props.column]: next }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function clearRange() {
|
|
317
|
+
rangeMin.value = undefined
|
|
318
|
+
rangeMax.value = undefined
|
|
319
|
+
const existing = filters.value[props.column]
|
|
320
|
+
if (existing) {
|
|
321
|
+
const { min: _min, max: _max, ...rest } = existing
|
|
322
|
+
filters.value = { ...filters.value, [props.column]: rest }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Boolean filter helpers
|
|
327
|
+
const booleanFilter = computed<boolean | null>(() => {
|
|
328
|
+
const f = filters.value[props.column]
|
|
329
|
+
if (!f || f.exact == null) return null
|
|
330
|
+
return f.exact === 'true'
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
function toggleBooleanFilter(value: boolean) {
|
|
334
|
+
const existing = filters.value[props.column] ?? {}
|
|
335
|
+
const strValue = String(value)
|
|
336
|
+
if (existing.exact === strValue) {
|
|
337
|
+
const { exact: _, ...rest } = existing
|
|
338
|
+
filters.value = { ...filters.value, [props.column]: rest }
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
filters.value = { ...filters.value, [props.column]: { ...existing, exact: strValue } }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function toggleSort(direction: SortDirection) {
|
|
346
|
+
if (sort.value?.column === props.column && sort.value.direction === direction) {
|
|
347
|
+
sort.value = null
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
sort.value = { column: props.column, direction }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
</script>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Popover
|
|
3
|
+
ref="anchor"
|
|
4
|
+
v-slot="{ open }"
|
|
5
|
+
class="relative shrink-0"
|
|
6
|
+
>
|
|
7
|
+
<PopoverButton
|
|
8
|
+
class="p-0.5 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-new-primary"
|
|
9
|
+
:class="hasColumnFilter ? 'bg-primary text-white' : 'hover:bg-gray-100'"
|
|
10
|
+
>
|
|
11
|
+
<RiFilterLine
|
|
12
|
+
class="size-3.5"
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
/>
|
|
15
|
+
<span class="sr-only">{{ t('Filtrer') }} {{ column }}</span>
|
|
16
|
+
</PopoverButton>
|
|
17
|
+
|
|
18
|
+
<ClientOnly>
|
|
19
|
+
<Teleport to="#tooltips">
|
|
20
|
+
<PopoverPanel
|
|
21
|
+
v-show="open"
|
|
22
|
+
ref="panel"
|
|
23
|
+
static
|
|
24
|
+
class="bg-white border border-black/10 rounded-lg shadow-md w-64 absolute z-[800]"
|
|
25
|
+
:style="floatingStyles"
|
|
26
|
+
>
|
|
27
|
+
<!-- Title -->
|
|
28
|
+
<div class="flex items-center justify-between px-3 py-2 border-b border-black/10">
|
|
29
|
+
<p class="text-sm font-medium mb-0">
|
|
30
|
+
{{ t('Filtrer') }} : {{ column }}
|
|
31
|
+
</p>
|
|
32
|
+
<BrandedButton
|
|
33
|
+
v-if="hasColumnFilter"
|
|
34
|
+
color="tertiary"
|
|
35
|
+
size="2xs"
|
|
36
|
+
:icon="RiCloseLine"
|
|
37
|
+
@click="clearColumnFilter"
|
|
38
|
+
>
|
|
39
|
+
{{ t('Effacer') }}
|
|
40
|
+
</BrandedButton>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<TabularFilterContent
|
|
44
|
+
v-model:sort="sort"
|
|
45
|
+
v-model:filters="filters"
|
|
46
|
+
:column="column"
|
|
47
|
+
:column-type="columnType"
|
|
48
|
+
:column-profile="columnProfile"
|
|
49
|
+
:null-percent="nullPercent"
|
|
50
|
+
:total-lines="totalLines"
|
|
51
|
+
:category-badge-styles="categoryBadgeStyles"
|
|
52
|
+
:boolean-counts="booleanCounts"
|
|
53
|
+
/>
|
|
54
|
+
</PopoverPanel>
|
|
55
|
+
</Teleport>
|
|
56
|
+
</ClientOnly>
|
|
57
|
+
</Popover>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<script setup lang="ts">
|
|
61
|
+
import { computed, useTemplateRef } from 'vue'
|
|
62
|
+
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
63
|
+
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
|
64
|
+
import { RiFilterLine, RiCloseLine } from '@remixicon/vue'
|
|
65
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
66
|
+
import { hasFilterForColumn as _hasFilterForColumn } from '../../functions/tabular'
|
|
67
|
+
import BrandedButton from '../BrandedButton.vue'
|
|
68
|
+
import ClientOnly from '../ClientOnly.vue'
|
|
69
|
+
import TabularFilterContent from './TabularFilterContent.vue'
|
|
70
|
+
import type { TabularColumnProfile, ColumnType, ColumnFilters, SortConfig, BadgeStyle } from './types'
|
|
71
|
+
|
|
72
|
+
const props = defineProps<{
|
|
73
|
+
column: string
|
|
74
|
+
columnType: ColumnType
|
|
75
|
+
columnProfile: TabularColumnProfile | null
|
|
76
|
+
nullPercent: string
|
|
77
|
+
totalLines: number
|
|
78
|
+
categoryBadgeStyles?: Record<string, BadgeStyle>
|
|
79
|
+
booleanCounts?: { trueCount: number, falseCount: number }
|
|
80
|
+
}>()
|
|
81
|
+
|
|
82
|
+
const sort = defineModel<SortConfig | null>('sort')
|
|
83
|
+
const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
|
|
84
|
+
|
|
85
|
+
const { t } = useTranslation()
|
|
86
|
+
|
|
87
|
+
// Headless UI Popover manages open state, Escape-to-close, outside-click,
|
|
88
|
+
// focus handling and ARIA. floating-ui only positions the teleported panel.
|
|
89
|
+
const anchorComponent = useTemplateRef<InstanceType<typeof Popover>>('anchor')
|
|
90
|
+
const panelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('panel')
|
|
91
|
+
const anchorEl = computed(() => anchorComponent.value?.$el as HTMLElement | undefined)
|
|
92
|
+
const panelEl = computed(() => panelComponent.value?.$el as HTMLElement | undefined)
|
|
93
|
+
|
|
94
|
+
const hasColumnFilter = computed(() => _hasFilterForColumn(filters.value, props.column))
|
|
95
|
+
|
|
96
|
+
function clearColumnFilter() {
|
|
97
|
+
const { [props.column]: _, ...rest } = filters.value
|
|
98
|
+
filters.value = rest
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { floatingStyles } = useFloating(anchorEl, panelEl, {
|
|
102
|
+
placement: 'bottom-start',
|
|
103
|
+
middleware: [flip(), shift()],
|
|
104
|
+
whileElementsMounted: autoUpdate,
|
|
105
|
+
})
|
|
106
|
+
</script>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Response from /api/resources/{rid}/data/ */
|
|
2
|
+
export type TabularDataResponse = {
|
|
3
|
+
data: TabularRow[]
|
|
4
|
+
meta: {
|
|
5
|
+
page: number
|
|
6
|
+
page_size: number
|
|
7
|
+
total: number
|
|
8
|
+
}
|
|
9
|
+
links: {
|
|
10
|
+
profile: string
|
|
11
|
+
swagger: string
|
|
12
|
+
next: string | null
|
|
13
|
+
prev: string | null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TabularRow = Record<string, unknown> & { __id: number }
|
|
18
|
+
|
|
19
|
+
/** Response from /api/resources/{rid}/profile/ */
|
|
20
|
+
export type TabularProfileResponse = {
|
|
21
|
+
profile: TabularProfile
|
|
22
|
+
deleted_at: string | null
|
|
23
|
+
dataset_id: string
|
|
24
|
+
indexes: null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TabularProfile = {
|
|
28
|
+
header: string[]
|
|
29
|
+
columns: Record<string, TabularColumnInfo>
|
|
30
|
+
formats: Record<string, string[]>
|
|
31
|
+
profile: Record<string, TabularColumnProfile>
|
|
32
|
+
encoding: string
|
|
33
|
+
separator: string
|
|
34
|
+
categorical: string[]
|
|
35
|
+
total_lines: number
|
|
36
|
+
nb_duplicates: number
|
|
37
|
+
columns_fields: Record<string, TabularColumnInfo>
|
|
38
|
+
columns_labels: Record<string, TabularColumnInfo>
|
|
39
|
+
header_row_idx: number
|
|
40
|
+
heading_columns: number
|
|
41
|
+
trailing_columns: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type TabularColumnInfo = {
|
|
45
|
+
score: number
|
|
46
|
+
format: string
|
|
47
|
+
python_type: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TabularColumnProfile = {
|
|
51
|
+
tops: TabularTopValue[]
|
|
52
|
+
nb_distinct: number
|
|
53
|
+
nb_missing_values: number
|
|
54
|
+
min?: number | string
|
|
55
|
+
max?: number | string
|
|
56
|
+
std?: number
|
|
57
|
+
mean?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type TabularTopValue = {
|
|
61
|
+
value: string
|
|
62
|
+
count: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean'
|
|
66
|
+
|
|
67
|
+
export type ColumnFilters = {
|
|
68
|
+
in?: string[]
|
|
69
|
+
exact?: string
|
|
70
|
+
min?: number
|
|
71
|
+
max?: number
|
|
72
|
+
contains?: string
|
|
73
|
+
null?: 'only' | 'exclude'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type SortDirection = 'asc' | 'desc'
|
|
77
|
+
|
|
78
|
+
export type SortConfig = {
|
|
79
|
+
column: string
|
|
80
|
+
direction: SortDirection
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type BadgeStyle = {
|
|
84
|
+
backgroundColor: string
|
|
85
|
+
color: string
|
|
86
|
+
}
|
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import { useComponentsConfig } from '../config'
|
|
2
|
+
import { hasTabularParsingError } from '../functions/resources'
|
|
2
3
|
import type { Resource } from '../types/resources'
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Composable to determine if a resource has tabular data.
|
|
7
|
+
* This is used to show the "Données" tab for tabular files AND the "Structure des données" tab (for tabular data structure).
|
|
8
|
+
*
|
|
9
|
+
* @returns A function to check if a resource has tabular data.
|
|
10
|
+
*/
|
|
4
11
|
export const useHasTabularData = () => {
|
|
5
12
|
const config = useComponentsConfig()
|
|
6
13
|
|
|
7
14
|
return (resource: Resource) => {
|
|
15
|
+
// Reject resources whose source URL last check failed (>= 400):
|
|
16
|
+
// the tabular-api purges its parquet when the source dies, but
|
|
17
|
+
// `parsing_table` may still be set — leading to a 404 on fetch.
|
|
18
|
+
const checkStatus = resource.extras['check:status']
|
|
19
|
+
const sourceUnreachable = typeof checkStatus === 'number' && checkStatus >= 400
|
|
20
|
+
|
|
8
21
|
return (
|
|
9
22
|
config.tabularApiUrl
|
|
10
23
|
&& resource.extras['analysis:parsing:parsing_table']
|
|
11
|
-
&& !resource
|
|
24
|
+
&& !hasTabularParsingError(resource)
|
|
25
|
+
&& !sourceUnreachable
|
|
12
26
|
&& (config.tabularAllowRemote || resource.filetype === 'file')
|
|
13
27
|
)
|
|
14
28
|
}
|
|
@@ -13,6 +13,6 @@ export function useMetrics() {
|
|
|
13
13
|
getDatasetMetrics: (datasetId: string) => getDatasetMetrics(datasetId, config.metricsApiUrl!),
|
|
14
14
|
getDataserviceMetrics: (dataserviceId: string) => getDataserviceMetrics(dataserviceId, config.metricsApiUrl!),
|
|
15
15
|
getReuseMetrics: (reuseId: string) => getReuseMetrics(reuseId, config.metricsApiUrl!),
|
|
16
|
-
createDatasetsForOrganizationMetricsUrl: (organizationId: string) => createDatasetsForOrganizationMetricsUrl(organizationId, config.metricsApiUrl!, config.apiBase),
|
|
16
|
+
createDatasetsForOrganizationMetricsUrl: (organizationId: string) => createDatasetsForOrganizationMetricsUrl(organizationId, config.metricsApiUrl!, config.apiBase, config.$fetch),
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { computed,
|
|
1
|
+
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
|
|
2
2
|
import { useComponentsConfig } from '../config'
|
|
3
|
-
import { useTranslation } from './useTranslation'
|
|
4
|
-
import { useHasTabularData } from './useHasTabularData'
|
|
5
|
-
import { detectOgcService } from '../functions/resources'
|
|
6
3
|
import { isOrganizationCertified } from '../functions/organizations'
|
|
7
|
-
import type { Resource, WfsMetadata } from '../types/resources'
|
|
8
|
-
import type { Dataset, DatasetV2 } from '../types/datasets'
|
|
9
4
|
import { getWfsExportFormats } from '../functions/resourceCapabilities'
|
|
5
|
+
import { detectOgcService, getParsingErrorMessage, getParsingErrorStep, isImagePreviewFormat } from '../functions/resources'
|
|
6
|
+
import type { Dataset, DatasetV2 } from '../types/datasets'
|
|
7
|
+
import type { Resource, WfsMetadata } from '../types/resources'
|
|
8
|
+
import { useHasTabularData } from './useHasTabularData'
|
|
9
|
+
import { useTranslation } from './useTranslation'
|
|
10
10
|
|
|
11
11
|
const GENERATED_FORMATS = ['parquet', 'pmtiles', 'geojson']
|
|
12
12
|
const URL_FORMATS = ['url', 'doi', 'www:link', 'www:link-1.0-http--link', 'www:link-1.0-http--partners', 'www:link-1.0-http--related', 'www:link-1.0-http--samples']
|
|
@@ -21,7 +21,7 @@ export function useResourceCapabilities(
|
|
|
21
21
|
|
|
22
22
|
const hasPreview = computed(() => {
|
|
23
23
|
const format = toValue(resource).format?.toLowerCase()
|
|
24
|
-
return format === 'json' || format === 'pdf' || format === 'xml'
|
|
24
|
+
return format === 'json' || format === 'pdf' || format === 'xml' || isImagePreviewFormat(format)
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
const hasTabularData = computed(() => {
|
|
@@ -49,6 +49,11 @@ export function useResourceCapabilities(
|
|
|
49
49
|
const ogcService = computed(() => detectOgcService(toValue(resource)))
|
|
50
50
|
const ogcWms = computed(() => ogcService.value === 'wms')
|
|
51
51
|
|
|
52
|
+
// The map is built from a pmtiles export; when that export fails we still want
|
|
53
|
+
// to show the Carte tab, but with the reason it could not be generated.
|
|
54
|
+
const hasPmtilesError = computed(() => getParsingErrorStep(toValue(resource)) === 'pmtiles_export')
|
|
55
|
+
const pmtilesError = computed(() => hasPmtilesError.value ? getParsingErrorMessage(toValue(resource)) : null)
|
|
56
|
+
|
|
52
57
|
const generatedFormats = computed(() => {
|
|
53
58
|
const r = toValue(resource)
|
|
54
59
|
const formats = GENERATED_FORMATS
|
|
@@ -85,21 +90,27 @@ export function useResourceCapabilities(
|
|
|
85
90
|
const r = toValue(resource)
|
|
86
91
|
const options = []
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
const dataTab = { key: 'data', label: hasTabularData.value ? t('Données') : t('Aperçu') }
|
|
94
|
+
const mapTab = { key: 'map', label: t('Carte') }
|
|
95
|
+
const hasMap = hasPmtiles.value || ogcWms.value
|
|
96
|
+
|
|
97
|
+
// The map is the most visual preview: when it is available, show it first.
|
|
98
|
+
// When its generation failed, keep the data preview first and surface the
|
|
99
|
+
// Carte tab (with its error) right after.
|
|
100
|
+
if (hasMap) {
|
|
101
|
+
options.push(mapTab, dataTab)
|
|
102
|
+
}
|
|
103
|
+
else if (hasPmtilesError.value) {
|
|
104
|
+
options.push(dataTab, mapTab)
|
|
90
105
|
}
|
|
91
106
|
else {
|
|
92
|
-
options.push(
|
|
107
|
+
options.push(dataTab)
|
|
93
108
|
}
|
|
94
109
|
|
|
95
110
|
if (hasTabularData.value) {
|
|
96
111
|
options.push({ key: 'data-structure', label: t('Structure des données') })
|
|
97
112
|
}
|
|
98
113
|
|
|
99
|
-
if (hasPmtiles.value || ogcWms.value) {
|
|
100
|
-
options.push({ key: 'map', label: t('Carte') })
|
|
101
|
-
}
|
|
102
|
-
|
|
103
114
|
if (r.description) {
|
|
104
115
|
options.push({ key: 'description', label: t('Description') })
|
|
105
116
|
}
|
|
@@ -108,7 +119,7 @@ export function useResourceCapabilities(
|
|
|
108
119
|
options.push({ key: 'downloads', label: t('Téléchargements') })
|
|
109
120
|
|
|
110
121
|
if (hasTabularData.value) {
|
|
111
|
-
options.push({ key: '
|
|
122
|
+
options.push({ key: 'api', label: t('API') })
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
return options
|
|
@@ -118,6 +129,8 @@ export function useResourceCapabilities(
|
|
|
118
129
|
hasPreview,
|
|
119
130
|
hasTabularData,
|
|
120
131
|
hasPmtiles,
|
|
132
|
+
hasPmtilesError,
|
|
133
|
+
pmtilesError,
|
|
121
134
|
hasDatafairPreview,
|
|
122
135
|
hasOpenAPIPreview,
|
|
123
136
|
ogcService,
|