@datagouv/components-next 1.0.2-dev.8 → 1.0.2-dev.81

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 (83) hide show
  1. package/assets/main.css +4 -0
  2. package/dist/Datafair.client-BzW-ctDf.js +30 -0
  3. package/dist/JsonPreview.client-BfMSzR07.js +40 -0
  4. package/dist/{MapContainer.client-DRkAmdOc.js → MapContainer.client-CLs-im9i.js} +35 -38
  5. package/dist/{PdfPreview.client-C-w6-w44.js → PdfPreview.client-C13PQCU_.js} +822 -865
  6. package/dist/{Pmtiles.client-BR7_ldHY.js → Pmtiles.client-CL7PXXDl.js} +574 -579
  7. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C6XnsZ-7.js +61 -0
  8. package/dist/XmlPreview.client-KaENrbbG.js +34 -0
  9. package/dist/components-next.css +3 -3
  10. package/dist/components-next.js +166 -148
  11. package/dist/components.css +1 -1
  12. package/dist/{index-SrYZwgCT.js → index-C7WVVGgD.js} +1 -1
  13. package/dist/{main-B2kXxWRG.js → main-K-42Oe8-.js} +91315 -75834
  14. package/dist/{vue3-xml-viewer.common-BRxsqI9j.js → vue3-xml-viewer.common-sHPSE-jD.js} +1 -1
  15. package/package.json +16 -10
  16. package/src/components/ActivityList/ActivityList.vue +0 -2
  17. package/src/components/Chart/ChartViewer.vue +226 -0
  18. package/src/components/Chart/ChartViewerWrapper.vue +170 -0
  19. package/src/components/Form/Listbox.vue +101 -0
  20. package/src/components/Form/SearchableSelect.vue +2 -1
  21. package/src/components/InfiniteLoader.vue +53 -0
  22. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  23. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  24. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  25. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  26. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  27. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  28. package/src/components/OpenApiViewer/openapi.ts +150 -0
  29. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  30. package/src/components/Pagination.vue +8 -5
  31. package/src/components/ReadMore.vue +1 -1
  32. package/src/components/ResourceAccordion/Datafair.client.vue +4 -10
  33. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -121
  34. package/src/components/ResourceAccordion/MapContainer.client.vue +7 -11
  35. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  36. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -103
  37. package/src/components/ResourceAccordion/Pmtiles.client.vue +5 -10
  38. package/src/components/ResourceAccordion/Preview.vue +16 -21
  39. package/src/components/ResourceAccordion/PreviewLoader.vue +1 -2
  40. package/src/components/ResourceAccordion/PreviewUnavailable.vue +22 -0
  41. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  42. package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -7
  43. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -115
  44. package/src/components/ResourceExplorer/ResourceExplorer.vue +81 -13
  45. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  46. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -11
  47. package/src/components/Search/GlobalSearch.vue +173 -108
  48. package/src/components/Search/SearchInput.vue +3 -3
  49. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  50. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  51. package/src/components/TabularExplorer/TabularExplorer.vue +870 -0
  52. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  53. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  54. package/src/components/TabularExplorer/types.ts +83 -0
  55. package/src/composables/useHasTabularData.ts +6 -0
  56. package/src/composables/useResourceCapabilities.ts +1 -1
  57. package/src/composables/useSearchFilter.ts +118 -0
  58. package/src/composables/useStableQueryParams.ts +31 -3
  59. package/src/config.ts +3 -0
  60. package/src/functions/api.ts +34 -33
  61. package/src/functions/api.types.ts +1 -0
  62. package/src/functions/charts.ts +68 -0
  63. package/src/functions/datasets.ts +0 -17
  64. package/src/functions/resources.ts +56 -1
  65. package/src/functions/tabular.ts +60 -0
  66. package/src/functions/tabularApi.ts +138 -11
  67. package/src/main.ts +55 -7
  68. package/src/types/dataservices.ts +2 -0
  69. package/src/types/pages.ts +0 -5
  70. package/src/types/posts.ts +2 -2
  71. package/src/types/reports.ts +3 -0
  72. package/src/types/search.ts +52 -1
  73. package/src/types/site.ts +5 -3
  74. package/src/types/users.ts +0 -1
  75. package/src/types/visualizations.ts +89 -0
  76. package/assets/swagger-themes/newspaper.css +0 -1670
  77. package/dist/Datafair.client-E5D6ePRC.js +0 -35
  78. package/dist/JsonPreview.client-C-6eBbPw.js +0 -87
  79. package/dist/Swagger.client-D4-F6yEf.js +0 -4
  80. package/dist/XmlPreview.client-Dl2VCgXF.js +0 -79
  81. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  82. package/src/functions/pagination.ts +0 -9
  83. /package/assets/illustrations/{_microscope.svg → microscope.svg} +0 -0
