@datagouv/components-next 1.0.2-dev.52 → 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-CBE0uebV.js → Datafair.client-qm_JoZUL.js} +1 -1
- package/dist/{JsonPreview.client-B-kw70ep.js → JsonPreview.client-BpovqdDN.js} +2 -2
- package/dist/{MapContainer.client-dUUInlHa.js → MapContainer.client-6Y5RJxtw.js} +2 -2
- package/dist/{PdfPreview.client-Y2qTpKFd.js → PdfPreview.client-Drv5EwJe.js} +2 -2
- package/dist/{Pmtiles.client-BOPJ59Cq.js → Pmtiles.client-B3dUb4iS.js} +1 -1
- package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-DtgGpKmM.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-BmRAxeK4.js} +1 -1
- package/dist/{XmlPreview.client-CtW1m4O8.js → XmlPreview.client-CXF1N-AI.js} +3 -3
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +144 -145
- package/dist/components.css +1 -1
- package/dist/{index-B8siUkxs.js → index-lCAbcwQm.js} +1 -1
- package/dist/{main-DaWGX8hL.js → main-5ZJvZtsQ.js} +31672 -52962
- package/dist/{vue3-xml-viewer.common-S9Mg9vGE.js → vue3-xml-viewer.common-X_gxbf2s.js} +1 -1
- package/package.json +1 -3
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/ResourceAccordion/Preview.vue +11 -11
- 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/useHasTabularData.ts +1 -9
- 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 +9 -137
- package/src/main.ts +9 -27
- package/src/components/Chart/ChartViewer.vue +0 -147
- package/src/components/Chart/ChartViewerWrapper.vue +0 -224
- package/src/components/Form/Listbox.vue +0 -101
- package/src/functions/charts.ts +0 -68
- package/src/types/visualizations.ts +0 -89
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datagouv/components-next",
|
|
3
|
-
"version": "1.0.2-dev.
|
|
3
|
+
"version": "1.0.2-dev.54",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
"@vueuse/router": "^14.2.1",
|
|
26
26
|
"chart.js": "^4.4.8",
|
|
27
27
|
"dompurify": "^3.2.5",
|
|
28
|
-
"echarts": "^6.0.0",
|
|
29
28
|
"geopf-extensions-openlayers": "^1.0.0-beta.5",
|
|
30
29
|
"leaflet": "^1.9.4",
|
|
31
30
|
"maplibre-gl": "^5.6.2",
|
|
@@ -49,7 +48,6 @@
|
|
|
49
48
|
"unified": "^11.0.5",
|
|
50
49
|
"unist-util-visit": "^5.0.0",
|
|
51
50
|
"vue-content-loader": "^2.0.1",
|
|
52
|
-
"vue-echarts": "^8.0.1",
|
|
53
51
|
"vue-sonner": "^2.0.9",
|
|
54
52
|
"vue3-json-viewer": "^2.4.1",
|
|
55
53
|
"vue3-text-clamp": "^0.1.2",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="sentinel">
|
|
3
|
+
<slot>
|
|
4
|
+
<div class="flex items-center justify-center p-4">
|
|
5
|
+
<span class="inline-flex items-center gap-2 text-xs text-gray-medium">
|
|
6
|
+
<RiLoader4Line
|
|
7
|
+
class="size-4 animate-spin"
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
/>
|
|
10
|
+
{{ t('Chargement…') }}
|
|
11
|
+
</span>
|
|
12
|
+
</div>
|
|
13
|
+
</slot>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
|
|
19
|
+
import { RiLoader4Line } from '@remixicon/vue'
|
|
20
|
+
import { useTranslation } from '../composables/useTranslation'
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
root?: HTMLElement | null
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
intersect: []
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const { t } = useTranslation()
|
|
31
|
+
|
|
32
|
+
const sentinelRef = useTemplateRef<HTMLElement>('sentinel')
|
|
33
|
+
let observer: IntersectionObserver | null = null
|
|
34
|
+
|
|
35
|
+
function setupObserver() {
|
|
36
|
+
observer?.disconnect()
|
|
37
|
+
const el = sentinelRef.value
|
|
38
|
+
if (!el) return
|
|
39
|
+
observer = new IntersectionObserver(
|
|
40
|
+
(entries) => {
|
|
41
|
+
if (entries[0]?.isIntersecting) {
|
|
42
|
+
emit('intersect')
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{ root: props.root ?? null, rootMargin: '200px' },
|
|
46
|
+
)
|
|
47
|
+
observer.observe(el)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
onMounted(setupObserver)
|
|
51
|
+
watch([sentinelRef, () => props.root], setupObserver)
|
|
52
|
+
onUnmounted(() => observer?.disconnect())
|
|
53
|
+
</script>
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
>
|
|
49
49
|
<BrandedButton
|
|
50
50
|
color="tertiary"
|
|
51
|
-
:icon="isSortedBy(col) && sortConfig && sortConfig.
|
|
51
|
+
:icon="isSortedBy(col) && sortConfig && sortConfig.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
|
|
52
52
|
icon-right
|
|
53
53
|
size="xs"
|
|
54
54
|
@click="sortByField(col)"
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
<!-- There is a weird bug with `sr-only`, I needed to add a relative parent to avoid full page x scrolling into the void… -->
|
|
57
57
|
<span class="relative">
|
|
58
58
|
{{ col }}
|
|
59
|
-
<span class="sr-only">{{ sortConfig && sortConfig.
|
|
59
|
+
<span class="sr-only">{{ sortConfig && sortConfig.direction === 'desc' ? t("Trier par ordre croissant") : t("Trier par ordre décroissant") }}</span>
|
|
60
60
|
</span>
|
|
61
61
|
</BrandedButton>
|
|
62
62
|
</th>
|
|
@@ -122,7 +122,7 @@ const rows = ref<Array<Record<string, unknown>>>([])
|
|
|
122
122
|
const columns = ref<Array<string>>([])
|
|
123
123
|
const loading = ref(true)
|
|
124
124
|
const hasError = ref(false)
|
|
125
|
-
const sortConfig = ref<SortConfig>(null)
|
|
125
|
+
const sortConfig = ref<SortConfig | null>(null)
|
|
126
126
|
const rowCount = ref(0)
|
|
127
127
|
const config = useComponentsConfig()
|
|
128
128
|
const pageSize = computed(() => config.tabularApiPageSize || 15)
|
|
@@ -138,11 +138,11 @@ function isSortedBy(col: string) {
|
|
|
138
138
|
/**
|
|
139
139
|
* Retrieve preview necessary infos
|
|
140
140
|
*/
|
|
141
|
-
async function getTableInfos(page: number, sortConfig?: SortConfig) {
|
|
141
|
+
async function getTableInfos(page: number, sortConfig?: SortConfig | null) {
|
|
142
142
|
try {
|
|
143
143
|
// Check that this function return wanted data
|
|
144
144
|
const response = await getData(config, props.resource.id, page, sortConfig)
|
|
145
|
-
if ('data' in response && response.data &&
|
|
145
|
+
if ('data' in response && response.data && response.data.length > 0) {
|
|
146
146
|
// Update existing rows
|
|
147
147
|
rows.value = response.data
|
|
148
148
|
columns.value = Object.keys(response.data[0]).filter(item => item !== '__id')
|
|
@@ -172,24 +172,24 @@ function changePage(page: number) {
|
|
|
172
172
|
* Sort by a specific column
|
|
173
173
|
*/
|
|
174
174
|
function sortByField(col: string) {
|
|
175
|
-
if (sortConfig.value && sortConfig.value.column
|
|
176
|
-
if (sortConfig.value.
|
|
177
|
-
sortConfig.value.
|
|
175
|
+
if (sortConfig.value && sortConfig.value.column === col) {
|
|
176
|
+
if (sortConfig.value.direction === 'asc') {
|
|
177
|
+
sortConfig.value.direction = 'desc'
|
|
178
178
|
}
|
|
179
179
|
else {
|
|
180
|
-
sortConfig.value.
|
|
180
|
+
sortConfig.value.direction = 'asc'
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
else {
|
|
184
184
|
if (!sortConfig.value) {
|
|
185
185
|
sortConfig.value = {
|
|
186
186
|
column: col,
|
|
187
|
-
|
|
187
|
+
direction: 'asc',
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
else {
|
|
191
191
|
sortConfig.value.column = col
|
|
192
|
-
sortConfig.value.
|
|
192
|
+
sortConfig.value.direction = 'asc'
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
currentPage.value = 1
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
<div v-if="allResources.length || hasAnyResources">
|
|
3
3
|
<div class="flex gap-6">
|
|
4
4
|
<div class="flex-1 min-w-0">
|
|
5
|
+
<div
|
|
6
|
+
v-if="dataset.resources.total > 1"
|
|
7
|
+
class="md:hidden flex justify-end mb-3"
|
|
8
|
+
>
|
|
9
|
+
<BrandedButton
|
|
10
|
+
size="xs"
|
|
11
|
+
color="secondary"
|
|
12
|
+
:icon="RiListUnordered"
|
|
13
|
+
@click="mobileSidebarOpen = true"
|
|
14
|
+
>
|
|
15
|
+
{{ t('Ressources ({count})', { count: dataset.resources.total }) }}
|
|
16
|
+
</BrandedButton>
|
|
17
|
+
</div>
|
|
5
18
|
<ResourceExplorerViewer
|
|
6
19
|
v-if="selectedResource && allResources.length"
|
|
7
20
|
:key="selectedResource.id"
|
|
@@ -30,17 +43,38 @@
|
|
|
30
43
|
</BrandedButton>
|
|
31
44
|
</div>
|
|
32
45
|
</div>
|
|
46
|
+
<div class="hidden md:block">
|
|
47
|
+
<ResourceExplorerSidebar
|
|
48
|
+
:resources="allResources"
|
|
49
|
+
:selected-resource-id="selectedResource?.id ?? null"
|
|
50
|
+
:collapsed="sidebarCollapsed"
|
|
51
|
+
:search
|
|
52
|
+
@select="selectResource"
|
|
53
|
+
@load-more="loadMore"
|
|
54
|
+
@update:collapsed="sidebarCollapsed = $event"
|
|
55
|
+
@update:search="updateSearch($event)"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Mobile sidebar panel -->
|
|
61
|
+
<dialog
|
|
62
|
+
ref="mobileSidebarDialog"
|
|
63
|
+
class="mobile-sidebar md:hidden fixed inset-0 m-0 ml-auto p-0 h-dvh max-h-dvh w-80 max-w-[85vw] bg-white shadow-lg overflow-y-auto overscroll-contain backdrop:bg-black/30"
|
|
64
|
+
@close="mobileSidebarOpen = false"
|
|
65
|
+
@click.self="closeMobileSidebar"
|
|
66
|
+
>
|
|
33
67
|
<ResourceExplorerSidebar
|
|
34
68
|
:resources="allResources"
|
|
35
69
|
:selected-resource-id="selectedResource?.id ?? null"
|
|
36
|
-
:collapsed="
|
|
70
|
+
:collapsed="false"
|
|
37
71
|
:search
|
|
38
72
|
@select="selectResource"
|
|
39
73
|
@load-more="loadMore"
|
|
40
|
-
@update:collapsed="
|
|
74
|
+
@update:collapsed="closeMobileSidebar"
|
|
41
75
|
@update:search="updateSearch($event)"
|
|
42
76
|
/>
|
|
43
|
-
</
|
|
77
|
+
</dialog>
|
|
44
78
|
</div>
|
|
45
79
|
<div
|
|
46
80
|
v-else
|
|
@@ -73,6 +107,7 @@ import type { DatasetV2 } from '../../types/datasets'
|
|
|
73
107
|
import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
|
|
74
108
|
import ResourceExplorerSidebar from './ResourceExplorerSidebar.vue'
|
|
75
109
|
import ResourceExplorerViewer from './ResourceExplorerViewer.vue'
|
|
110
|
+
import { RiListUnordered } from '@remixicon/vue'
|
|
76
111
|
import BrandedButton from '../BrandedButton.vue'
|
|
77
112
|
|
|
78
113
|
const props = withDefaults(defineProps<{
|
|
@@ -211,6 +246,21 @@ const flatResources = computed(() =>
|
|
|
211
246
|
|
|
212
247
|
// Fetch resource by ID if specified in URL (for SSR)
|
|
213
248
|
const initialResourceId = resourceIdQuery.value
|
|
249
|
+
const mobileSidebarOpen = ref(false)
|
|
250
|
+
const mobileSidebarDialog = ref<HTMLDialogElement | null>(null)
|
|
251
|
+
|
|
252
|
+
watch(mobileSidebarOpen, (open) => {
|
|
253
|
+
if (open) {
|
|
254
|
+
mobileSidebarDialog.value?.showModal()
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
mobileSidebarDialog.value?.close()
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
function closeMobileSidebar() {
|
|
262
|
+
mobileSidebarOpen.value = false
|
|
263
|
+
}
|
|
214
264
|
const { data: fetchedResource } = initialResourceId
|
|
215
265
|
? await useFetch<Resource>(`/api/1/datasets/${props.dataset.id}/resources/${initialResourceId}/`)
|
|
216
266
|
: { data: ref(null) }
|
|
@@ -238,6 +288,7 @@ function updateSearch(newSearch: string) {
|
|
|
238
288
|
|
|
239
289
|
const selectResource = (resource: Resource) => {
|
|
240
290
|
selectedResource.value = resource
|
|
291
|
+
mobileSidebarOpen.value = false
|
|
241
292
|
router.replace({
|
|
242
293
|
query: { ...router.currentRoute.value.query, resource_id: resource.id },
|
|
243
294
|
})
|
|
@@ -252,3 +303,9 @@ watch(flatResources, () => {
|
|
|
252
303
|
}
|
|
253
304
|
})
|
|
254
305
|
</script>
|
|
306
|
+
|
|
307
|
+
<style>
|
|
308
|
+
html:has(dialog.mobile-sidebar[open]) {
|
|
309
|
+
overflow: hidden;
|
|
310
|
+
}
|
|
311
|
+
</style>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<aside
|
|
3
3
|
v-if="!collapsed"
|
|
4
|
-
class="w-72 shrink-0
|
|
4
|
+
class="w-full md:w-72 shrink-0 p-4 md:pr-0"
|
|
5
5
|
>
|
|
6
6
|
<div class="flex items-center justify-between mb-3">
|
|
7
7
|
<h3 class="text-sm font-bold uppercase mb-0">
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
>
|
|
33
33
|
</div>
|
|
34
34
|
|
|
35
|
-
<div class="space-y-4 overflow-y-auto">
|
|
35
|
+
<div class="space-y-4 overflow-y-auto md:max-h-[calc(100vh-14rem)]">
|
|
36
36
|
<div
|
|
37
37
|
v-for="group in resources"
|
|
38
38
|
:key="group.type"
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<h3 class="m-0 flex items-baseline text-base font-bold leading-tight">
|
|
7
7
|
<ResourceIcon
|
|
8
8
|
:resource
|
|
9
|
-
class="size-3.5 mr-1"
|
|
9
|
+
class="size-3.5 mr-1 shrink-0 translate-y-px"
|
|
10
10
|
/>
|
|
11
11
|
<span class="line-clamp-2">{{ resource.title || t('Fichier sans nom') }}</span>
|
|
12
12
|
</h3>
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
:label="t('Copier le lien')"
|
|
15
15
|
:copied-label="t('Lien copié !')"
|
|
16
16
|
:text="resourceExternalUrl"
|
|
17
|
+
class="hidden md:inline-flex"
|
|
17
18
|
/>
|
|
18
19
|
</div>
|
|
19
|
-
<div class="text-gray-medium text-xs flex items-center gap-1">
|
|
20
|
+
<div class="text-gray-medium text-xs flex items-center gap-1 flex-wrap">
|
|
20
21
|
<SchemaBadge :resource />
|
|
21
22
|
<RiSubtractLine
|
|
22
|
-
v-if="resource.schema"
|
|
23
|
+
v-if="resource.schema?.name || resource.schema?.url"
|
|
23
24
|
aria-hidden="true"
|
|
24
25
|
class="size-3 fill-gray-medium"
|
|
25
26
|
/>
|
|
@@ -42,11 +42,15 @@
|
|
|
42
42
|
</Sidemenu>
|
|
43
43
|
</div>
|
|
44
44
|
|
|
45
|
-
<div v-if="activeFilters.length > 0">
|
|
45
|
+
<div v-if="activeFilters.length > 0 || $slots['custom-filters-top'] || $slots['custom-filters-bottom']">
|
|
46
46
|
<Sidemenu :button-text="t('Filtres')">
|
|
47
47
|
<template #title>
|
|
48
48
|
{{ t('Filtres') }}
|
|
49
49
|
</template>
|
|
50
|
+
<slot
|
|
51
|
+
name="custom-filters-top"
|
|
52
|
+
:current-type="currentType"
|
|
53
|
+
/>
|
|
50
54
|
<BasicAndAdvancedFilters
|
|
51
55
|
v-slot="{ isEnabled, getOrder }"
|
|
52
56
|
:basic-filters="activeBasicFilters"
|
|
@@ -146,6 +150,10 @@
|
|
|
146
150
|
:get-order="getOrder"
|
|
147
151
|
/>
|
|
148
152
|
</BasicAndAdvancedFilters>
|
|
153
|
+
<slot
|
|
154
|
+
name="custom-filters-bottom"
|
|
155
|
+
:current-type="currentType"
|
|
156
|
+
/>
|
|
149
157
|
<div
|
|
150
158
|
v-if="hasFilters"
|
|
151
159
|
class="mt-6 text-center"
|
|
@@ -340,12 +348,13 @@
|
|
|
340
348
|
</template>
|
|
341
349
|
|
|
342
350
|
<script setup lang="ts">
|
|
343
|
-
import { computed, watch, useTemplateRef, type Ref } from 'vue'
|
|
351
|
+
import { computed, provide, shallowReactive, useSlots, watch, useTemplateRef, type Ref } from 'vue'
|
|
344
352
|
import { useRouteQuery } from '@vueuse/router'
|
|
345
353
|
import { RiBookShelfLine, RiBuilding2Line, RiCloseCircleLine, RiDatabase2Line, RiLightbulbLine, RiLineChartLine, RiRssLine, RiTerminalLine } from '@remixicon/vue'
|
|
346
354
|
import magnifyingGlassSrc from '../../../assets/illustrations/magnifying_glass.svg?url'
|
|
347
355
|
import { useTranslation } from '../../composables/useTranslation'
|
|
348
356
|
import { useDebouncedRef } from '../../composables/useDebouncedRef'
|
|
357
|
+
import { forEachActiveCustomFilter, isCustomFilterActive, searchFilterContextKey, type CustomFilterEntry } from '../../composables/useSearchFilter'
|
|
349
358
|
import { useStableQueryParams } from '../../composables/useStableQueryParams'
|
|
350
359
|
import { useComponentsConfig } from '../../config'
|
|
351
360
|
import { useFetch } from '../../functions/api'
|
|
@@ -399,6 +408,13 @@ if (!currentType.value) currentType.value = props.config[0]?.class ?? 'datasets'
|
|
|
399
408
|
const { t } = useTranslation()
|
|
400
409
|
const componentsConfig = useComponentsConfig()
|
|
401
410
|
|
|
411
|
+
// Custom filter registry for useSearchFilter composable
|
|
412
|
+
const customFilterRegistry = shallowReactive(new Map<string, CustomFilterEntry>())
|
|
413
|
+
// Per-filter watch stoppers: each registered filter gets its own watcher so a
|
|
414
|
+
// value change resets page to 1, but registration itself does not (the value
|
|
415
|
+
// came from the URL, not from a user action).
|
|
416
|
+
const customFilterStops = new Map<string, () => void>()
|
|
417
|
+
|
|
402
418
|
// Initial type is used to determine which fetch should be SSR (non-lazy)
|
|
403
419
|
const initialType = currentType.value
|
|
404
420
|
|
|
@@ -439,7 +455,8 @@ const activeFilters = computed(() => [
|
|
|
439
455
|
...(currentTypeConfig.value?.advancedFilters ?? []),
|
|
440
456
|
] as string[])
|
|
441
457
|
|
|
442
|
-
const
|
|
458
|
+
const slots = useSlots()
|
|
459
|
+
const showSidebar = computed(() => props.config.length > 1 || activeFilters.value.length > 0 || !!slots['custom-filters-top'] || !!slots['custom-filters-bottom'])
|
|
443
460
|
|
|
444
461
|
// URL query params
|
|
445
462
|
const q = useRouteQuery<string>('q', '')
|
|
@@ -447,6 +464,18 @@ const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsCo
|
|
|
447
464
|
const page = useRouteQuery('page', 1, { transform: Number })
|
|
448
465
|
const sort = useRouteQuery<string | undefined>('sort')
|
|
449
466
|
|
|
467
|
+
provide(searchFilterContextKey, {
|
|
468
|
+
register(urlParam, entry) {
|
|
469
|
+
customFilterRegistry.set(urlParam, entry)
|
|
470
|
+
customFilterStops.set(urlParam, watch(entry.ref, () => page.value = 1))
|
|
471
|
+
},
|
|
472
|
+
unregister(urlParam) {
|
|
473
|
+
customFilterStops.get(urlParam)?.()
|
|
474
|
+
customFilterStops.delete(urlParam)
|
|
475
|
+
customFilterRegistry.delete(urlParam)
|
|
476
|
+
},
|
|
477
|
+
})
|
|
478
|
+
|
|
450
479
|
// Filter values
|
|
451
480
|
const organizationId = useRouteQuery<string | undefined>('organization')
|
|
452
481
|
const organizationType = useRouteQuery<string | undefined>('organization_badge')
|
|
@@ -513,6 +542,7 @@ const topicsEnabled = computed(() => props.config.some(c => c.class === 'topics'
|
|
|
513
542
|
// Create stable params for each type
|
|
514
543
|
const stableParamsOptions = {
|
|
515
544
|
allFilters,
|
|
545
|
+
customFilterRegistry,
|
|
516
546
|
q: qDebounced,
|
|
517
547
|
sort,
|
|
518
548
|
page,
|
|
@@ -547,7 +577,10 @@ const reusesUrl = computed(() => reusesEnabled.value ? '/api/2/reuses/search/' :
|
|
|
547
577
|
const organizationsUrl = computed(() => organizationsEnabled.value ? '/api/2/organizations/search/' : null)
|
|
548
578
|
const topicsUrl = computed(() => topicsEnabled.value ? '/api/2/topics/search/' : null)
|
|
549
579
|
|
|
550
|
-
// Reset page on filter/sort change
|
|
580
|
+
// Reset page on filter/sort change. Custom filters (registered via
|
|
581
|
+
// useSearchFilter) have their own watchers set up in `provide`, so they're
|
|
582
|
+
// intentionally excluded here to avoid resetting the page when a filter
|
|
583
|
+
// registers with its URL-derived value.
|
|
551
584
|
const filtersForReset = computed(() => ({
|
|
552
585
|
q: qDebounced.value,
|
|
553
586
|
organization: organizationId.value,
|
|
@@ -587,6 +620,7 @@ const hasFilters = computed(() => {
|
|
|
587
620
|
|| lastUpdateRange.value
|
|
588
621
|
|| producerType.value
|
|
589
622
|
|| reuseType.value
|
|
623
|
+
|| Array.from(customFilterRegistry.values()).some(isCustomFilterActive)
|
|
590
624
|
})
|
|
591
625
|
|
|
592
626
|
const showForumLink = computed(() => (currentType.value === 'datasets' || currentType.value === 'dataservices') && !!componentsConfig.forumUrl)
|
|
@@ -607,6 +641,9 @@ function resetFilters() {
|
|
|
607
641
|
lastUpdateRange.value = undefined
|
|
608
642
|
producerType.value = undefined
|
|
609
643
|
reuseType.value = undefined
|
|
644
|
+
for (const entry of customFilterRegistry.values()) {
|
|
645
|
+
entry.ref.value = entry.defaultValue
|
|
646
|
+
}
|
|
610
647
|
q.value = ''
|
|
611
648
|
flushQ()
|
|
612
649
|
}
|
|
@@ -702,6 +739,10 @@ const rssUrl = computed(() => {
|
|
|
702
739
|
if (badge.value) params.set('badge', badge.value)
|
|
703
740
|
if (topic.value) params.set('topic', topic.value)
|
|
704
741
|
|
|
742
|
+
forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
|
|
743
|
+
params.set(apiParam, value)
|
|
744
|
+
})
|
|
745
|
+
|
|
705
746
|
// Add sort if set
|
|
706
747
|
if (sort.value) params.set('sort', sort.value)
|
|
707
748
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span
|
|
3
|
+
v-if="value == null || value === ''"
|
|
4
|
+
class="font-[Inconsolata,monospace] text-gray-low italic"
|
|
5
|
+
:class="compact ? 'text-xs' : 'text-sm'"
|
|
6
|
+
>null</span>
|
|
7
|
+
<span
|
|
8
|
+
v-else-if="columnType === 'boolean'"
|
|
9
|
+
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs"
|
|
10
|
+
:class="isTruthy(value) ? 'bg-new-success-light text-new-success' : 'bg-new-warning-light text-new-error'"
|
|
11
|
+
>
|
|
12
|
+
<span
|
|
13
|
+
class="size-2 rounded-full"
|
|
14
|
+
:class="isTruthy(value) ? 'bg-new-success' : 'bg-new-error'"
|
|
15
|
+
/>
|
|
16
|
+
{{ isTruthy(value) ? t('Vrai') : t('Faux') }}
|
|
17
|
+
</span>
|
|
18
|
+
<span
|
|
19
|
+
v-else-if="columnType === 'categorical'"
|
|
20
|
+
class="inline-block rounded font-medium px-2 py-0.5 text-xs max-w-full truncate"
|
|
21
|
+
:style="categoryBadgeStyle ? { backgroundColor: categoryBadgeStyle.backgroundColor, color: categoryBadgeStyle.color } : undefined"
|
|
22
|
+
>{{ value }}</span>
|
|
23
|
+
<span
|
|
24
|
+
v-else-if="columnType === 'number'"
|
|
25
|
+
:class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
|
|
26
|
+
>{{ formatNumber(value) }}</span>
|
|
27
|
+
<span
|
|
28
|
+
v-else-if="columnType === 'date'"
|
|
29
|
+
:class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
|
|
30
|
+
>{{ formatCellDate(value) }}</span>
|
|
31
|
+
<span
|
|
32
|
+
v-else
|
|
33
|
+
class="text-gray-title truncate block text-xs"
|
|
34
|
+
>{{ typeof value === 'object' ? JSON.stringify(value) : value }}</span>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup lang="ts">
|
|
38
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
39
|
+
import { useFormatTabular, isTruthy } from '../../functions/tabular'
|
|
40
|
+
import type { ColumnType, BadgeStyle } from './types'
|
|
41
|
+
|
|
42
|
+
defineProps<{
|
|
43
|
+
value: unknown
|
|
44
|
+
columnType: ColumnType
|
|
45
|
+
categoryBadgeStyle?: BadgeStyle
|
|
46
|
+
compact?: boolean
|
|
47
|
+
}>()
|
|
48
|
+
|
|
49
|
+
const { t } = useTranslation()
|
|
50
|
+
const { formatNumber, formatCellDate } = useFormatTabular()
|
|
51
|
+
</script>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<ClientOnly>
|
|
3
|
+
<Teleport to="#tooltips">
|
|
4
|
+
<div
|
|
5
|
+
v-if="cell"
|
|
6
|
+
ref="panel"
|
|
7
|
+
class="bg-white border border-black/10 rounded-lg shadow-md w-80 absolute z-[800]"
|
|
8
|
+
:style="floatingStyles"
|
|
9
|
+
>
|
|
10
|
+
<!-- Value -->
|
|
11
|
+
<div class="px-3 pt-3 pb-2 border-b border-gray-default">
|
|
12
|
+
<p class="text-[10px] text-gray-plain mb-0">
|
|
13
|
+
{{ t('Valeur brute') }}
|
|
14
|
+
</p>
|
|
15
|
+
<p class="text-xs text-gray-title mb-0">
|
|
16
|
+
{{ displayValue }}
|
|
17
|
+
</p>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- Type -->
|
|
21
|
+
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-default">
|
|
22
|
+
<span class="text-[10px] text-gray-plain">{{ t('Type') }}</span>
|
|
23
|
+
<span class="inline-flex items-center gap-1 bg-gray-some rounded px-1.5 py-0.5 text-xs text-gray-plain">
|
|
24
|
+
<component
|
|
25
|
+
:is="typeIcon"
|
|
26
|
+
class="size-3"
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
/>
|
|
29
|
+
{{ typeLabel }}
|
|
30
|
+
</span>
|
|
31
|
+
<span class="text-[10px] text-gray-plain shrink-0">·</span>
|
|
32
|
+
<span class="text-[10px] text-gray-plain truncate min-w-0">{{ cell.column }}</span>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Actions -->
|
|
36
|
+
<div class="p-1">
|
|
37
|
+
<button
|
|
38
|
+
class="flex items-center gap-2.5 w-full px-3 py-2 rounded-md text-xs font-medium hover:bg-gray-50"
|
|
39
|
+
@click="filterByValue"
|
|
40
|
+
>
|
|
41
|
+
<RiFilter2Line
|
|
42
|
+
class="size-4"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
/>
|
|
45
|
+
{{ t('Filtrer par cette valeur') }}
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
class="flex items-center gap-2.5 w-full px-3 py-2 rounded-md text-xs font-medium hover:bg-gray-50"
|
|
49
|
+
@click="copyValue"
|
|
50
|
+
>
|
|
51
|
+
<RiCheckLine
|
|
52
|
+
v-if="copied"
|
|
53
|
+
class="size-4 text-green-500"
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
/>
|
|
56
|
+
<RiFileCopyLine
|
|
57
|
+
v-else
|
|
58
|
+
class="size-4 text-gray-plain"
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
/>
|
|
61
|
+
{{ copied ? t('Copié !') : t('Copier la valeur') }}
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</Teleport>
|
|
66
|
+
</ClientOnly>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<script setup lang="ts">
|
|
70
|
+
import { computed, ref, useTemplateRef, watch } from 'vue'
|
|
71
|
+
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
72
|
+
import { onClickOutside } from '@vueuse/core'
|
|
73
|
+
import {
|
|
74
|
+
RiFilter2Line,
|
|
75
|
+
RiFileCopyLine,
|
|
76
|
+
RiCheckLine,
|
|
77
|
+
} from '@remixicon/vue'
|
|
78
|
+
import { toast } from 'vue-sonner'
|
|
79
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
80
|
+
import { buildTypeConfig } from '../../functions/tabular'
|
|
81
|
+
import ClientOnly from '../ClientOnly.vue'
|
|
82
|
+
import type { ColumnType, ColumnFilters } from './types'
|
|
83
|
+
|
|
84
|
+
export interface CellInfo {
|
|
85
|
+
column: string
|
|
86
|
+
columnType: ColumnType
|
|
87
|
+
value: unknown
|
|
88
|
+
element: HTMLElement
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cell = defineModel<CellInfo | null>('cell', { default: null })
|
|
92
|
+
const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
|
|
93
|
+
|
|
94
|
+
const { t } = useTranslation()
|
|
95
|
+
|
|
96
|
+
const panelRef = useTemplateRef<HTMLElement>('panel')
|
|
97
|
+
const anchorRef = ref<HTMLElement | null>(null)
|
|
98
|
+
|
|
99
|
+
watch(cell, (c) => {
|
|
100
|
+
anchorRef.value = c?.element ?? null
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const { floatingStyles } = useFloating(anchorRef, panelRef, {
|
|
104
|
+
placement: 'bottom-start',
|
|
105
|
+
middleware: [flip(), shift()],
|
|
106
|
+
whileElementsMounted: autoUpdate,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const displayValue = computed(() => {
|
|
110
|
+
if (!cell.value) return ''
|
|
111
|
+
const v = cell.value.value
|
|
112
|
+
if (v == null || v === '') return '–'
|
|
113
|
+
if (typeof v === 'object') return JSON.stringify(v)
|
|
114
|
+
return String(v)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const typeConfig = buildTypeConfig(t)
|
|
118
|
+
|
|
119
|
+
const typeIcon = computed(() => cell.value ? typeConfig[cell.value.columnType].icon : typeConfig.text.icon)
|
|
120
|
+
const typeLabel = computed(() => cell.value ? typeConfig[cell.value.columnType].label : '')
|
|
121
|
+
|
|
122
|
+
function close() {
|
|
123
|
+
cell.value = null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function filterByValue() {
|
|
127
|
+
if (!cell.value) return
|
|
128
|
+
const val = String(cell.value.value ?? '')
|
|
129
|
+
const col = cell.value.column
|
|
130
|
+
const existing = filters.value[col] ?? {}
|
|
131
|
+
if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'date') {
|
|
132
|
+
const current = existing.in ?? []
|
|
133
|
+
if (!current.includes(val)) {
|
|
134
|
+
filters.value = { ...filters.value, [col]: { ...existing, in: [...current, val] } }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else if (cell.value.columnType === 'number') {
|
|
138
|
+
const num = Number(cell.value.value)
|
|
139
|
+
if (Number.isFinite(num)) {
|
|
140
|
+
filters.value = { ...filters.value, [col]: { ...existing, min: num, max: num } }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (cell.value.columnType === 'boolean') {
|
|
144
|
+
filters.value = { ...filters.value, [col]: { ...existing, exact: val } }
|
|
145
|
+
}
|
|
146
|
+
close()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const copied = ref(false)
|
|
150
|
+
|
|
151
|
+
async function copyValue() {
|
|
152
|
+
try {
|
|
153
|
+
await navigator.clipboard.writeText(displayValue.value)
|
|
154
|
+
copied.value = true
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
copied.value = false
|
|
157
|
+
}, 1500)
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
toast.error(t('Impossible de copier dans le presse-papier'))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
onClickOutside(panelRef, (e) => {
|
|
165
|
+
if (!cell.value) return
|
|
166
|
+
const clickedCell = (e.target as HTMLElement).closest('[data-cell]')
|
|
167
|
+
if (clickedCell && clickedCell === cell.value.element) return
|
|
168
|
+
close()
|
|
169
|
+
})
|
|
170
|
+
</script>
|