@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.
Files changed (126) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-BtQqFBTQ.js → Datafair.client-CTp0KeR2.js} +4 -4
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-CpOf0A1Y.js +36 -0
  8. package/dist/JsonPreview.client-DL3IBzvJ.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-DB6p6KF8.js → MapContainer.client-DwyIXqCs.js} +38 -39
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-Dr4VcIur.js → PdfPreview.client-BoR_Mszg.js} +823 -855
  13. package/dist/Pmtiles.client-BpMMDkeP.js +26289 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-Dnz4oSUe.js +58 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-C3Y-2Eij.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +182 -146
  23. package/dist/components.css +2 -2
  24. package/dist/{index-D3rieddI.js → index-B8gyXZY_.js} +21832 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-BDNw7m8K.js} +185 -187
  26. package/dist/main-SY-oNn1W.js +73989 -0
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-CJXShyPB.js → vue3-xml-viewer.common-CBGfdux9.js} +16 -16
  32. package/package.json +50 -36
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +3 -2
  35. package/src/components/Chart/ChartViewer.vue +320 -0
  36. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  37. package/src/components/DataserviceCard.vue +9 -0
  38. package/src/components/DataserviceQuality.vue +82 -0
  39. package/src/components/DataserviceQualityInline.vue +31 -0
  40. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  41. package/src/components/DatasetCard.vue +9 -4
  42. package/src/components/DatasetQuality.vue +11 -11
  43. package/src/components/DatasetQualityInline.vue +2 -2
  44. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  45. package/src/components/Form/Listbox.vue +101 -0
  46. package/src/components/Form/SearchableSelect.vue +2 -1
  47. package/src/components/InfiniteLoader.vue +53 -0
  48. package/src/components/ObjectCardHeader.vue +11 -4
  49. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  50. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  51. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  52. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  53. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  54. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  55. package/src/components/OpenApiViewer/openapi.ts +150 -0
  56. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  57. package/src/components/Pagination.vue +8 -5
  58. package/src/components/RadioInput.vue +7 -2
  59. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  60. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  61. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  62. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  63. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
  64. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  65. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
  66. package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
  67. package/src/components/ResourceAccordion/Preview.vue +11 -11
  68. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  69. package/src/components/ResourceAccordion/ResourceAccordion.vue +19 -113
  70. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  71. package/src/components/ResourceExplorer/ResourceExplorer.vue +22 -19
  72. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  73. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +64 -148
  74. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  75. package/src/components/ReuseCard.vue +14 -6
  76. package/src/components/ReuseDetails.vue +2 -2
  77. package/src/components/ReuseHorizontalCard.vue +2 -2
  78. package/src/components/Search/GlobalSearch.vue +219 -117
  79. package/src/components/Search/SearchInput.vue +5 -4
  80. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  81. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  82. package/src/components/TabularExplorer/TabularExplorer.vue +1132 -0
  83. package/src/components/TabularExplorer/TabularFilterContent.vue +353 -0
  84. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  85. package/src/components/TabularExplorer/types.ts +86 -0
  86. package/src/composables/useHasTabularData.ts +15 -1
  87. package/src/composables/useMetrics.ts +1 -1
  88. package/src/composables/useResourceCapabilities.ts +28 -15
  89. package/src/composables/useSearchFilter.ts +120 -0
  90. package/src/composables/useStableQueryParams.ts +38 -6
  91. package/src/composables/useTabularProfile.ts +70 -0
  92. package/src/config.ts +21 -3
  93. package/src/functions/activities.ts +3 -3
  94. package/src/functions/api.ts +20 -42
  95. package/src/functions/api.types.ts +1 -0
  96. package/src/functions/charts.ts +95 -0
  97. package/src/functions/datasets.ts +0 -17
  98. package/src/functions/dates.ts +3 -2
  99. package/src/functions/metrics.ts +6 -4
  100. package/src/functions/resources.ts +117 -1
  101. package/src/functions/tabular.ts +171 -0
  102. package/src/functions/tabularApi.ts +96 -11
  103. package/src/main.ts +107 -14
  104. package/src/types/dataservices.ts +17 -0
  105. package/src/types/organizations.ts +10 -0
  106. package/src/types/pages.ts +0 -5
  107. package/src/types/posts.ts +2 -2
  108. package/src/types/reports.ts +5 -1
  109. package/src/types/resources.ts +0 -1
  110. package/src/types/reuses.ts +12 -1
  111. package/src/types/search.ts +63 -1
  112. package/src/types/site.ts +5 -3
  113. package/src/types/ui.ts +2 -0
  114. package/src/types/users.ts +2 -1
  115. package/src/types/visualizations.ts +93 -0
  116. package/assets/swagger-themes/newspaper.css +0 -1670
  117. package/dist/JsonPreview.client-DojrmGyU.js +0 -72
  118. package/dist/Pmtiles.client-DbMjfK00.js +0 -25185
  119. package/dist/Swagger.client-Db_xYKJv.js +0 -4
  120. package/dist/XmlPreview.client-CHxSFhd7.js +0 -64
  121. package/dist/main-D70Xt4a7.js +0 -105854
  122. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  123. package/src/functions/pagination.ts +0 -9
  124. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  125. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  126. /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.extras['analysis:parsing:error']
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, toValue, type MaybeRefOrGetter } from 'vue'
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
- if (hasTabularData.value) {
89
- options.push({ key: 'data', label: t('Données') })
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({ key: 'data', label: t('Aperçu') })
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: 'swagger', label: t('Swagger') })
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,