@@ -0,0 +1,351 @@
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="flex items-center gap-2 px-3 py-2 border-b border-black/10"
30
+ >
31
+ <span class="text-xs text-gray-plain whitespace-nowrap">
32
+ <span class="font-mono tabular-nums">{{ columnProfile.nb_missing_values }}</span>
33
+ null
34
+ <span class="text-gray-low">({{ nullPercent }})</span>
35
+ </span>
36
+ <ProgressBar
37
+ :value="columnProfile.nb_missing_values"
38
+ :max="totalLines"
39
+ bar-class="bg-gray-low"
40
+ class="flex-1 !h-1.5 !min-w-0 !border-0 !bg-gray-default"
41
+ />
42
+ <div class="flex items-center gap-0.5">
43
+ <BrandedButton
44
+ :color="nullFilter === 'only' ? 'primary' : 'tertiary'"
45
+ size="2xs"
46
+ keep-margins-even-without-borders
47
+ @click="toggleNullFilter('only')"
48
+ >
49
+ {{ t('seul.') }}
50
+ </BrandedButton>
51
+ <BrandedButton
52
+ :color="nullFilter === 'exclude' ? 'primary' : 'tertiary'"
53
+ size="2xs"
54
+ keep-margins-even-without-borders
55
+ @click="toggleNullFilter('exclude')"
56
+ >
57
+ {{ t('exclure') }}
58
+ </BrandedButton>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- Search (contains) -->
63
+ <div class="px-3 py-2 border-b border-black/10">
64
+ <div class="relative">
65
+ <RiSearchLine
66
+ class="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-gray-medium"
67
+ aria-hidden="true"
68
+ />
69
+ <input
70
+ v-model="search"
71
+ type="text"
72
+ 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"
73
+ :placeholder="t('Rechercher...')"
74
+ >
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Categorical values -->
79
+ <div
80
+ v-if="columnType === 'categorical' && columnProfile?.tops && filteredTops.length"
81
+ class="max-h-56 overflow-auto p-1"
82
+ >
83
+ <button
84
+ v-for="top in filteredTops"
85
+ :key="top.value"
86
+ 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"
87
+ :class="isValueSelected(top.value) && 'bg-gray-50'"
88
+ @click="toggleValue(top.value)"
89
+ >
90
+ <span
91
+ class="flex size-4 shrink-0 items-center justify-center rounded border"
92
+ :class="isValueSelected(top.value) ? 'border-new-primary bg-new-primary text-white' : 'border-gray-low'"
93
+ >
94
+ <RiCheckLine
95
+ v-if="isValueSelected(top.value)"
96
+ class="size-3"
97
+ aria-hidden="true"
98
+ />
99
+ </span>
100
+ <span class="flex-1 truncate text-left text-xs">
101
+ <span
102
+ v-if="categoryBadgeStyles?.[top.value]"
103
+ class="inline-block rounded font-medium px-2 py-0.5 text-xs"
104
+ :style="{ backgroundColor: categoryBadgeStyles![top.value]!.backgroundColor, color: categoryBadgeStyles![top.value]!.color }"
105
+ >{{ top.value }}</span>
106
+ <template v-else>
107
+ {{ top.value ?? 'null' }}
108
+ </template>
109
+ </span>
110
+ <span class="font-mono text-xs text-gray-low tabular-nums shrink-0">{{ top.count }}</span>
111
+ </button>
112
+ </div>
113
+
114
+ <!-- Boolean filter -->
115
+ <div
116
+ v-if="columnType === 'boolean'"
117
+ class="px-3 py-3 space-y-1.5"
118
+ >
119
+ <button
120
+ class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-colors"
121
+ :class="booleanFilter === true ? 'bg-new-primary text-white' : 'bg-gray-some text-gray-title hover:bg-gray-default'"
122
+ @click="toggleBooleanFilter(true)"
123
+ >
124
+ <span
125
+ class="size-2.5 rounded-full shrink-0"
126
+ :class="booleanFilter === true ? 'bg-new-success-light' : 'bg-new-success'"
127
+ />
128
+ <span class="flex-1 text-left">{{ t('Vrai') }}</span>
129
+ <span
130
+ v-if="booleanCounts"
131
+ class="font-mono tabular-nums text-xs"
132
+ :class="booleanFilter === true ? 'text-white/70' : 'text-gray-low'"
133
+ >{{ booleanCounts.trueCount }}</span>
134
+ </button>
135
+ <button
136
+ class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-colors"
137
+ :class="booleanFilter === false ? 'bg-new-primary text-white' : 'bg-gray-some text-gray-title hover:bg-gray-default'"
138
+ @click="toggleBooleanFilter(false)"
139
+ >
140
+ <span
141
+ class="size-2.5 rounded-full shrink-0"
142
+ :class="booleanFilter === false ? 'bg-new-warning-light' : 'bg-new-error'"
143
+ />
144
+ <span class="flex-1 text-left">{{ t('Faux') }}</span>
145
+ <span
146
+ v-if="booleanCounts"
147
+ class="font-mono tabular-nums text-xs"
148
+ :class="booleanFilter === false ? 'text-white/70' : 'text-gray-low'"
149
+ >{{ booleanCounts.falseCount }}</span>
150
+ </button>
151
+ </div>
152
+
153
+ <!-- Number range -->
154
+ <form
155
+ v-if="columnType === 'number' && columnProfile"
156
+ class="px-3 py-2 border-b border-black/10 space-y-2"
157
+ @submit.prevent="applyRange"
158
+ >
159
+ <div class="flex items-center gap-2 text-xs text-gray-plain">
160
+ <span class="tabular-nums">{{ formatNumber(profileMin) }}</span>
161
+ <span class="text-gray-medium">—</span>
162
+ <span class="tabular-nums">{{ formatNumber(profileMax) }}</span>
163
+ </div>
164
+ <div class="flex items-center gap-2">
165
+ <input
166
+ v-model.number="rangeMin"
167
+ type="number"
168
+ step="any"
169
+ 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"
170
+ :placeholder="String(profileMin)"
171
+ :min="profileMin"
172
+ :max="profileMax"
173
+ >
174
+ <span class="text-gray-medium text-xs">–</span>
175
+ <input
176
+ v-model.number="rangeMax"
177
+ type="number"
178
+ step="any"
179
+ 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"
180
+ :placeholder="String(profileMax)"
181
+ :min="profileMin"
182
+ :max="profileMax"
183
+ >
184
+ </div>
185
+ <div class="flex items-center gap-2">
186
+ <BrandedButton
187
+ color="primary"
188
+ size="2xs"
189
+ type="submit"
190
+ >
191
+ {{ t('Appliquer') }}
192
+ </BrandedButton>
193
+ <BrandedButton
194
+ color="tertiary"
195
+ size="2xs"
196
+ type="button"
197
+ keep-margins-even-without-borders
198
+ @click="clearRange"
199
+ >
200
+ {{ t('Effacer') }}
201
+ </BrandedButton>
202
+ </div>
203
+ </form>
204
+ </div>
205
+ </template>
206
+
207
+ <script setup lang="ts">
208
+ import { computed, ref } from 'vue'
209
+ import { watchDebounced } from '@vueuse/core'
210
+ import {
211
+ RiArrowUpLine,
212
+ RiArrowDownLine,
213
+ RiSearchLine,
214
+ RiCheckLine,
215
+ } from '@remixicon/vue'
216
+ import { useTranslation } from '../../composables/useTranslation'
217
+ import { useFormatTabular } from '../../functions/tabular'
218
+ import BrandedButton from '../BrandedButton.vue'
219
+ import ProgressBar from '../ProgressBar.vue'
220
+ import type { TabularColumnProfile, ColumnType, ColumnFilters, SortConfig, SortDirection, BadgeStyle } from './types'
221
+
222
+ const props = defineProps<{
223
+ column: string
224
+ columnType: ColumnType
225
+ columnProfile: TabularColumnProfile | null
226
+ nullPercent: string
227
+ totalLines: number
228
+ categoryBadgeStyles?: Record<string, BadgeStyle>
229
+ booleanCounts?: { trueCount: number, falseCount: number }
230
+ }>()
231
+
232
+ const sort = defineModel<SortConfig | null>('sort')
233
+ const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
234
+
235
+ const { t } = useTranslation()
236
+ const { formatNumber } = useFormatTabular()
237
+
238
+ const search = ref('')
239
+
240
+ watchDebounced(search, (q) => {
241
+ const existing = filters.value[props.column] ?? {}
242
+ if (q) {
243
+ filters.value = { ...filters.value, [props.column]: { ...existing, contains: q } }
244
+ }
245
+ else {
246
+ const { contains: _, ...rest } = existing
247
+ filters.value = { ...filters.value, [props.column]: rest }
248
+ }
249
+ }, { debounce: 300 })
250
+
251
+ // Null filter helpers
252
+ const nullFilter = computed(() => filters.value[props.column]?.null ?? null)
253
+
254
+ function toggleNullFilter(mode: 'only' | 'exclude') {
255
+ const existing = filters.value[props.column] ?? {}
256
+ if (existing.null === mode) {
257
+ const { null: _, ...rest } = existing
258
+ filters.value = { ...filters.value, [props.column]: rest }
259
+ }
260
+ else {
261
+ filters.value = { ...filters.value, [props.column]: { ...existing, null: mode } }
262
+ }
263
+ }
264
+
265
+ // Categorical filter helpers
266
+ const selectedValues = computed(() => filters.value[props.column]?.in ?? [])
267
+
268
+ const filteredTops = computed(() => {
269
+ if (!props.columnProfile?.tops) return []
270
+ if (!search.value) return props.columnProfile.tops
271
+ const q = search.value.toLowerCase()
272
+ return props.columnProfile.tops.filter(top =>
273
+ (top.value ?? '').toLowerCase().includes(q),
274
+ )
275
+ })
276
+
277
+ function isValueSelected(value: string) {
278
+ return selectedValues.value.includes(value)
279
+ }
280
+
281
+ function toggleValue(value: string) {
282
+ const current = selectedValues.value
283
+ const next = current.includes(value)
284
+ ? current.filter(v => v !== value)
285
+ : [...current, value]
286
+ const existing = filters.value[props.column] ?? {}
287
+ filters.value = { ...filters.value, [props.column]: { ...existing, in: next } }
288
+ }
289
+
290
+ // Number range filter helpers
291
+ const profileMin = computed(() => props.columnProfile?.min ?? 0)
292
+ const profileMax = computed(() => props.columnProfile?.max ?? 100)
293
+ const rangeMin = ref<number | undefined>(undefined)
294
+ const rangeMax = ref<number | undefined>(undefined)
295
+
296
+ function applyRange() {
297
+ const existing = filters.value[props.column] ?? {}
298
+ const next = { ...existing }
299
+ if (Number.isFinite(rangeMin.value)) {
300
+ next.min = rangeMin.value
301
+ }
302
+ else {
303
+ delete next.min
304
+ }
305
+ if (Number.isFinite(rangeMax.value)) {
306
+ next.max = rangeMax.value
307
+ }
308
+ else {
309
+ delete next.max
310
+ }
311
+ filters.value = { ...filters.value, [props.column]: next }
312
+ }
313
+
314
+ function clearRange() {
315
+ rangeMin.value = undefined
316
+ rangeMax.value = undefined
317
+ const existing = filters.value[props.column]
318
+ if (existing) {
319
+ const { min: _min, max: _max, ...rest } = existing
320
+ filters.value = { ...filters.value, [props.column]: rest }
321
+ }
322
+ }
323
+
324
+ // Boolean filter helpers
325
+ const booleanFilter = computed<boolean | null>(() => {
326
+ const f = filters.value[props.column]
327
+ if (!f || f.exact == null) return null
328
+ return f.exact === 'true'
329
+ })
330
+
331
+ function toggleBooleanFilter(value: boolean) {
332
+ const existing = filters.value[props.column] ?? {}
333
+ const strValue = String(value)
334
+ if (existing.exact === strValue) {
335
+ const { exact: _, ...rest } = existing
336
+ filters.value = { ...filters.value, [props.column]: rest }
337
+ }
338
+ else {
339
+ filters.value = { ...filters.value, [props.column]: { ...existing, exact: strValue } }
340
+ }
341
+ }
342
+
343
+ function toggleSort(direction: SortDirection) {
344
+ if (sort.value?.column === props.column && sort.value.direction === direction) {
345
+ sort.value = null
346
+ }
347
+ else {
348
+ sort.value = { column: props.column, direction }
349
+ }
350
+ }
351
+ </script>
@@ -0,0 +1,111 @@
1
+ <template>
2
+ <div
3
+ ref="anchor"
4
+ class="relative shrink-0"
5
+ >
6
+ <button
7
+ class="p-0.5 rounded focus:outline-none"
8
+ :class="hasColumnFilter ? 'bg-primary text-white' : 'hover:bg-gray-100'"
9
+ @click.stop="togglePopover"
10
+ >
11
+ <RiFilter2Line
12
+ class="size-3.5"
13
+ aria-hidden="true"
14
+ />
15
+ <span class="sr-only">{{ t('Filtrer') }} {{ column }}</span>
16
+ </button>
17
+
18
+ <ClientOnly>
19
+ <Teleport to="#tooltips">
20
+ <div
21
+ v-show="isOpen"
22
+ ref="panel"
23
+ class="bg-white border border-black/10 rounded-lg shadow-md w-64 absolute z-[800]"
24
+ :style="floatingStyles"
25
+ >
26
+ <!-- Title -->
27
+ <div class="flex items-center justify-between px-3 py-2 border-b border-black/10">
28
+ <p class="text-sm font-medium mb-0">
29
+ {{ t('Filtrer') }} : {{ column }}
30
+ </p>
31
+ <BrandedButton
32
+ v-if="hasColumnFilter"
33
+ color="tertiary"
34
+ size="2xs"
35
+ :icon="RiCloseLine"
36
+ @click="clearColumnFilter"
37
+ >
38
+ {{ t('Effacer') }}
39
+ </BrandedButton>
40
+ </div>
41
+
42
+ <TabularFilterContent
43
+ v-model:sort="sort"
44
+ v-model:filters="filters"
45
+ :column="column"
46
+ :column-type="columnType"
47
+ :column-profile="columnProfile"
48
+ :null-percent="nullPercent"
49
+ :total-lines="totalLines"
50
+ :category-badge-styles="categoryBadgeStyles"
51
+ :boolean-counts="booleanCounts"
52
+ />
53
+ </div>
54
+ </Teleport>
55
+ </ClientOnly>
56
+ </div>
57
+ </template>
58
+
59
+ <script setup lang="ts">
60
+ import { computed, ref, useTemplateRef } from 'vue'
61
+ import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
62
+ import { onClickOutside } from '@vueuse/core'
63
+ import { RiFilter2Line, RiCloseLine } from '@remixicon/vue'
64
+ import { useTranslation } from '../../composables/useTranslation'
65
+ import { hasFilterForColumn as _hasFilterForColumn } from '../../functions/tabular'
66
+ import BrandedButton from '../BrandedButton.vue'
67
+ import ClientOnly from '../ClientOnly.vue'
68
+ import TabularFilterContent from './TabularFilterContent.vue'
69
+ import type { TabularColumnProfile, ColumnType, ColumnFilters, SortConfig, BadgeStyle } from './types'
70
+
71
+ const props = defineProps<{
72
+ column: string
73
+ columnType: ColumnType
74
+ columnProfile: TabularColumnProfile | null
75
+ nullPercent: string
76
+ totalLines: number
77
+ categoryBadgeStyles?: Record<string, BadgeStyle>
78
+ booleanCounts?: { trueCount: number, falseCount: number }
79
+ }>()
80
+
81
+ const sort = defineModel<SortConfig | null>('sort')
82
+ const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
83
+
84
+ const { t } = useTranslation()
85
+
86
+ const isOpen = ref(false)
87
+
88
+ function togglePopover() {
89
+ isOpen.value = !isOpen.value
90
+ }
91
+
92
+ const anchorRef = useTemplateRef<HTMLElement>('anchor')
93
+ const panelRef = useTemplateRef<HTMLElement>('panel')
94
+
95
+ onClickOutside(panelRef, () => {
96
+ isOpen.value = false
97
+ }, { ignore: [anchorRef] })
98
+
99
+ const hasColumnFilter = computed(() => _hasFilterForColumn(filters.value, props.column))
100
+
101
+ function clearColumnFilter() {
102
+ const { [props.column]: _, ...rest } = filters.value
103
+ filters.value = rest
104
+ }
105
+
106
+ const { floatingStyles } = useFloating(anchorRef, panelRef, {
107
+ placement: 'bottom-start',
108
+ middleware: [flip(), shift()],
109
+ whileElementsMounted: autoUpdate,
110
+ })
111
+ </script>
@@ -0,0 +1,83 @@
1
+ /** Response from /api/resources/{rid}/data/ */
2
+ export interface 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 interface TabularProfileResponse {
21
+ profile: TabularProfile
22
+ }
23
+
24
+ export interface TabularProfile {
25
+ header: string[]
26
+ columns: Record<string, TabularColumnInfo>
27
+ formats: Record<string, string[]>
28
+ profile: Record<string, TabularColumnProfile>
29
+ encoding: string
30
+ separator: string
31
+ categorical: string[]
32
+ total_lines: number
33
+ nb_duplicates: number
34
+ columns_fields: unknown
35
+ columns_labels: unknown
36
+ header_row_idx: number
37
+ heading_columns: number
38
+ trailing_columns: number
39
+ }
40
+
41
+ export interface TabularColumnInfo {
42
+ score: number
43
+ format: string
44
+ python_type: string
45
+ }
46
+
47
+ export interface TabularColumnProfile {
48
+ tops: TabularTopValue[]
49
+ nb_distinct: number
50
+ nb_missing_values: number
51
+ min?: number
52
+ max?: number
53
+ std?: number
54
+ mean?: number
55
+ }
56
+
57
+ export interface TabularTopValue {
58
+ value: string
59
+ count: number
60
+ }
61
+
62
+ export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean'
63
+
64
+ export interface ColumnFilters {
65
+ in?: string[]
66
+ exact?: string
67
+ min?: number
68
+ max?: number
69
+ contains?: string
70
+ null?: 'only' | 'exclude'
71
+ }
72
+
73
+ export type SortDirection = 'asc' | 'desc'
74
+
75
+ export interface SortConfig {
76
+ column: string
77
+ direction: SortDirection
78
+ }
79
+
80
+ export interface BadgeStyle {
81
+ backgroundColor: string
82
+ color: string
83
+ }
@@ -1,6 +1,12 @@
1
1
  import { useComponentsConfig } from '../config'
