@datagouv/components-next 1.0.2-dev.17 → 1.0.2-dev.170

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 (124) 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-Ds3VQrRi.js → Datafair.client-B-OG5WtK.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-D0c-ifUi.js +36 -0
  8. package/dist/JsonPreview.client-Dwh9sW9M.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-C2AZ7sOB.js} +38 -39
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-8NzNfW8b.js} +823 -855
  13. package/dist/Pmtiles.client-BFH4rTB7.js +26324 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BOgE441d.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-DMOUO508.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 +183 -146
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-C9obT9DJ.js} +21834 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-B4AZU0vr.js} +185 -187
  26. package/dist/{main-vqhkVhVS.js → main-DNoYWfit.js} +40723 -73240
  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-BcNJo35o.js → vue3-xml-viewer.common-Cp7Cu5ME.js} +16 -16
  32. package/package.json +47 -38
  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/InfiniteLoader.vue +53 -0
  47. package/src/components/ObjectCardHeader.vue +11 -4
  48. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  49. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  50. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  51. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  52. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  53. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  54. package/src/components/OpenApiViewer/openapi.ts +150 -0
  55. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  56. package/src/components/Pagination.vue +8 -5
  57. package/src/components/RadioInput.vue +7 -2
  58. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  59. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  60. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  61. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  62. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
  63. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  64. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
  65. package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
  66. package/src/components/ResourceAccordion/Preview.vue +11 -11
  67. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  68. package/src/components/ResourceAccordion/ResourceAccordion.vue +19 -113
  69. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  70. package/src/components/ResourceExplorer/ResourceExplorer.vue +22 -19
  71. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  72. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +64 -148
  73. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  74. package/src/components/ReuseCard.vue +14 -6
  75. package/src/components/ReuseDetails.vue +2 -2
  76. package/src/components/ReuseHorizontalCard.vue +2 -2
  77. package/src/components/Search/GlobalSearch.vue +219 -117
  78. package/src/components/Search/SearchInput.vue +5 -4
  79. package/src/components/TabularExplorer/TabularCell.vue +52 -0
  80. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  81. package/src/components/TabularExplorer/TabularExplorer.vue +1225 -0
  82. package/src/components/TabularExplorer/TabularFilterContent.vue +370 -0
  83. package/src/components/TabularExplorer/TabularFilterPopover.vue +107 -0
  84. package/src/components/TabularExplorer/types.ts +86 -0
  85. package/src/composables/useHasTabularData.ts +15 -1
  86. package/src/composables/useMetrics.ts +1 -1
  87. package/src/composables/useResourceCapabilities.ts +28 -15
  88. package/src/composables/useSearchFilter.ts +120 -0
  89. package/src/composables/useStableQueryParams.ts +38 -6
  90. package/src/composables/useTabularProfile.ts +70 -0
  91. package/src/config.ts +21 -3
  92. package/src/functions/activities.ts +3 -3
  93. package/src/functions/api.ts +20 -42
  94. package/src/functions/api.types.ts +1 -0
  95. package/src/functions/charts.ts +99 -0
  96. package/src/functions/datasets.ts +0 -17
  97. package/src/functions/dates.ts +3 -2
  98. package/src/functions/metrics.ts +6 -4
  99. package/src/functions/resources.ts +117 -1
  100. package/src/functions/tabular.ts +214 -0
  101. package/src/functions/tabularApi.ts +96 -11
  102. package/src/main.ts +109 -14
  103. package/src/types/dataservices.ts +17 -0
  104. package/src/types/organizations.ts +10 -0
  105. package/src/types/pages.ts +0 -5
  106. package/src/types/posts.ts +2 -2
  107. package/src/types/reports.ts +5 -1
  108. package/src/types/resources.ts +0 -1
  109. package/src/types/reuses.ts +12 -1
  110. package/src/types/search.ts +63 -1
  111. package/src/types/site.ts +5 -3
  112. package/src/types/ui.ts +2 -0
  113. package/src/types/users.ts +2 -1
  114. package/src/types/visualizations.ts +93 -0
  115. package/assets/swagger-themes/newspaper.css +0 -1670
  116. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  117. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  118. package/dist/Swagger.client-B4y0hXME.js +0 -4
  119. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  120. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  121. package/src/functions/pagination.ts +0 -9
  122. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  123. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  124. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -0,0 +1,370 @@
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) or date filter -->
65
+ <div
66
+ v-if="columnType !== 'boolean'"
67
+ class="px-3 py-2 border-b border-black/10"
68
+ >
69
+ <div class="relative">
70
+ <component
71
+ :is="searchField.icon"
72
+ class="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-gray-medium"
73
+ aria-hidden="true"
74
+ />
75
+ <input
76
+ v-model="search"
77
+ :type="searchField.type"
78
+ 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"
79
+ :placeholder="searchField.placeholder"
80
+ >
81
+ </div>
82
+ </div>
83
+
84
+ <!-- Categorical values -->
85
+ <div
86
+ v-if="columnType === 'categorical' && columnProfile?.tops && filteredTops.length"
87
+ class="max-h-56 overflow-auto p-1"
88
+ >
89
+ <button
90
+ v-for="top in filteredTops"
91
+ :key="top.value"
92
+ 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"
93
+ :class="isValueSelected(top.value) && 'bg-gray-50'"
94
+ @click="toggleValue(top.value)"
95
+ >
96
+ <span
97
+ class="flex size-4 shrink-0 items-center justify-center rounded border"
98
+ :class="isValueSelected(top.value) ? 'border-new-primary bg-new-primary text-white' : 'border-gray-low'"
99
+ >
100
+ <RiCheckLine
101
+ v-if="isValueSelected(top.value)"
102
+ class="size-3"
103
+ aria-hidden="true"
104
+ />
105
+ </span>
106
+ <span class="flex-1 truncate text-left text-xs">
107
+ <span
108
+ v-if="categoryBadgeStyles?.[top.value]"
109
+ class="inline-block rounded font-medium px-2 py-0.5 text-xs"
110
+ :style="{ backgroundColor: categoryBadgeStyles![top.value]!.backgroundColor, color: categoryBadgeStyles![top.value]!.color }"
111
+ >{{ top.value }}</span>
112
+ <template v-else>
113
+ {{ top.value ?? 'null' }}
114
+ </template>
115
+ </span>
116
+ <span class="font-mono text-xs text-gray-low tabular-nums shrink-0">{{ top.count }}</span>
117
+ </button>
118
+ </div>
119
+
120
+ <!-- Boolean filter -->
121
+ <div
122
+ v-if="columnType === 'boolean'"
123
+ class="px-3 py-3 space-y-1.5"
124
+ >
125
+ <button
126
+ class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-colors"
127
+ :class="booleanFilter === true ? 'bg-new-primary text-white' : 'bg-gray-some text-gray-title hover:bg-gray-default'"
128
+ @click="toggleBooleanFilter(true)"
129
+ >
130
+ <span
131
+ class="size-2.5 rounded-full shrink-0"
132
+ :class="booleanFilter === true ? 'bg-new-success-light' : 'bg-new-success'"
133
+ />
134
+ <span class="flex-1 text-left">{{ t('Vrai') }}</span>
135
+ <span
136
+ v-if="booleanCounts"
137
+ class="font-mono tabular-nums text-xs"
138
+ :class="booleanFilter === true ? 'text-white/70' : 'text-gray-low'"
139
+ >{{ booleanCounts.trueCount }}</span>
140
+ </button>
141
+ <button
142
+ class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-colors"
143
+ :class="booleanFilter === false ? 'bg-new-primary text-white' : 'bg-gray-some text-gray-title hover:bg-gray-default'"
144
+ @click="toggleBooleanFilter(false)"
145
+ >
146
+ <span
147
+ class="size-2.5 rounded-full shrink-0"
148
+ :class="booleanFilter === false ? 'bg-new-warning-light' : 'bg-new-error'"
149
+ />
150
+ <span class="flex-1 text-left">{{ t('Faux') }}</span>
151
+ <span
152
+ v-if="booleanCounts"
153
+ class="font-mono tabular-nums text-xs"
154
+ :class="booleanFilter === false ? 'text-white/70' : 'text-gray-low'"
155
+ >{{ booleanCounts.falseCount }}</span>
156
+ </button>
157
+ </div>
158
+
159
+ <!-- Number range -->
160
+ <form
161
+ v-if="(columnType === 'number' || columnType === 'year') && columnProfile"
162
+ class="px-3 py-2 border-b border-black/10 space-y-2"
163
+ @submit.prevent="applyRange"
164
+ >
165
+ <div class="flex items-center gap-2 text-xs text-gray-plain">
166
+ <span class="tabular-nums">{{ columnType === 'year' ? profileMin : formatNumber(profileMin) }}</span>
167
+ <span class="text-gray-medium">—</span>
168
+ <span class="tabular-nums">{{ columnType === 'year' ? profileMax : formatNumber(profileMax) }}</span>
169
+ </div>
170
+ <div class="flex items-center gap-2">
171
+ <input
172
+ v-model.number="rangeMin"
173
+ type="number"
174
+ step="any"
175
+ 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"
176
+ :placeholder="String(profileMin)"
177
+ :min="profileMin"
178
+ :max="profileMax"
179
+ >
180
+ <span class="text-gray-medium text-xs">–</span>
181
+ <input
182
+ v-model.number="rangeMax"
183
+ type="number"
184
+ step="any"
185
+ 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"
186
+ :placeholder="String(profileMax)"
187
+ :min="profileMin"
188
+ :max="profileMax"
189
+ >
190
+ </div>
191
+ <div class="flex items-center gap-2">
192
+ <BrandedButton
193
+ color="primary"
194
+ size="2xs"
195
+ type="submit"
196
+ >
197
+ {{ t('Appliquer') }}
198
+ </BrandedButton>
199
+ <BrandedButton
200
+ color="tertiary"
201
+ size="2xs"
202
+ type="button"
203
+ keep-margins-even-without-borders
204
+ @click="clearRange"
205
+ >
206
+ {{ t('Effacer') }}
207
+ </BrandedButton>
208
+ </div>
209
+ </form>
210
+ </div>
211
+ </template>
212
+
213
+ <script setup lang="ts">
214
+ import { computed, ref } from 'vue'
215
+ import { watchDebounced } from '@vueuse/core'
216
+ import {
217
+ RiArrowUpLine,
218
+ RiArrowDownLine,
219
+ RiSearchLine,
220
+ RiCalendarLine,
221
+ RiCheckLine,
222
+ } from '@remixicon/vue'
223
+ import { useTranslation } from '../../composables/useTranslation'
224
+ import { useFormatTabular } from '../../functions/tabular'
225
+ import BrandedButton from '../BrandedButton.vue'
226
+ import ProgressBar from '../ProgressBar.vue'
227
+ import type { TabularColumnProfile, ColumnType, ColumnFilters, SortConfig, SortDirection, BadgeStyle } from './types'
228
+
229
+ const props = defineProps<{
230
+ column: string
231
+ columnType: ColumnType
232
+ columnProfile: TabularColumnProfile | null
233
+ nullPercent: string
234
+ totalLines: number
235
+ categoryBadgeStyles?: Record<string, BadgeStyle>
236
+ booleanCounts?: { trueCount: number, falseCount: number }
237
+ }>()
238
+
239
+ const sort = defineModel<SortConfig | null>('sort')
240
+ const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
241
+
242
+ const { t } = useTranslation()
243
+ const { formatNumber } = useFormatTabular()
244
+
245
+ const search = ref('')
246
+
247
+ // Numbers, years and dates are matched exactly (the API has no `contains` for
248
+ // them), so the field offers the matching native picker instead of a text search.
249
+ const searchField = computed(() => {
250
+ switch (props.columnType) {
251
+ case 'date': return { icon: RiCalendarLine, type: 'date', placeholder: '' }
252
+ case 'number':
253
+ case 'year': return { icon: RiSearchLine, type: 'number', placeholder: t('Rechercher...') }
254
+ default: return { icon: RiSearchLine, type: 'text', placeholder: t('Rechercher...') }
255
+ }
256
+ })
257
+
258
+ watchDebounced(search, (q) => {
259
+ const existing = filters.value[props.column] ?? {}
260
+ const operator = props.columnType === 'number' || props.columnType === 'year' || props.columnType === 'date' ? 'exact' : 'contains'
261
+ if (q) {
262
+ filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: q } }
263
+ }
264
+ else {
265
+ const { [operator]: _, ...rest } = existing
266
+ filters.value = { ...filters.value, [props.column]: rest }
267
+ }
268
+ }, { debounce: 300 })
269
+
270
+ // Null filter helpers
271
+ const nullFilter = computed(() => filters.value[props.column]?.null ?? null)
272
+
273
+ function toggleNullFilter(mode: 'only' | 'exclude') {
274
+ const existing = filters.value[props.column] ?? {}
275
+ if (existing.null === mode) {
276
+ const { null: _, ...rest } = existing
277
+ filters.value = { ...filters.value, [props.column]: rest }
278
+ }
279
+ else {
280
+ filters.value = { ...filters.value, [props.column]: { ...existing, null: mode } }
281
+ }
282
+ }
283
+
284
+ // Categorical filter helpers
285
+ const selectedValues = computed(() => filters.value[props.column]?.in ?? [])
286
+
287
+ const filteredTops = computed(() => {
288
+ if (!props.columnProfile?.tops) return []
289
+ if (!search.value) return props.columnProfile.tops
290
+ const q = search.value.toLowerCase()
291
+ return props.columnProfile.tops.filter(top =>
292
+ (top.value ?? '').toLowerCase().includes(q),
293
+ )
294
+ })
295
+
296
+ function isValueSelected(value: string) {
297
+ return selectedValues.value.includes(value)
298
+ }
299
+
300
+ function toggleValue(value: string) {
301
+ const current = selectedValues.value
302
+ const next = current.includes(value)
303
+ ? current.filter(v => v !== value)
304
+ : [...current, value]
305
+ const existing = filters.value[props.column] ?? {}
306
+ filters.value = { ...filters.value, [props.column]: { ...existing, in: next } }
307
+ }
308
+
309
+ // Number range filter helpers
310
+ const profileMin = computed(() => props.columnProfile?.min ?? 0)
311
+ const profileMax = computed(() => props.columnProfile?.max ?? 100)
312
+ const rangeMin = ref<number | undefined>(undefined)
313
+ const rangeMax = ref<number | undefined>(undefined)
314
+
315
+ function applyRange() {
316
+ const existing = filters.value[props.column] ?? {}
317
+ const next = { ...existing }
318
+ if (Number.isFinite(rangeMin.value)) {
319
+ next.min = rangeMin.value
320
+ }
321
+ else {
322
+ delete next.min
323
+ }
324
+ if (Number.isFinite(rangeMax.value)) {
325
+ next.max = rangeMax.value
326
+ }
327
+ else {
328
+ delete next.max
329
+ }
330
+ filters.value = { ...filters.value, [props.column]: next }
331
+ }
332
+
333
+ function clearRange() {
334
+ rangeMin.value = undefined
335
+ rangeMax.value = undefined
336
+ const existing = filters.value[props.column]
337
+ if (existing) {
338
+ const { min: _min, max: _max, ...rest } = existing
339
+ filters.value = { ...filters.value, [props.column]: rest }
340
+ }
341
+ }
342
+
343
+ // Boolean filter helpers
344
+ const booleanFilter = computed<boolean | null>(() => {
345
+ const f = filters.value[props.column]
346
+ if (!f || f.exact == null) return null
347
+ return f.exact === 'true'
348
+ })
349
+
350
+ function toggleBooleanFilter(value: boolean) {
351
+ const existing = filters.value[props.column] ?? {}
352
+ const strValue = String(value)
353
+ if (existing.exact === strValue) {
354
+ const { exact: _, ...rest } = existing
355
+ filters.value = { ...filters.value, [props.column]: rest }
356
+ }
357
+ else {
358
+ filters.value = { ...filters.value, [props.column]: { ...existing, exact: strValue } }
359
+ }
360
+ }
361
+
362
+ function toggleSort(direction: SortDirection) {
363
+ if (sort.value?.column === props.column && sort.value.direction === direction) {
364
+ sort.value = null
365
+ }
366
+ else {
367
+ sort.value = { column: props.column, direction }
368
+ }
369
+ }
370
+ </script>
@@ -0,0 +1,107 @@
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
+ :data-testid="`column-filter-${column}`"
27
+ >
28
+ <!-- Title -->
29
+ <div class="flex items-center justify-between px-3 py-2 border-b border-black/10">
30
+ <p class="text-sm font-medium mb-0">
31
+ {{ t('Filtrer') }} : {{ column }}
32
+ </p>
33
+ <BrandedButton
34
+ v-if="hasColumnFilter"
35
+ color="tertiary"
36
+ size="2xs"
37
+ :icon="RiCloseLine"
38
+ @click="clearColumnFilter"
39
+ >
40
+ {{ t('Effacer') }}
41
+ </BrandedButton>
42
+ </div>
43
+
44
+ <TabularFilterContent
45
+ v-model:sort="sort"
46
+ v-model:filters="filters"
47
+ :column="column"
48
+ :column-type="columnType"
49
+ :column-profile="columnProfile"
50
+ :null-percent="nullPercent"
51
+ :total-lines="totalLines"
52
+ :category-badge-styles="categoryBadgeStyles"
53
+ :boolean-counts="booleanCounts"
54
+ />
55
+ </PopoverPanel>
56
+ </Teleport>
57
+ </ClientOnly>
58
+ </Popover>
59
+ </template>
60
+
61
+ <script setup lang="ts">
62
+ import { computed, useTemplateRef } from 'vue'
63
+ import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
64
+ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
65
+ import { RiFilterLine, RiCloseLine } from '@remixicon/vue'
66
+ import { useTranslation } from '../../composables/useTranslation'
67
+ import { hasFilterForColumn as _hasFilterForColumn } from '../../functions/tabular'
68
+ import BrandedButton from '../BrandedButton.vue'
69
+ import ClientOnly from '../ClientOnly.vue'
70
+ import TabularFilterContent from './TabularFilterContent.vue'
71
+ import type { TabularColumnProfile, ColumnType, ColumnFilters, SortConfig, BadgeStyle } from './types'
72
+
73
+ const props = defineProps<{
74
+ column: string
75
+ columnType: ColumnType
76
+ columnProfile: TabularColumnProfile | null
77
+ nullPercent: string
78
+ totalLines: number
79
+ categoryBadgeStyles?: Record<string, BadgeStyle>
80
+ booleanCounts?: { trueCount: number, falseCount: number }
81
+ }>()
82
+
83
+ const sort = defineModel<SortConfig | null>('sort')
84
+ const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
85
+
86
+ const { t } = useTranslation()
87
+
88
+ // Headless UI Popover manages open state, Escape-to-close, outside-click,
89
+ // focus handling and ARIA. floating-ui only positions the teleported panel.
90
+ const anchorComponent = useTemplateRef<InstanceType<typeof Popover>>('anchor')
91
+ const panelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('panel')
92
+ const anchorEl = computed(() => anchorComponent.value?.$el as HTMLElement | undefined)
93
+ const panelEl = computed(() => panelComponent.value?.$el as HTMLElement | undefined)
94
+
95
+ const hasColumnFilter = computed(() => _hasFilterForColumn(filters.value, props.column))
96
+
97
+ function clearColumnFilter() {
98
+ const { [props.column]: _, ...rest } = filters.value
99
+ filters.value = rest
100
+ }
101
+
102
+ const { floatingStyles } = useFloating(anchorEl, panelEl, {
103
+ placement: 'bottom-start',
104
+ middleware: [flip(), shift()],
105
+ whileElementsMounted: autoUpdate,
106
+ })
107
+ </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' | 'year'
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
  }