@datagouv/components-next 1.0.2-dev.53 → 1.0.2-dev.54
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/{Datafair.client-hLoIoNbP.js → Datafair.client-qm_JoZUL.js} +1 -1
- package/dist/{JsonPreview.client-BUCeeFKz.js → JsonPreview.client-BpovqdDN.js} +2 -2
- package/dist/{MapContainer.client-DrQRSrq_.js → MapContainer.client-6Y5RJxtw.js} +2 -2
- package/dist/{PdfPreview.client-vQ4bfJx3.js → PdfPreview.client-Drv5EwJe.js} +2 -2
- package/dist/{Pmtiles.client-DWtu_UNl.js → Pmtiles.client-B3dUb4iS.js} +1 -1
- package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-4Ufr2Kmw.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-BmRAxeK4.js} +1 -1
- package/dist/{XmlPreview.client-CEEHnAFF.js → XmlPreview.client-CXF1N-AI.js} +3 -3
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +136 -128
- package/dist/components.css +1 -1
- package/dist/{index-CsOZmih1.js → index-lCAbcwQm.js} +1 -1
- package/dist/{main-7DRSPyNj.js → main-5ZJvZtsQ.js} +22490 -20505
- package/dist/{vue3-xml-viewer.common-DOIGuzsk.js → vue3-xml-viewer.common-X_gxbf2s.js} +1 -1
- package/package.json +1 -1
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/ResourceAccordion/Preview.vue +10 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +1 -1
- package/src/components/ResourceExplorer/ResourceExplorer.vue +60 -3
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +4 -3
- package/src/components/Search/GlobalSearch.vue +45 -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/useSearchFilter.ts +90 -0
- package/src/composables/useStableQueryParams.ts +28 -3
- package/src/functions/api.ts +34 -33
- package/src/functions/api.types.ts +1 -0
- package/src/functions/tabular.ts +60 -0
- package/src/functions/tabularApi.ts +4 -6
- package/src/main.ts +9 -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type InjectionKey, type Ref, inject, onMounted, onScopeDispose } from 'vue'
|
|
2
|
+
import { useRouteQuery } from '@vueuse/router'
|
|
3
|
+
|
|
4
|
+
export interface CustomFilterEntry {
|
|
5
|
+
apiParam: string
|
|
6
|
+
ref: Ref<string | undefined>
|
|
7
|
+
defaultValue: string | undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SearchFilterContext {
|
|
11
|
+
register(urlParam: string, entry: CustomFilterEntry): void
|
|
12
|
+
unregister(urlParam: string): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isCustomFilterActive(entry: CustomFilterEntry): boolean {
|
|
16
|
+
const v = entry.ref.value
|
|
17
|
+
return v !== undefined && v !== null && v !== '' && v !== entry.defaultValue
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function forEachActiveCustomFilter(
|
|
21
|
+
registry: Map<string, CustomFilterEntry>,
|
|
22
|
+
apply: (apiParam: string, value: string) => void,
|
|
23
|
+
): void {
|
|
24
|
+
for (const entry of registry.values()) {
|
|
25
|
+
if (!isCustomFilterActive(entry)) continue
|
|
26
|
+
apply(entry.apiParam, String(entry.ref.value))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const searchFilterContextKey: InjectionKey<SearchFilterContext>
|
|
31
|
+
= Symbol('SearchFilterContext')
|
|
32
|
+
|
|
33
|
+
export interface UseSearchFilterOptions {
|
|
34
|
+
/** The API parameter name to map this filter to. Defaults to the urlParam. */
|
|
35
|
+
apiParam?: string
|
|
36
|
+
/** Default value when not present in URL. Defaults to undefined. */
|
|
37
|
+
defaultValue?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Registers a custom filter with the parent GlobalSearch component.
|
|
42
|
+
*
|
|
43
|
+
* Must be called inside a component rendered within GlobalSearch's
|
|
44
|
+
* `#custom-filters-top` or `#custom-filters-bottom` slot.
|
|
45
|
+
*
|
|
46
|
+
* @param urlParam - The URL query parameter name (e.g. 'theme' → `?theme=value`)
|
|
47
|
+
* @param options - Optional: `apiParam` to map to a different API param (e.g. 'tag'), `defaultValue`
|
|
48
|
+
* @returns A reactive ref bound to the URL parameter, suitable for v-model
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```vue
|
|
52
|
+
* <script setup>
|
|
53
|
+
* import { useSearchFilter } from '@datagouv/components-next'
|
|
54
|
+
* // URL: ?theme=environment → API: ?tag=environment
|
|
55
|
+
* const value = useSearchFilter('theme', { apiParam: 'tag' })
|
|
56
|
+
* </script>
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function useSearchFilter(
|
|
60
|
+
urlParam: string,
|
|
61
|
+
options: UseSearchFilterOptions = {},
|
|
62
|
+
): Ref<string | undefined> {
|
|
63
|
+
const context = inject(searchFilterContextKey)
|
|
64
|
+
if (!context) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`useSearchFilter("${urlParam}") must be used inside a <GlobalSearch> component.`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { apiParam = urlParam, defaultValue = undefined } = options
|
|
71
|
+
|
|
72
|
+
const value = useRouteQuery<string | undefined>(urlParam, defaultValue)
|
|
73
|
+
|
|
74
|
+
// Register in onMounted to avoid SSR/hydration mismatch: the registry must be
|
|
75
|
+
// empty during SSR so server and client produce the same initial HTML.
|
|
76
|
+
onMounted(() => {
|
|
77
|
+
context.register(urlParam, { apiParam, ref: value, defaultValue })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
onScopeDispose(() => {
|
|
81
|
+
// Clear the URL param when the scope ends. This mirrors GlobalSearch's
|
|
82
|
+
// own `watch(currentType)` logic that drops built-in filters which don't
|
|
83
|
+
// apply to the new type: a custom filter's applicability is signalled
|
|
84
|
+
// by the consumer via `v-if`, so its unmount is the equivalent signal.
|
|
85
|
+
value.value = defaultValue
|
|
86
|
+
context.unregister(urlParam)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { ref, watch, type Ref } from 'vue'
|
|
1
|
+
import { computed, ref, watch, type Ref } from 'vue'
|
|
2
2
|
import type { SearchTypeConfig } from '../types/search'
|
|
3
|
+
import { forEachActiveCustomFilter, type CustomFilterEntry } from './useSearchFilter'
|
|
3
4
|
|
|
4
5
|
type FilterRefs = Record<string, Ref<unknown>>
|
|
5
6
|
|
|
6
7
|
interface StableQueryParamsOptions {
|
|
7
8
|
typeConfig: SearchTypeConfig | undefined
|
|
8
9
|
allFilters: FilterRefs
|
|
10
|
+
customFilterRegistry: Map<string, CustomFilterEntry>
|
|
9
11
|
q: Ref<string>
|
|
10
12
|
sort: Ref<string | undefined>
|
|
11
13
|
page: Ref<number>
|
|
@@ -17,7 +19,7 @@ interface StableQueryParamsOptions {
|
|
|
17
19
|
* Applies hiddenFilters first, then user filters (which can override hiddenFilters).
|
|
18
20
|
*/
|
|
19
21
|
export function useStableQueryParams(options: StableQueryParamsOptions) {
|
|
20
|
-
const { typeConfig, allFilters, q, sort, page, pageSize } = options
|
|
22
|
+
const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options
|
|
21
23
|
const stableParams = ref<Record<string, unknown>>({})
|
|
22
24
|
|
|
23
25
|
const buildParams = () => {
|
|
@@ -50,6 +52,19 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
|
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
// 3.5. Apply custom filter values. Concatenate into an array on collision
|
|
56
|
+
// so a custom filter mapped onto a built-in apiParam (e.g. theme → tag)
|
|
57
|
+
// combines with an existing built-in value instead of overwriting it.
|
|
58
|
+
forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
|
|
59
|
+
const existing = params[apiParam]
|
|
60
|
+
if (existing === undefined) {
|
|
61
|
+
params[apiParam] = value
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
params[apiParam] = Array.isArray(existing) ? [...existing, value] : [existing, value]
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
53
68
|
// 4. Always include q, sort (if valid for this type), page, page_size
|
|
54
69
|
if (q.value) {
|
|
55
70
|
params.q = q.value
|
|
@@ -66,9 +81,19 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
|
|
|
66
81
|
return params
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
// Computed that reads all custom filter values, establishing reactive dependencies
|
|
85
|
+
// on both the Map mutations (shallowReactive) and each entry's ref.value.
|
|
86
|
+
const customFilterValues = computed(() => {
|
|
87
|
+
const snapshot: Record<string, unknown> = {}
|
|
88
|
+
for (const [urlParam, entry] of customFilterRegistry) {
|
|
89
|
+
snapshot[urlParam] = entry.ref.value
|
|
90
|
+
}
|
|
91
|
+
return snapshot
|
|
92
|
+
})
|
|
93
|
+
|
|
69
94
|
// Watch all dependencies and update only if content changed
|
|
70
95
|
watch(
|
|
71
|
-
[q, sort, page, ...Object.values(allFilters)],
|
|
96
|
+
[q, sort, page, ...Object.values(allFilters), customFilterValues],
|
|
72
97
|
() => {
|
|
73
98
|
const newParams = buildParams()
|
|
74
99
|
// JSON.stringify comparison is safe here because buildParams() builds the object deterministically
|