2
2
  import type { Resource } from '../types/resources'
3
3
 
4
+ /**
5
+ * Composable to determine if a resource has tabular data.
6
+ * This is used to show the "Données" tab for tabular files AND the "Structure des données" tab (for tabular data structure).
7
+ *
8
+ * @returns A function to check if a resource has tabular data.
9
+ */
4
10
  export const useHasTabularData = () => {
5
11
  const config = useComponentsConfig()
6
12
 
@@ -88,7 +88,7 @@ export function useResourceCapabilities(
88
88
  if (hasTabularData.value) {
89
89
  options.push({ key: 'data', label: t('Données') })
90
90
  }
91
- else if (hasPreview.value || hasDatafairPreview.value || hasOpenAPIPreview.value) {
91
+ else {
92
92
  options.push({ key: 'data', label: t('Aperçu') })
93
93
  }
94
94
 
@@ -0,0 +1,118 @@
1
+ import { type InjectionKey, type Ref, inject, onMounted, onScopeDispose } from 'vue'
2
+ import { useRoute, useRouter } from 'vue-router'
3
+ import { useRouteQuery } from '@vueuse/router'
4
+ import type { SearchTypeConfig } from '../types/search'
5
+
6
+ export function configKey(c: SearchTypeConfig): string {
7
+ return c.key ?? c.class
8
+ }
9
+
10
+ export interface CustomFilterEntry {
11
+ apiParam: string
12
+ ref: Ref<string | undefined>
13
+ defaultValue: string | undefined
14
+ typeKeys?: string[] // undefined = applies to all types
15
+ }
16
+
17
+ export interface SearchFilterContext {
18
+ register(urlParam: string, entry: CustomFilterEntry): void
19
+ unregister(urlParam: string): void
20
+ }
21
+
22
+ export function isCustomFilterActive(entry: CustomFilterEntry): boolean {
23
+ const v = entry.ref.value
24
+ return v !== undefined && v !== null && v !== '' && v !== entry.defaultValue
25
+ }
26
+
27
+ export function forEachActiveCustomFilter(
28
+ registry: Map<string, CustomFilterEntry>,
29
+ apply: (apiParam: string, value: string) => void,
30
+ typeKey?: string,
31
+ ): void {
32
+ for (const entry of registry.values()) {
33
+ if (!isCustomFilterActive(entry)) continue
34
+ if (typeKey && entry.typeKeys && !entry.typeKeys.includes(typeKey)) continue
35
+ apply(entry.apiParam, String(entry.ref.value))
36
+ }
37
+ }
38
+
39
+ export const searchFilterContextKey: InjectionKey<SearchFilterContext>
40
+ = Symbol('SearchFilterContext')
41
+
42
+ export interface UseSearchFilterOptions {
43
+ /** The API parameter name to map this filter to. Defaults to the urlParam. */
44
+ apiParam?: string
45
+ /** Default value when not present in URL. Defaults to undefined. */
46
+ defaultValue?: string
47
+ /** One or more type config keys this filter applies to. Undefined means all types. */
48
+ typeKeys?: string | string[]
49
+ }
50
+
51
+ /**
52
+ * Registers a custom filter with the parent GlobalSearch component.
53
+ *
54
+ * Must be called inside a component rendered within GlobalSearch's
55
+ * `#custom-filters-top` or `#custom-filters-bottom` slot.
56
+ *
57
+ * @param urlParam - The URL query parameter name (e.g. 'theme' → `?theme=value`)
58
+ * @param options - Optional: `apiParam` to map to a different API param (e.g. 'tag'), `defaultValue`
59
+ * @returns A reactive ref bound to the URL parameter, suitable for v-model
60
+ *
61
+ * @example
62
+ * ```vue
63
+ * <script setup>
64
+ * import { useSearchFilter } from '@datagouv/components-next'
65
+ * // URL: ?theme=environment → API: ?tag=environment
66
+ * const value = useSearchFilter('theme', { apiParam: 'tag' })
67
+ * </script>
68
+ * ```
69
+ */
70
+ export function useSearchFilter(
71
+ urlParam: string,
72
+ options: UseSearchFilterOptions = {},
73
+ ): Ref<string | undefined> {
74
+ const context = inject(searchFilterContextKey)
75
+ if (!context) {
76
+ throw new Error(
77
+ `useSearchFilter("${urlParam}") must be used inside a <GlobalSearch> component.`,
78
+ )
79
+ }
80
+
81
+ const { apiParam = urlParam, defaultValue = undefined, typeKeys } = options
82
+ const normalizedTypeKeys = typeKeys
83
+ ? (Array.isArray(typeKeys) ? typeKeys : [typeKeys])
84
+ : undefined
85
+
86
+ const route = useRoute()
87
+ const router = useRouter()
88
+ const value = useRouteQuery<string | undefined>(urlParam, defaultValue)
89
+
90
+ // Register in onMounted to avoid SSR/hydration mismatch: the registry must be
91
+ // empty during SSR so server and client produce the same initial HTML.
92
+ onMounted(() => {
93
+ context.register(urlParam, { apiParam, ref: value, defaultValue, typeKeys: normalizedTypeKeys })
94
+ })
95
+
96
+ onScopeDispose(() => {
97
+ // Clear the URL param when the scope ends. This mirrors GlobalSearch's
98
+ // own `watch(currentType)` logic that drops built-in filters which don't
99
+ // apply to the new type: a custom filter's applicability is signalled
100
+ // by the consumer via `v-if`, so its unmount is the equivalent signal.
101
+ //
102
+ // We cannot use `value.value = defaultValue` here because VueUse's
103
+ // useRouteQuery registers its own tryOnScopeDispose cleanup that zeroes
104
+ // the internal `query` variable to undefined (FIFO order, it runs first).
105
+ // The setter then sees `query === v` and early-returns without ever
106
+ // calling router.replace(). Instead we read the live route.query directly
107
+ // (which is router state, not affected by that cleanup) and push the update.
108
+ if (route.query[urlParam] !== undefined) {
109
+ const { [urlParam]: _removed, ...restQuery } = route.query
110
+ router.replace({
111
+ query: defaultValue === undefined ? restQuery : { ...restQuery, [urlParam]: String(defaultValue) },
112
+ })
113
+ }
114
+ context.unregister(urlParam)
115
+ })
116
+
117
+ return value
118
+ }