@datagouv/components-next 1.0.2-dev.97 → 1.0.2-dev.99
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/{Datafair.client-8bXp6UeQ.js → Datafair.client-C2j760M5.js} +1 -1
- package/dist/{JsonPreview.client-BxuoPK_w.js → JsonPreview.client-PFfBR4J8.js} +2 -2
- package/dist/{MapContainer.client-CTz0wmJG.js → MapContainer.client-CGrS2baS.js} +2 -2
- package/dist/{PdfPreview.client-DpVreUSl.js → PdfPreview.client-DU36UBGQ.js} +2 -2
- package/dist/{Pmtiles.client-BwmHo3T8.js → Pmtiles.client-DuTezcn5.js} +1 -1
- package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-stmU5qEB.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-ProPRqX6.js} +1 -1
- package/dist/{XmlPreview.client-DAOs89cR.js → XmlPreview.client-Bcq2Ye14.js} +3 -3
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +169 -161
- package/dist/components.css +1 -1
- package/dist/{index-JqjPja1u.js → index-BJ-zwAF5.js} +1 -1
- package/dist/{main-D4WQMky0.js → main-TqHFAOCi.js} +73370 -73298
- package/dist/{vue3-xml-viewer.common-BGsoNyZe.js → vue3-xml-viewer.common-BnJTx_B7.js} +1 -1
- package/package.json +1 -1
- package/src/components/DatasetCard.vue +6 -4
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- 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/TabularExplorer/TabularExplorer.vue +257 -154
- package/src/composables/useHasTabularData.ts +7 -0
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/main.ts +12 -0
|
@@ -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
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { computed, inject, provide, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
|
|
2
|
+
import { useComponentsConfig } from '../config'
|
|
3
|
+
import { useFetch } from '../functions/api'
|
|
4
|
+
import type { AsyncDataRequestStatus } from '../functions/api.types'
|
|
5
|
+
import type { TabularProfileResponse } from '../components/TabularExplorer/types'
|
|
6
|
+
|
|
7
|
+
const TABULAR_PROFILE_KEY = Symbol('tabular-profile')
|
|
8
|
+
|
|
9
|
+
export type TabularProfileState = {
|
|
10
|
+
resourceId: Readonly<Ref<string>>
|
|
11
|
+
data: Readonly<Ref<TabularProfileResponse | null>>
|
|
12
|
+
error: Readonly<Ref<unknown | null>>
|
|
13
|
+
status: Readonly<Ref<AsyncDataRequestStatus>>
|
|
14
|
+
refresh: () => Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// What is shared through provide/inject: the resourceId (to let descendants
|
|
18
|
+
// check they target the same resource) and the in-flight fetch promise. We
|
|
19
|
+
// share the promise rather than the resolved state because `provide()` must
|
|
20
|
+
// run synchronously during setup — see `provideTabularProfile`.
|
|
21
|
+
type TabularProfileShared = {
|
|
22
|
+
resourceId: Readonly<Ref<string>>
|
|
23
|
+
state: Promise<TabularProfileState>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function createProfileState(resourceId: MaybeRefOrGetter<string>): Promise<TabularProfileState> {
|
|
27
|
+
const config = useComponentsConfig()
|
|
28
|
+
const ridRef = computed(() => toValue(resourceId))
|
|
29
|
+
|
|
30
|
+
// Goes through the package's useFetch, which delegates to the host's
|
|
31
|
+
// customUseFetch (Nuxt useFetch in cdata) so the response is shared
|
|
32
|
+
// between SSR and CSR via the Nuxt payload — avoiding a double fetch.
|
|
33
|
+
const profileUrl = computed(() =>
|
|
34
|
+
ridRef.value ? `${config.tabularApiUrl}/api/resources/${ridRef.value}/profile/` : null,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const { data, error, status, refresh } = await useFetch<TabularProfileResponse>(profileUrl, { raw: true })
|
|
38
|
+
|
|
39
|
+
return { resourceId: ridRef, data, error, status, refresh }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parent: fetch the tabular profile once and share it with descendants
|
|
44
|
+
* (TabularExplorer, DataStructure...) via provide/inject.
|
|
45
|
+
*
|
|
46
|
+
* Not async on purpose: `provide()` only works while the active component
|
|
47
|
+
* instance is set, which is lost after the first `await`. So we kick off the
|
|
48
|
+
* fetch, `provide()` the resulting promise synchronously, then return it for
|
|
49
|
+
* the caller to await.
|
|
50
|
+
*/
|
|
51
|
+
export function provideTabularProfile(resourceId: MaybeRefOrGetter<string>): Promise<TabularProfileState> {
|
|
52
|
+
const ridRef = computed(() => toValue(resourceId))
|
|
53
|
+
const state = createProfileState(resourceId)
|
|
54
|
+
provide<TabularProfileShared>(TABULAR_PROFILE_KEY, { resourceId: ridRef, state })
|
|
55
|
+
return state
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Child: get the tabular profile from an ancestor that called
|
|
60
|
+
* `provideTabularProfile` for the same resourceId. Falls back to
|
|
61
|
+
* fetching on its own if no compatible ancestor is found — preserves
|
|
62
|
+
* standalone usage of TabularExplorer / DataStructure.
|
|
63
|
+
*/
|
|
64
|
+
export async function injectTabularProfile(resourceId: MaybeRefOrGetter<string>): Promise<TabularProfileState> {
|
|
65
|
+
const injected = inject<TabularProfileShared | null>(TABULAR_PROFILE_KEY, null)
|
|
66
|
+
if (injected && injected.resourceId.value === toValue(resourceId)) {
|
|
67
|
+
return await injected.state
|
|
68
|
+
}
|
|
69
|
+
return await createProfileState(resourceId)
|
|
70
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -40,6 +40,9 @@ import BrandedButton from './components/BrandedButton.vue'
|
|
|
40
40
|
import CopyButton from './components/CopyButton.vue'
|
|
41
41
|
import DataserviceCard from './components/DataserviceCard.vue'
|
|
42
42
|
import DatasetCard from './components/DatasetCard.vue'
|
|
43
|
+
import DataStructure from './components/ResourceAccordion/DataStructure.vue'
|
|
44
|
+
import Downloads from './components/ResourceAccordion/Downloads.vue'
|
|
45
|
+
import Metadata from './components/ResourceAccordion/Metadata.vue'
|
|
43
46
|
import DescriptionListTerm from './components/DescriptionListTerm.vue'
|
|
44
47
|
import DescriptionListDetails from './components/DescriptionListDetails.vue'
|
|
45
48
|
import DiscussionMessageCard from './components/DiscussionMessageCard.vue'
|
|
@@ -60,6 +63,7 @@ import LoadingBlock from './components/LoadingBlock.vue'
|
|
|
60
63
|
import MarkdownViewer from './components/MarkdownViewer.vue'
|
|
61
64
|
import OrganizationCard from './components/OrganizationCard.vue'
|
|
62
65
|
import OrganizationHorizontalCard from './components/OrganizationHorizontalCard.vue'
|
|
66
|
+
import ObjectCardOwner from './components/ObjectCardOwner.vue'
|
|
63
67
|
import OrganizationLogo from './components/OrganizationLogo.vue'
|
|
64
68
|
import OrganizationNameWithCertificate from './components/OrganizationNameWithCertificate.vue'
|
|
65
69
|
import OwnerType from './components/OwnerType.vue'
|
|
@@ -73,6 +77,7 @@ import PostCard from './components/PostCard.vue'
|
|
|
73
77
|
import ReadMore from './components/ReadMore.vue'
|
|
74
78
|
import ResourceAccordion from './components/ResourceAccordion/ResourceAccordion.vue'
|
|
75
79
|
import ResourceIcon from './components/ResourceAccordion/ResourceIcon.vue'
|
|
80
|
+
import ResourceSelector from './components/ResourceExplorer/ResourceSelector.vue'
|
|
76
81
|
import ResourceExplorer from './components/ResourceExplorer/ResourceExplorer.vue'
|
|
77
82
|
import ResourceExplorerSidebar from './components/ResourceExplorer/ResourceExplorerSidebar.vue'
|
|
78
83
|
import ResourceExplorerViewer from './components/ResourceExplorer/ResourceExplorerViewer.vue'
|
|
@@ -115,6 +120,8 @@ export * from './composables/useMetrics'
|
|
|
115
120
|
export * from './composables/useReuseType'
|
|
116
121
|
export * from './composables/useTranslation'
|
|
117
122
|
export * from './composables/useHasTabularData'
|
|
123
|
+
export * from './composables/useResourceCapabilities'
|
|
124
|
+
export * from './composables/useTabularProfile'
|
|
118
125
|
|
|
119
126
|
export * from './functions/activities'
|
|
120
127
|
export * from './functions/datasets'
|
|
@@ -326,6 +333,9 @@ export {
|
|
|
326
333
|
CopyButton,
|
|
327
334
|
DataserviceCard,
|
|
328
335
|
DatasetCard,
|
|
336
|
+
DataStructure,
|
|
337
|
+
Downloads,
|
|
338
|
+
Metadata,
|
|
329
339
|
DatasetInformationSection,
|
|
330
340
|
DatasetTemporalitySection,
|
|
331
341
|
DatasetSpatialSection,
|
|
@@ -365,7 +375,9 @@ export {
|
|
|
365
375
|
ResourceExplorer,
|
|
366
376
|
ResourceExplorerSidebar,
|
|
367
377
|
ResourceExplorerViewer,
|
|
378
|
+
ObjectCardOwner,
|
|
368
379
|
ResourceIcon,
|
|
380
|
+
ResourceSelector,
|
|
369
381
|
ReuseCard,
|
|
370
382
|
ReuseDetails,
|
|
371
383
|
ReuseHorizontalCard,
|