@datagouv/components-next 1.0.2-dev.9 → 1.0.2-dev.91
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/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-ZFh5ta_U.js} +1 -1
- package/dist/Datafair.client-CyZRNADr.js +30 -0
- package/dist/{Event--kp8kMdJ.js → Event-DSQcW7OF.js} +24 -24
- package/dist/{Image-34hvypZI.js → Image-BijNEG0p.js} +6 -6
- package/dist/JsonPreview.client-C9iaPSmQ.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
- package/dist/MapContainer.client-BuoZ69XO.js +101 -0
- package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
- package/dist/{PdfPreview.client-BVjPxlPu.js → PdfPreview.client-MI0bDghc.js} +822 -865
- package/dist/{Pmtiles.client-CRJ56yX2.js → Pmtiles.client-CaKEYQBc.js} +574 -579
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BKqb6TMw.js +61 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-hJQIqcZm.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-Dcl7oIVu.js} +35 -35
- package/dist/{TileImage-BsXBxMtq.js → TileImage-BJeHipMX.js} +4 -4
- package/dist/{View-BR92hTWP.js → View-xp_P_OHw.js} +412 -401
- package/dist/XmlPreview.client-BVAeNK4n.js +34 -0
- package/dist/{common-PJfpC179.js → common-BjQlan3k.js} +36 -36
- package/dist/components-next.css +6 -6
- package/dist/components-next.js +166 -148
- package/dist/components.css +1 -1
- package/dist/{index-BZsAZ7iw.js → index-BBdS8QKx.js} +32886 -27183
- package/dist/{main-qc4CO9Kn.js → main-Dk_66g-3.js} +91331 -75844
- package/dist/{proj-DsetBcW7.js → proj-CsNo9yH1.js} +532 -512
- package/dist/{tilecoord-Db24Px13.js → tilecoord-A0fLnBZr.js} +28 -28
- package/dist/{vue3-xml-viewer.common-CCOV_ohP.js → vue3-xml-viewer.common-B8dNNkOU.js} +1 -1
- package/package.json +18 -11
- package/src/components/ActivityList/ActivityList.vue +0 -2
- package/src/components/Chart/ChartViewer.vue +226 -0
- package/src/components/Chart/ChartViewerWrapper.vue +170 -0
- 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/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/ReadMore.vue +1 -1
- package/src/components/ResourceAccordion/Datafair.client.vue +4 -10
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -121
- package/src/components/ResourceAccordion/MapContainer.client.vue +5 -14
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -103
- package/src/components/ResourceAccordion/Pmtiles.client.vue +5 -10
- package/src/components/ResourceAccordion/Preview.vue +16 -21
- package/src/components/ResourceAccordion/PreviewLoader.vue +1 -2
- package/src/components/ResourceAccordion/PreviewUnavailable.vue +22 -0
- package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -7
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -115
- package/src/components/ResourceExplorer/ResourceExplorer.vue +81 -13
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -11
- package/src/components/Search/GlobalSearch.vue +191 -110
- 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 +870 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
- package/src/components/TabularExplorer/types.ts +83 -0
- package/src/composables/useHasTabularData.ts +6 -0
- package/src/composables/useResourceCapabilities.ts +1 -1
- package/src/composables/useSearchFilter.ts +118 -0
- package/src/composables/useStableQueryParams.ts +31 -3
- package/src/config.ts +3 -0
- package/src/functions/api.ts +34 -33
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +68 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/resources.ts +56 -1
- package/src/functions/tabular.ts +60 -0
- package/src/functions/tabularApi.ts +138 -11
- package/src/main.ts +55 -7
- package/src/types/dataservices.ts +2 -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/search.ts +52 -1
- package/src/types/site.ts +5 -3
- package/src/types/users.ts +2 -1
- package/src/types/visualizations.ts +89 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/Datafair.client-0UYUu5yf.js +0 -35
- package/dist/JsonPreview.client-BrTMBWHZ.js +0 -87
- package/dist/MapContainer.client-CUmKyByc.js +0 -107
- package/dist/Swagger.client-2Yn7iF0A.js +0 -4
- package/dist/XmlPreview.client-DxqlVnKu.js +0 -79
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /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
|
|
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
|
+
}
|