@datagouv/components-next 1.1.1 → 1.2.0
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/dist/{Control-DuZJdKV_.js → Control-ZFh5ta_U.js} +1 -1
- package/dist/{Datafair.client-BzW-ctDf.js → Datafair.client-rf4T1IkA.js} +1 -1
- package/dist/{Event--kp8kMdJ.js → Event-DSQcW7OF.js} +24 -24
- package/dist/{Image-34hvypZI.js → Image-BijNEG0p.js} +6 -6
- package/dist/{JsonPreview.client-BfMSzR07.js → JsonPreview.client-dzar6iuh.js} +2 -2
- package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
- package/dist/{MapContainer.client-CLs-im9i.js → MapContainer.client-D-MoRNhG.js} +37 -38
- package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
- package/dist/{PdfPreview.client-C13PQCU_.js → PdfPreview.client-DoDYLmJD.js} +2 -2
- package/dist/{Pmtiles.client-CL7PXXDl.js → Pmtiles.client-Dzm01Zfm.js} +1 -1
- package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-C6XnsZ-7.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js} +1 -1
- 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-KaENrbbG.js → XmlPreview.client-cOhwff6P.js} +3 -3
- package/dist/{common-PJfpC179.js → common-BjQlan3k.js} +36 -36
- package/dist/components-next.css +4 -4
- package/dist/components-next.js +160 -155
- package/dist/components.css +1 -1
- package/dist/{index-C7WVVGgD.js → index-NofRBuyf.js} +32886 -27183
- package/dist/{main-K-42Oe8-.js → main-Iz1ZCL6k.js} +41753 -89461
- 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-sHPSE-jD.js → vue3-xml-viewer.common-tVI9uXUz.js} +1 -1
- package/package.json +11 -4
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +3 -0
- package/src/components/DataserviceCard.vue +3 -0
- package/src/components/DatasetCard.vue +9 -4
- package/src/components/ObjectCardHeader.vue +11 -4
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
- package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -102
- package/src/components/ResourceExplorer/ResourceExplorer.vue +2 -55
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +26 -135
- package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
- package/src/components/ReuseCard.vue +12 -4
- package/src/components/Search/GlobalSearch.vue +30 -7
- package/src/components/Search/SearchInput.vue +2 -1
- package/src/components/TabularExplorer/TabularExplorer.vue +257 -154
- package/src/composables/useHasTabularData.ts +7 -0
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useStableQueryParams.ts +7 -3
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/config.ts +17 -3
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +5 -34
- package/src/functions/metrics.ts +6 -4
- package/src/main.ts +39 -6
- package/src/types/search.ts +11 -0
- package/src/types/ui.ts +2 -0
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
<SearchInput
|
|
14
14
|
v-model="q"
|
|
15
15
|
:placeholder="resolvedPlaceholder"
|
|
16
|
+
:auto-focus
|
|
16
17
|
/>
|
|
17
18
|
</div>
|
|
18
19
|
<div class="grid grid-cols-12 mt-2 md:mt-5">
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
:value="configKey(typeConfig)"
|
|
36
37
|
:count="resultsMap[configKey(typeConfig)]?.data.value?.total"
|
|
37
38
|
:loading="resultsMap[configKey(typeConfig)]?.status.value === 'pending' || resultsMap[configKey(typeConfig)]?.status.value === 'idle'"
|
|
38
|
-
:icon="strategies[typeConfig.class].icon"
|
|
39
|
+
:icon="typeConfig.icon ?? strategies[typeConfig.class].icon"
|
|
39
40
|
>
|
|
40
41
|
{{ typeConfig.name || strategies[typeConfig.class].name }}
|
|
41
42
|
</RadioInput>
|
|
@@ -199,10 +200,13 @@
|
|
|
199
200
|
<div class="fr-col">
|
|
200
201
|
<select
|
|
201
202
|
id="sort-search"
|
|
202
|
-
v-model="
|
|
203
|
+
v-model="effectiveSort"
|
|
203
204
|
class="fr-select text-sm shadow-input-blue!"
|
|
204
205
|
>
|
|
205
|
-
<option
|
|
206
|
+
<option
|
|
207
|
+
v-if="!currentTypeConfig?.defaultSort"
|
|
208
|
+
:value="undefined"
|
|
209
|
+
>
|
|
206
210
|
{{ t('Pertinence') }}
|
|
207
211
|
</option>
|
|
208
212
|
<option
|
|
@@ -401,11 +405,17 @@ const props = withDefaults(defineProps<{
|
|
|
401
405
|
config?: GlobalSearchConfig
|
|
402
406
|
placeholder?: string | null
|
|
403
407
|
hideSearchInput?: boolean
|
|
408
|
+
autoFocus?: boolean
|
|
404
409
|
}>(), {
|
|
405
410
|
config: getDefaultGlobalSearchConfig,
|
|
406
411
|
hideSearchInput: false,
|
|
412
|
+
autoFocus: true,
|
|
407
413
|
})
|
|
408
414
|
|
|
415
|
+
const emit = defineEmits<{
|
|
416
|
+
resultsCount: [total: number]
|
|
417
|
+
}>()
|
|
418
|
+
|
|
409
419
|
// defineModel's default is static and can't depend on props, so we cast and initialize manually
|
|
410
420
|
const currentType = defineModel<string>('type') as Ref<string>
|
|
411
421
|
if (!currentType.value) currentType.value = configKey(props.config[0] ?? { class: 'datasets' })
|
|
@@ -475,8 +485,17 @@ const showSidebar = computed(() => props.config.length > 1 || activeFilters.valu
|
|
|
475
485
|
// URL query params
|
|
476
486
|
const q = useRouteQuery<string>('q', '')
|
|
477
487
|
const { debounced: qDebounced, flush: flushQ } = useDebouncedRef(q, componentsConfig.searchDebounce ?? 300)
|
|
488
|
+
// When the search input is hidden, the parent owns the input and is expected
|
|
489
|
+
// to debounce user typing itself (otherwise typing would land in the URL
|
|
490
|
+
// instantly via v-model and stack two debounces). Bypass the internal debounce
|
|
491
|
+
// so URL-driven q changes hit the fetch params immediately.
|
|
492
|
+
const qForParams = computed(() => props.hideSearchInput ? q.value : qDebounced.value)
|
|
478
493
|
const page = useRouteQuery('page', 1, { transform: Number })
|
|
479
494
|
const sort = useRouteQuery<string | undefined>('sort')
|
|
495
|
+
const effectiveSort = computed({
|
|
496
|
+
get: () => sort.value ?? currentTypeConfig.value?.defaultSort,
|
|
497
|
+
set: (value) => { sort.value = value },
|
|
498
|
+
})
|
|
480
499
|
|
|
481
500
|
provide(searchFilterContextKey, {
|
|
482
501
|
register(urlParam, entry) {
|
|
@@ -550,7 +569,7 @@ watch(currentType, () => {
|
|
|
550
569
|
const stableParamsOptions = {
|
|
551
570
|
allFilters,
|
|
552
571
|
customFilterRegistry,
|
|
553
|
-
q:
|
|
572
|
+
q: qForParams,
|
|
554
573
|
sort,
|
|
555
574
|
page,
|
|
556
575
|
pageSize,
|
|
@@ -643,7 +662,7 @@ for (const c of props.config) {
|
|
|
643
662
|
// intentionally excluded here to avoid resetting the page when a filter
|
|
644
663
|
// registers with its URL-derived value.
|
|
645
664
|
const filtersForReset = computed(() => ({
|
|
646
|
-
q:
|
|
665
|
+
q: qForParams.value,
|
|
647
666
|
organization: organizationId.value,
|
|
648
667
|
organization_badge: organizationType.value,
|
|
649
668
|
tag: tag.value,
|
|
@@ -712,6 +731,10 @@ function resetFilters() {
|
|
|
712
731
|
const searchResults = computed(() => resultsMap[currentType.value]?.data.value)
|
|
713
732
|
const searchResultsStatus = computed(() => resultsMap[currentType.value]?.status.value)
|
|
714
733
|
|
|
734
|
+
watch(searchResults, (results) => {
|
|
735
|
+
if (results) emit('resultsCount', results.total)
|
|
736
|
+
}, { immediate: true })
|
|
737
|
+
|
|
715
738
|
// RSS feed URL for datasets
|
|
716
739
|
const rssUrl = computed(() => {
|
|
717
740
|
if (currentTypeConfig.value?.class !== 'datasets') return null
|
|
@@ -726,7 +749,7 @@ const rssUrl = computed(() => {
|
|
|
726
749
|
}
|
|
727
750
|
|
|
728
751
|
// Add active filters
|
|
729
|
-
if (
|
|
752
|
+
if (qForParams.value) params.set('q', qForParams.value)
|
|
730
753
|
if (organizationId.value) params.set('organization', organizationId.value)
|
|
731
754
|
if (organizationType.value) params.set('organization_badge', organizationType.value)
|
|
732
755
|
if (tag.value) params.set('tag', tag.value)
|
|
@@ -743,7 +766,7 @@ const rssUrl = computed(() => {
|
|
|
743
766
|
}, currentTypeConfig.value ? configKey(currentTypeConfig.value) : undefined)
|
|
744
767
|
|
|
745
768
|
// Add sort if set
|
|
746
|
-
if (
|
|
769
|
+
if (effectiveSort.value) params.set('sort', effectiveSort.value)
|
|
747
770
|
|
|
748
771
|
const queryString = params.toString()
|
|
749
772
|
const basePath = '/api/1/datasets/recent.atom'
|
|
@@ -37,7 +37,7 @@ import BrandedButton from '../BrandedButton.vue'
|
|
|
37
37
|
|
|
38
38
|
const q = defineModel<string>({ required: true })
|
|
39
39
|
|
|
40
|
-
withDefaults(defineProps<{
|
|
40
|
+
const props = withDefaults(defineProps<{
|
|
41
41
|
placeholder?: string | null
|
|
42
42
|
autoFocus?: boolean
|
|
43
43
|
}>(), {
|
|
@@ -57,6 +57,7 @@ const focus = () => {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
onMounted(async () => {
|
|
60
|
+
if (!props.autoFocus) return
|
|
60
61
|
await nextTick()
|
|
61
62
|
focus()
|
|
62
63
|
})
|
|
@@ -1,164 +1,218 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div>
|
|
3
|
-
<
|
|
4
|
-
v-if="
|
|
5
|
-
|
|
6
|
-
class="mb-4"
|
|
3
|
+
<div
|
|
4
|
+
v-if="previewError"
|
|
5
|
+
class="max-w-3xl mx-auto"
|
|
7
6
|
>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<div class="flex-1" />
|
|
28
|
-
|
|
29
|
-
<!-- Right: Stats -->
|
|
30
|
-
<div class="flex items-center gap-4">
|
|
31
|
-
<!-- Columns (clickable, opens column popover) -->
|
|
32
|
-
<Popover
|
|
33
|
-
ref="columnAnchor"
|
|
34
|
-
v-slot="{ open }"
|
|
35
|
-
class="relative"
|
|
36
|
-
>
|
|
37
|
-
<PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
|
|
38
|
-
<RiLayoutColumnLine
|
|
39
|
-
class="size-3"
|
|
40
|
-
aria-hidden="true"
|
|
41
|
-
/>
|
|
42
|
-
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
43
|
-
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
44
|
-
<RiArrowDownSLine
|
|
45
|
-
class="size-3 opacity-50"
|
|
46
|
-
aria-hidden="true"
|
|
47
|
-
/>
|
|
48
|
-
</PopoverButton>
|
|
49
|
-
|
|
50
|
-
<ClientOnly>
|
|
51
|
-
<Teleport to="#tooltips">
|
|
52
|
-
<PopoverPanel
|
|
53
|
-
v-show="open"
|
|
54
|
-
ref="columnPanel"
|
|
55
|
-
static
|
|
56
|
-
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
57
|
-
:style="columnFloatingStyles"
|
|
58
|
-
>
|
|
59
|
-
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
|
|
60
|
-
<span class="text-xs font-medium text-gray-title">
|
|
61
|
-
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
62
|
-
</span>
|
|
63
|
-
<BrandedButton
|
|
64
|
-
v-if="hiddenCount"
|
|
65
|
-
color="tertiary"
|
|
66
|
-
size="2xs"
|
|
67
|
-
@click="showAllColumns"
|
|
68
|
-
>
|
|
69
|
-
{{ t('Tout afficher') }}
|
|
70
|
-
</BrandedButton>
|
|
71
|
-
</div>
|
|
72
|
-
<div class="max-h-64 overflow-auto p-1">
|
|
73
|
-
<label
|
|
74
|
-
v-for="col in allColumns"
|
|
75
|
-
:key="col"
|
|
76
|
-
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
|
|
77
|
-
>
|
|
78
|
-
<input
|
|
79
|
-
type="checkbox"
|
|
80
|
-
:checked="visibleColumns.has(col)"
|
|
81
|
-
class="size-3.5 accent-new-primary"
|
|
82
|
-
@change="toggleColumn(col)"
|
|
83
|
-
>
|
|
84
|
-
<span class="truncate">{{ col }}</span>
|
|
85
|
-
</label>
|
|
86
|
-
</div>
|
|
87
|
-
</PopoverPanel>
|
|
88
|
-
</Teleport>
|
|
89
|
-
</ClientOnly>
|
|
90
|
-
</Popover>
|
|
91
|
-
|
|
92
|
-
<!-- Rows -->
|
|
93
|
-
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
94
|
-
<RiLayoutRowLine
|
|
95
|
-
class="size-3 text-mention-grey"
|
|
96
|
-
aria-hidden="true"
|
|
97
|
-
/>
|
|
98
|
-
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
99
|
-
<span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
100
|
-
</span>
|
|
7
|
+
<SimpleBanner
|
|
8
|
+
type="warning"
|
|
9
|
+
class="mb-4"
|
|
10
|
+
>
|
|
11
|
+
{{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
|
|
12
|
+
<pre class="text-xs mt-2 whitespace-pre-wrap break-words">{{ previewError }}</pre>
|
|
13
|
+
</SimpleBanner>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div
|
|
17
|
+
v-else-if="previewLoading"
|
|
18
|
+
class="animate-pulse-placeholder"
|
|
19
|
+
:aria-label="t('Chargement de l\'aperçu…')"
|
|
20
|
+
role="status"
|
|
21
|
+
>
|
|
22
|
+
<div class="container">
|
|
23
|
+
<div class="flex items-center justify-end gap-4 py-3">
|
|
24
|
+
<div class="h-4 w-20 bg-gray-200" />
|
|
25
|
+
<div class="h-4 w-20 bg-gray-200" />
|
|
101
26
|
</div>
|
|
102
27
|
</div>
|
|
28
|
+
<div class="overflow-hidden">
|
|
29
|
+
<table class="w-full text-sm border-collapse">
|
|
30
|
+
<thead class="shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
31
|
+
<tr>
|
|
32
|
+
<th
|
|
33
|
+
v-for="i in 6"
|
|
34
|
+
:key="i"
|
|
35
|
+
class="px-3 py-2 text-left"
|
|
36
|
+
>
|
|
37
|
+
<div class="h-4 w-24 bg-gray-200" />
|
|
38
|
+
</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody>
|
|
42
|
+
<tr
|
|
43
|
+
v-for="row in 8"
|
|
44
|
+
:key="row"
|
|
45
|
+
class="border-b border-gray-100"
|
|
46
|
+
>
|
|
47
|
+
<td
|
|
48
|
+
v-for="col in 6"
|
|
49
|
+
:key="col"
|
|
50
|
+
class="px-3 py-2.5"
|
|
51
|
+
>
|
|
52
|
+
<div class="h-3 bg-gray-200" />
|
|
53
|
+
</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
103
59
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
class="
|
|
108
|
-
|
|
109
|
-
<!-- Header -->
|
|
110
|
-
<div class="flex items-center justify-between">
|
|
111
|
-
<div class="flex items-center gap-2">
|
|
112
|
-
<RiFilter2Line
|
|
113
|
-
class="size-3.5"
|
|
114
|
-
aria-hidden="true"
|
|
115
|
-
/>
|
|
116
|
-
<span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
|
|
117
|
-
<span class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5">
|
|
118
|
-
{{ activeFilters.length }}
|
|
119
|
-
</span>
|
|
120
|
-
</div>
|
|
60
|
+
<template v-else-if="tableData && profileData">
|
|
61
|
+
<!-- Toolbar (constrained width — only the table itself goes edge-to-edge) -->
|
|
62
|
+
<div class="container">
|
|
63
|
+
<div class="flex items-center py-3 gap-2">
|
|
64
|
+
<!-- Mobile: filter & sort button -->
|
|
121
65
|
<BrandedButton
|
|
66
|
+
class="md:hidden"
|
|
122
67
|
color="tertiary"
|
|
123
68
|
size="2xs"
|
|
124
|
-
:icon="
|
|
125
|
-
|
|
69
|
+
:icon="RiFilter2Line"
|
|
70
|
+
keep-margins-even-without-borders
|
|
71
|
+
@click="mobileFilterOpen = true"
|
|
126
72
|
>
|
|
127
|
-
{{ t('
|
|
73
|
+
{{ t('Filtres & tri') }}
|
|
128
74
|
</BrandedButton>
|
|
75
|
+
|
|
76
|
+
<div class="flex-1" />
|
|
77
|
+
|
|
78
|
+
<!-- Right: Stats -->
|
|
79
|
+
<div class="flex items-center gap-4">
|
|
80
|
+
<!-- Columns (clickable, opens column popover) -->
|
|
81
|
+
<Popover
|
|
82
|
+
ref="columnAnchor"
|
|
83
|
+
v-slot="{ open }"
|
|
84
|
+
class="relative"
|
|
85
|
+
>
|
|
86
|
+
<PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
|
|
87
|
+
<RiLayoutColumnLine
|
|
88
|
+
class="size-3"
|
|
89
|
+
aria-hidden="true"
|
|
90
|
+
/>
|
|
91
|
+
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
92
|
+
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
93
|
+
<RiArrowDownSLine
|
|
94
|
+
class="size-3 opacity-50"
|
|
95
|
+
aria-hidden="true"
|
|
96
|
+
/>
|
|
97
|
+
</PopoverButton>
|
|
98
|
+
|
|
99
|
+
<ClientOnly>
|
|
100
|
+
<Teleport to="#tooltips">
|
|
101
|
+
<PopoverPanel
|
|
102
|
+
v-show="open"
|
|
103
|
+
ref="columnPanel"
|
|
104
|
+
static
|
|
105
|
+
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
106
|
+
:style="columnFloatingStyles"
|
|
107
|
+
>
|
|
108
|
+
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
|
|
109
|
+
<span class="text-xs font-medium text-gray-title">
|
|
110
|
+
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
111
|
+
</span>
|
|
112
|
+
<BrandedButton
|
|
113
|
+
v-if="hiddenCount"
|
|
114
|
+
color="tertiary"
|
|
115
|
+
size="2xs"
|
|
116
|
+
@click="showAllColumns"
|
|
117
|
+
>
|
|
118
|
+
{{ t('Tout afficher') }}
|
|
119
|
+
</BrandedButton>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="max-h-64 overflow-auto p-1">
|
|
122
|
+
<label
|
|
123
|
+
v-for="col in allColumns"
|
|
124
|
+
:key="col"
|
|
125
|
+
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
|
|
126
|
+
>
|
|
127
|
+
<input
|
|
128
|
+
type="checkbox"
|
|
129
|
+
:checked="visibleColumns.has(col)"
|
|
130
|
+
class="size-3.5 accent-new-primary"
|
|
131
|
+
@change="toggleColumn(col)"
|
|
132
|
+
>
|
|
133
|
+
<span class="truncate">{{ col }}</span>
|
|
134
|
+
</label>
|
|
135
|
+
</div>
|
|
136
|
+
</PopoverPanel>
|
|
137
|
+
</Teleport>
|
|
138
|
+
</ClientOnly>
|
|
139
|
+
</Popover>
|
|
140
|
+
|
|
141
|
+
<!-- Rows -->
|
|
142
|
+
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
143
|
+
<RiLayoutRowLine
|
|
144
|
+
class="size-3 text-mention-grey"
|
|
145
|
+
aria-hidden="true"
|
|
146
|
+
/>
|
|
147
|
+
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
148
|
+
<span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
129
151
|
</div>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
|
|
153
|
+
<!-- Active filters -->
|
|
154
|
+
<div
|
|
155
|
+
v-if="activeFilters.length > 0"
|
|
156
|
+
class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
|
|
157
|
+
>
|
|
158
|
+
<!-- Header -->
|
|
159
|
+
<div class="flex items-center justify-between">
|
|
160
|
+
<div class="flex items-center gap-2">
|
|
161
|
+
<RiFilter2Line
|
|
162
|
+
class="size-3.5"
|
|
163
|
+
aria-hidden="true"
|
|
164
|
+
/>
|
|
165
|
+
<span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
|
|
166
|
+
<span class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5">
|
|
167
|
+
{{ activeFilters.length }}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
<BrandedButton
|
|
171
|
+
color="tertiary"
|
|
172
|
+
size="2xs"
|
|
173
|
+
:icon="RiCloseLine"
|
|
174
|
+
@click="clearAllFilters"
|
|
175
|
+
>
|
|
176
|
+
{{ t('Tout effacer') }}
|
|
177
|
+
</BrandedButton>
|
|
178
|
+
</div>
|
|
179
|
+
<!-- Chips -->
|
|
180
|
+
<div class="flex flex-wrap gap-1.5">
|
|
181
|
+
<span
|
|
182
|
+
v-for="af in activeFilters"
|
|
183
|
+
:key="af.column"
|
|
184
|
+
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
147
185
|
>
|
|
148
|
-
<
|
|
149
|
-
|
|
186
|
+
<component
|
|
187
|
+
:is="getTypeConfig(af.column).icon"
|
|
188
|
+
class="size-3 text-gray-title"
|
|
150
189
|
aria-hidden="true"
|
|
151
190
|
/>
|
|
152
|
-
<span class="
|
|
153
|
-
|
|
154
|
-
|
|
191
|
+
<span class="text-gray-title">{{ af.column }}</span>
|
|
192
|
+
<span class="text-gray-plain">{{ af.label }}</span>
|
|
193
|
+
<button
|
|
194
|
+
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
195
|
+
@click="removeFilter(af.column)"
|
|
196
|
+
>
|
|
197
|
+
<RiCloseLine
|
|
198
|
+
class="size-3"
|
|
199
|
+
aria-hidden="true"
|
|
200
|
+
/>
|
|
201
|
+
<span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
|
|
202
|
+
</button>
|
|
203
|
+
</span>
|
|
204
|
+
</div>
|
|
155
205
|
</div>
|
|
156
206
|
</div>
|
|
207
|
+
<!-- /container (toolbar + active filters) -->
|
|
157
208
|
|
|
158
209
|
<!-- Desktop: scrollable table -->
|
|
210
|
+
<!-- `-mx-4` lets the table extend edge-to-edge of the parent's px-4 wrapper
|
|
211
|
+
(used in both ResourceExplorerViewer's TabPanel and pages/explore.vue),
|
|
212
|
+
while the toolbar and active-filter rows above keep that padding. -->
|
|
159
213
|
<div
|
|
160
214
|
ref="scrollContainer"
|
|
161
|
-
class="hidden md:block overflow-auto max-h-[70vh]"
|
|
215
|
+
class="hidden md:block overflow-auto max-h-[70vh] -mx-4"
|
|
162
216
|
>
|
|
163
217
|
<table class="text-sm border-collapse">
|
|
164
218
|
<thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
@@ -457,7 +511,7 @@
|
|
|
457
511
|
</template>
|
|
458
512
|
|
|
459
513
|
<script setup lang="ts">
|
|
460
|
-
import { computed, onUnmounted, ref, watch, useTemplateRef } from 'vue'
|
|
514
|
+
import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
|
|
461
515
|
import { ofetch } from 'ofetch'
|
|
462
516
|
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
463
517
|
import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
@@ -474,6 +528,7 @@ import {
|
|
|
474
528
|
import { useFetch } from '../../functions/api'
|
|
475
529
|
import { useComponentsConfig } from '../../config'
|
|
476
530
|
import { useTranslation } from '../../composables/useTranslation'
|
|
531
|
+
import { injectTabularProfile } from '../../composables/useTabularProfile'
|
|
477
532
|
import { buildTypeConfig, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
|
|
478
533
|
import ClientOnly from '../ClientOnly.vue'
|
|
479
534
|
import SimpleBanner from '../SimpleBanner.vue'
|
|
@@ -484,7 +539,7 @@ import TabularCellPopover from './TabularCellPopover.vue'
|
|
|
484
539
|
import type { CellInfo } from './TabularCellPopover.vue'
|
|
485
540
|
import TabularFilterContent from './TabularFilterContent.vue'
|
|
486
541
|
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
487
|
-
import type { TabularDataResponse,
|
|
542
|
+
import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
|
|
488
543
|
|
|
489
544
|
const props = defineProps<{
|
|
490
545
|
resourceId: string
|
|
@@ -509,10 +564,6 @@ const dataUrl = computed(() =>
|
|
|
509
564
|
`${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
|
|
510
565
|
)
|
|
511
566
|
|
|
512
|
-
const profileUrl = computed(() =>
|
|
513
|
-
`${config.tabularApiUrl}/api/resources/${props.resourceId}/profile/`,
|
|
514
|
-
)
|
|
515
|
-
|
|
516
567
|
// Sort & filter state
|
|
517
568
|
const sort = ref<SortConfig | null>(null)
|
|
518
569
|
const filters = ref<Record<string, ColumnFilters>>({})
|
|
@@ -552,7 +603,17 @@ const dataQuery = computed(() => {
|
|
|
552
603
|
|
|
553
604
|
const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
|
|
554
605
|
|
|
555
|
-
|
|
606
|
+
// Profile is shared with sibling components (e.g. DataStructure) via
|
|
607
|
+
// `provideTabularProfile` in the parent. Falls back to a local fetch
|
|
608
|
+
// when no parent provides it (standalone usage).
|
|
609
|
+
const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
|
|
610
|
+
|
|
611
|
+
// The component renders nothing useful until the profile is available
|
|
612
|
+
// (allColumns is derived from it). Surface a clear loading / error state
|
|
613
|
+
// so we don't end up with an empty table + a spinner running forever.
|
|
614
|
+
const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
|
|
615
|
+
const previewError = computed(() => error.value || profileError.value)
|
|
616
|
+
const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
|
|
556
617
|
|
|
557
618
|
// Infinite scroll state
|
|
558
619
|
const allRows = ref<TabularRow[]>([])
|
|
@@ -625,23 +686,65 @@ function showAllColumns() {
|
|
|
625
686
|
visibleColumns.value = new Set(allColumns.value)
|
|
626
687
|
}
|
|
627
688
|
|
|
628
|
-
// Column
|
|
689
|
+
// Column widths
|
|
690
|
+
//
|
|
691
|
+
// Strategy:
|
|
692
|
+
// - On first render (when both data and profile are loaded), we measure the
|
|
693
|
+
// natural offsetWidth of each <th>, then distribute any leftover container
|
|
694
|
+
// space proportionally so the table fills the available horizontal area.
|
|
695
|
+
// - We immediately lock all columns to those widths via `columnWidths`, so
|
|
696
|
+
// pagination, filtering, sorting or infinite-scroll never trigger a layout
|
|
697
|
+
// shift (the table doesn't re-auto-size based on newly visible content).
|
|
698
|
+
// - Manual resize via the drag handle keeps working unchanged: it always sees
|
|
699
|
+
// columnWidths populated, so it goes straight into the resize loop.
|
|
629
700
|
const columnWidths = ref<Record<string, number>>({})
|
|
630
701
|
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
631
702
|
|
|
632
|
-
function
|
|
633
|
-
const
|
|
634
|
-
if (!
|
|
635
|
-
const
|
|
703
|
+
function measureAndDistributeColumnWidths() {
|
|
704
|
+
const container = scrollContainerRef.value
|
|
705
|
+
if (!container) return
|
|
706
|
+
const ths = container.querySelectorAll('th')
|
|
707
|
+
if (!ths || ths.length === 0) return
|
|
708
|
+
|
|
709
|
+
const naturalWidths: Record<string, number> = {}
|
|
710
|
+
let naturalSum = 0
|
|
636
711
|
ths.forEach((th, i) => {
|
|
637
712
|
const col = displayedColumns.value[i]
|
|
638
|
-
if (col)
|
|
713
|
+
if (!col) return
|
|
714
|
+
const w = th.offsetWidth
|
|
715
|
+
naturalWidths[col] = w
|
|
716
|
+
naturalSum += w
|
|
639
717
|
})
|
|
718
|
+
if (naturalSum === 0) return
|
|
719
|
+
|
|
720
|
+
// Inflate to fill container if columns don't naturally cover it.
|
|
721
|
+
const containerWidth = container.clientWidth
|
|
722
|
+
const ratio = naturalSum < containerWidth ? containerWidth / naturalSum : 1
|
|
723
|
+
|
|
724
|
+
const widths: Record<string, number> = {}
|
|
725
|
+
for (const [col, w] of Object.entries(naturalWidths)) {
|
|
726
|
+
widths[col] = Math.floor(w * ratio)
|
|
727
|
+
}
|
|
640
728
|
columnWidths.value = widths
|
|
641
729
|
}
|
|
642
730
|
|
|
731
|
+
// Lock columns once both data and profile are ready (so the <thead> is rendered
|
|
732
|
+
// with content-driven natural widths). Only runs once — subsequent data changes
|
|
733
|
+
// (pagination, filters) keep the locked widths and avoid layout shifts.
|
|
734
|
+
watch(
|
|
735
|
+
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
736
|
+
async ([dataReady, colCount]) => {
|
|
737
|
+
if (!dataReady || colCount === 0) return
|
|
738
|
+
if (Object.keys(columnWidths.value).length > 0) return
|
|
739
|
+
await nextTick()
|
|
740
|
+
measureAndDistributeColumnWidths()
|
|
741
|
+
},
|
|
742
|
+
{ immediate: true },
|
|
743
|
+
)
|
|
744
|
+
|
|
643
745
|
function startResize(col: string, e: MouseEvent) {
|
|
644
|
-
|
|
746
|
+
// Fallback in case the user grabs the handle before the initial measure ran.
|
|
747
|
+
if (!Object.keys(columnWidths.value).length) measureAndDistributeColumnWidths()
|
|
645
748
|
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
646
749
|
// Disable smooth scroll during resize
|
|
647
750
|
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
@@ -11,10 +11,17 @@ export const useHasTabularData = () => {
|
|
|
11
11
|
const config = useComponentsConfig()
|
|
12
12
|
|
|
13
13
|
return (resource: Resource) => {
|
|
14
|
+
// Reject resources whose source URL last check failed (>= 400):
|
|
15
|
+
// the tabular-api purges its parquet when the source dies, but
|
|
16
|
+
// `parsing_table` may still be set — leading to a 404 on fetch.
|
|
17
|
+
const checkStatus = resource.extras['check:status']
|
|
18
|
+
const sourceUnreachable = typeof checkStatus === 'number' && checkStatus >= 400
|
|
19
|
+
|
|
14
20
|
return (
|
|
15
21
|
config.tabularApiUrl
|
|
16
22
|
&& resource.extras['analysis:parsing:parsing_table']
|
|
17
23
|
&& !resource.extras['analysis:parsing:error']
|
|
24
|
+
&& !sourceUnreachable
|
|
18
25
|
&& (config.tabularAllowRemote || resource.filetype === 'file')
|
|
19
26
|
)
|
|
20
27
|
}
|
|
@@ -13,6 +13,6 @@ export function useMetrics() {
|
|
|
13
13
|
getDatasetMetrics: (datasetId: string) => getDatasetMetrics(datasetId, config.metricsApiUrl!),
|
|
14
14
|
getDataserviceMetrics: (dataserviceId: string) => getDataserviceMetrics(dataserviceId, config.metricsApiUrl!),
|
|
15
15
|
getReuseMetrics: (reuseId: string) => getReuseMetrics(reuseId, config.metricsApiUrl!),
|
|
16
|
-
createDatasetsForOrganizationMetricsUrl: (organizationId: string) => createDatasetsForOrganizationMetricsUrl(organizationId, config.metricsApiUrl!, config.apiBase),
|
|
16
|
+
createDatasetsForOrganizationMetricsUrl: (organizationId: string) => createDatasetsForOrganizationMetricsUrl(organizationId, config.metricsApiUrl!, config.apiBase, config.$fetch),
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -72,10 +72,14 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
|
|
|
72
72
|
if (q.value) {
|
|
73
73
|
params.q = q.value
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
const sortToUse = sort.value ?? typeConfig?.defaultSort
|
|
76
|
+
if (sortToUse) {
|
|
76
77
|
const validSortValues = typeConfig?.sortOptions?.map(o => o.value as string) ?? []
|
|
77
|
-
if (validSortValues.includes(
|
|
78
|
-
params.sort =
|
|
78
|
+
if (validSortValues.includes(sortToUse)) {
|
|
79
|
+
params.sort = sortToUse
|
|
80
|
+
}
|
|
81
|
+
else if (import.meta.env.DEV && typeConfig?.defaultSort && typeConfig?.sortOptions && sortToUse === typeConfig.defaultSort) {
|
|
82
|
+
console.warn(`[GlobalSearch] defaultSort "${typeConfig.defaultSort}" is not in sortOptions for "${typeConfig.class}". Valid values: ${validSortValues.join(', ')}`)
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
params.page = page.value
|