@datagouv/components-next 1.0.2-dev.97 → 1.0.2-dev.98

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.
Files changed (25) hide show
  1. package/dist/{Datafair.client-8bXp6UeQ.js → Datafair.client-C2j760M5.js} +1 -1
  2. package/dist/{JsonPreview.client-BxuoPK_w.js → JsonPreview.client-PFfBR4J8.js} +2 -2
  3. package/dist/{MapContainer.client-CTz0wmJG.js → MapContainer.client-CGrS2baS.js} +2 -2
  4. package/dist/{PdfPreview.client-DpVreUSl.js → PdfPreview.client-DU36UBGQ.js} +2 -2
  5. package/dist/{Pmtiles.client-BwmHo3T8.js → Pmtiles.client-DuTezcn5.js} +1 -1
  6. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-stmU5qEB.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-ProPRqX6.js} +1 -1
  7. package/dist/{XmlPreview.client-DAOs89cR.js → XmlPreview.client-Bcq2Ye14.js} +3 -3
  8. package/dist/components-next.css +1 -1
  9. package/dist/components-next.js +169 -161
  10. package/dist/components.css +1 -1
  11. package/dist/{index-JqjPja1u.js → index-BJ-zwAF5.js} +1 -1
  12. package/dist/{main-D4WQMky0.js → main-TqHFAOCi.js} +73370 -73298
  13. package/dist/{vue3-xml-viewer.common-BGsoNyZe.js → vue3-xml-viewer.common-BnJTx_B7.js} +1 -1
  14. package/package.json +1 -1
  15. package/src/components/DatasetCard.vue +6 -4
  16. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  17. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  18. package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -102
  19. package/src/components/ResourceExplorer/ResourceExplorer.vue +2 -55
  20. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +26 -135
  21. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  22. package/src/components/TabularExplorer/TabularExplorer.vue +257 -154
  23. package/src/composables/useHasTabularData.ts +7 -0
  24. package/src/composables/useTabularProfile.ts +70 -0
  25. package/src/main.ts +12 -0
@@ -1,164 +1,218 @@
1
1
  <template>
2
2
  <div>
3
- <SimpleBanner
4
- v-if="error"
5
- type="warning"
6
- class="mb-4"
3
+ <div
4
+ v-if="previewError"
5
+ class="max-w-3xl mx-auto"
7
6
  >
8
- {{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
9
- <pre class="text-xs mt-2">{{ error }}</pre>
10
- </SimpleBanner>
11
-
12
- <template v-else-if="tableData">
13
- <!-- Toolbar -->
14
- <div class="flex items-center py-3 gap-2">
15
- <!-- Mobile: filter & sort button -->
16
- <BrandedButton
17
- class="md:hidden"
18
- color="tertiary"
19
- size="2xs"
20
- :icon="RiFilter2Line"
21
- keep-margins-even-without-borders
22
- @click="mobileFilterOpen = true"
23
- >
24
- {{ t('Filtres & tri') }}
25
- </BrandedButton>
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
- <!-- Active filters -->
105
- <div
106
- v-if="activeFilters.length > 0"
107
- class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
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="RiCloseLine"
125
- @click="clearAllFilters"
69
+ :icon="RiFilter2Line"
70
+ keep-margins-even-without-borders
71
+ @click="mobileFilterOpen = true"
126
72
  >
127
- {{ t('Tout effacer') }}
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
- <!-- Chips -->
131
- <div class="flex flex-wrap gap-1.5">
132
- <span
133
- v-for="af in activeFilters"
134
- :key="af.column"
135
- class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
136
- >
137
- <component
138
- :is="getTypeConfig(af.column).icon"
139
- class="size-3 text-gray-title"
140
- aria-hidden="true"
141
- />
142
- <span class="text-gray-title">{{ af.column }}</span>
143
- <span class="text-gray-plain">{{ af.label }}</span>
144
- <button
145
- class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
146
- @click="removeFilter(af.column)"
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
- <RiCloseLine
149
- class="size-3"
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="sr-only">{{ t('Supprimer ce filtre') }}</span>
153
- </button>
154
- </span>
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, TabularProfileResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
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
- const { data: profileData } = await useFetch<TabularProfileResponse>(profileUrl, { raw: true })
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 resize
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 initColumnWidths() {
633
- const ths = scrollContainerRef.value?.querySelectorAll('th')
634
- if (!ths) return
635
- const widths: Record<string, number> = {}
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) widths[col] = th.offsetWidth
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
- if (!Object.keys(columnWidths.value).length) initColumnWidths()
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,