@datagouv/components-next 1.2.0 → 1.4.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/README.md +7 -6
- package/dist/{Control-ZFh5ta_U.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-rf4T1IkA.js → Datafair.client-D_6yzJUG.js} +9 -9
- package/dist/{Event-DSQcW7OF.js → Event-iXDrp-fy.js} +11 -11
- package/dist/{Image-BijNEG0p.js → Image-mNMBOofs.js} +5 -5
- package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
- package/dist/{JsonPreview.client-dzar6iuh.js → JsonPreview.client-DSl1kiwC.js} +5 -5
- package/dist/{Map-BUtPf5GN.js → Map-BfqKMw0S.js} +1306 -1166
- package/dist/{MapContainer.client-D-MoRNhG.js → MapContainer.client-BSfpk_rT.js} +15 -15
- package/dist/{OSM-D4MTdBtk.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DoDYLmJD.js → PdfPreview.client-BANFeM6K.js} +441 -434
- package/dist/Pmtiles.client-DH9ZrsZR.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
- package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-Dcl7oIVu.js → Tile-CSpn0Ca8.js} +153 -156
- package/dist/{TileImage-BJeHipMX.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-xp_P_OHw.js → View-BYBpt-AU.js} +167 -117
- package/dist/{XmlPreview.client-cOhwff6P.js → XmlPreview.client-KCB5mwVf.js} +5 -5
- package/dist/{common-BjQlan3k.js → common-BoeTUnDZ.js} +26 -26
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +201 -170
- package/dist/components.css +2 -2
- package/dist/{index-NofRBuyf.js → index-DHWzX5vK.js} +5999 -5472
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-pVQfonBI.js} +162 -164
- package/dist/{main-Iz1ZCL6k.js → main-BDcYkhOc.js} +36671 -29402
- package/dist/{proj-CsNo9yH1.js → proj-DxGbobp7.js} +312 -296
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-A0fLnBZr.js → tilecoord-DvX7WWiA.js} +264 -255
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-tVI9uXUz.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
- package/package.json +40 -39
- package/src/components/ActivityList/ActivityList.vue +21 -12
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +111 -17
- package/src/components/Chart/ChartViewerWrapper.vue +28 -16
- package/src/components/ChartCard.vue +112 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +23 -6
- package/src/components/DataserviceQuality.vue +82 -0
- package/src/components/DataserviceQualityInline.vue +31 -0
- package/src/components/DataserviceQualityTooltipContent.vue +79 -0
- package/src/components/DatasetCard.vue +17 -6
- package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
- package/src/components/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- package/src/components/DiscussionMessageCard.vue +14 -15
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/FormattedDate.vue +69 -0
- package/src/components/LoadingBlock.vue +4 -1
- package/src/components/ObjectCard.vue +4 -1
- package/src/components/ObjectCardOwner.vue +13 -1
- package/src/components/Pagination.vue +18 -58
- package/src/components/Placeholder.vue +3 -2
- package/src/components/PostCard.vue +13 -15
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +19 -7
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
- package/src/components/ResourceAccordion/Metadata.vue +21 -5
- package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +30 -19
- package/src/components/ResourceAccordion/Preview.vue +8 -4
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +31 -11
- package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +140 -184
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
- package/src/components/ResourceListItem.vue +168 -0
- package/src/components/ReuseCard.vue +18 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +4 -3
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +28 -10
- package/src/components/Search/SearchInput.vue +1 -1
- package/src/components/Search/Sidemenu.vue +4 -3
- package/src/components/SmallChart.vue +11 -10
- package/src/components/StatBox.vue +13 -5
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +7 -8
- package/src/components/TabularExplorer/TabularCellPopover.vue +21 -5
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularDateField.vue +41 -0
- package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +127 -780
- package/src/components/TabularExplorer/TabularFilterContent.vue +48 -38
- package/src/components/TabularExplorer/TabularFilterPopover.vue +27 -32
- package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
- package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
- package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
- package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
- package/src/components/TabularExplorer/TabularTable.vue +484 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +40 -28
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/components/TopicCard.vue +15 -14
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +2 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +48 -40
- package/src/composables/useSearchFilter.ts +7 -5
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +1 -0
- package/src/functions/api.ts +13 -7
- package/src/functions/charts.ts +32 -1
- package/src/functions/dates.ts +26 -7
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +57 -56
- package/src/functions/owned.ts +21 -0
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +88 -0
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +222 -5
- package/src/functions/tabularApi.ts +1 -43
- package/src/main.ts +32 -6
- package/src/types/dataservices.ts +15 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +2 -2
- package/src/types/visualizations.ts +5 -0
- package/dist/Pmtiles.client-Dzm01Zfm.js +0 -25185
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js +0 -61
- /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
- /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
- /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="fill ? 'flex min-h-0 flex-1 flex-col' : ''">
|
|
3
|
+
<!-- No column selected -->
|
|
4
|
+
<div
|
|
5
|
+
v-if="displayedColumns.length === 0"
|
|
6
|
+
class="flex flex-col items-center py-12"
|
|
7
|
+
>
|
|
8
|
+
<img
|
|
9
|
+
:src="noColumnsSrc"
|
|
10
|
+
class="h-32"
|
|
11
|
+
alt=""
|
|
12
|
+
>
|
|
13
|
+
<p class="font-bold text-gray-title my-3">
|
|
14
|
+
{{ t('Aucune colonne sélectionnée') }}
|
|
15
|
+
</p>
|
|
16
|
+
<BrandedButton
|
|
17
|
+
color="secondary"
|
|
18
|
+
size="xs"
|
|
19
|
+
@click="showAllColumns"
|
|
20
|
+
>
|
|
21
|
+
{{ t('Afficher toutes les colonnes') }}
|
|
22
|
+
</BrandedButton>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Desktop: scrollable table, full width (the host provides no padding here).
|
|
26
|
+
`fill` makes it grow to the bottom (fullscreen); otherwise it's capped. -->
|
|
27
|
+
<div
|
|
28
|
+
v-if="displayedColumns.length > 0"
|
|
29
|
+
ref="scrollContainer"
|
|
30
|
+
class="hidden md:block overflow-auto transition-opacity"
|
|
31
|
+
:class="[
|
|
32
|
+
fill ? 'min-h-0 flex-1' : 'max-h-[70vh]',
|
|
33
|
+
fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '',
|
|
34
|
+
{ 'opacity-40': isRefreshing },
|
|
35
|
+
]"
|
|
36
|
+
:aria-busy="isRefreshing"
|
|
37
|
+
>
|
|
38
|
+
<table
|
|
39
|
+
class="text-sm border-collapse"
|
|
40
|
+
data-testid="data-table"
|
|
41
|
+
>
|
|
42
|
+
<thead
|
|
43
|
+
class="sticky top-0 z-10 bg-gray-some transition-shadow"
|
|
44
|
+
:class="scrolled ? 'shadow-[inset_0_-1px_0_0_#E5E5E5,0_6px_8px_-6px_rgba(0,0,0,0.15)]' : 'shadow-[inset_0_-1px_0_0_#E5E5E5]'"
|
|
45
|
+
>
|
|
46
|
+
<tr class="border-b border-gray-default">
|
|
47
|
+
<th
|
|
48
|
+
v-for="col in displayedColumns"
|
|
49
|
+
:key="col"
|
|
50
|
+
class="group/th relative h-12 px-3 last:pr-5 text-left align-middle whitespace-nowrap border-r border-gray-default last:border-r-0"
|
|
51
|
+
:style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
52
|
+
>
|
|
53
|
+
<div class="flex items-center justify-between gap-1">
|
|
54
|
+
<div class="flex min-w-0 items-center gap-1">
|
|
55
|
+
<!-- Type as an icon only; the human label is on hover. The title
|
|
56
|
+
sits on a wrapper because an SVG ignores it as an attribute,
|
|
57
|
+
and the icon carries the label for screen readers. -->
|
|
58
|
+
<span
|
|
59
|
+
class="flex shrink-0"
|
|
60
|
+
:title="getColumnDisplay(col).label"
|
|
61
|
+
>
|
|
62
|
+
<component
|
|
63
|
+
:is="getColumnDisplay(col).icon"
|
|
64
|
+
class="size-4 text-gray-plain"
|
|
65
|
+
role="img"
|
|
66
|
+
:aria-label="getColumnDisplay(col).label"
|
|
67
|
+
/>
|
|
68
|
+
</span>
|
|
69
|
+
<span
|
|
70
|
+
class="truncate text-[12px] font-bold text-gray-title"
|
|
71
|
+
:title="col"
|
|
72
|
+
>{{ col }}</span>
|
|
73
|
+
<RiArrowUpLine
|
|
74
|
+
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
75
|
+
class="size-3.5 shrink-0 text-new-primary"
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
/>
|
|
78
|
+
<RiArrowDownLine
|
|
79
|
+
v-else-if="sort?.column === col && sort.direction === 'desc'"
|
|
80
|
+
class="size-3.5 shrink-0 text-new-primary"
|
|
81
|
+
aria-hidden="true"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
<TabularFilterPopover
|
|
85
|
+
v-model:sort="sort"
|
|
86
|
+
v-model:filters="filters"
|
|
87
|
+
:column="col"
|
|
88
|
+
:column-type="getColumnType(col)"
|
|
89
|
+
:column-profile="getColumnProfile(col)"
|
|
90
|
+
:null-percent="getNullPercent(col)"
|
|
91
|
+
:total-lines="totalLines"
|
|
92
|
+
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
<!-- Resize handle: wide hit zone, thin visible bar -->
|
|
96
|
+
<div
|
|
97
|
+
class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
|
|
98
|
+
:class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
|
|
99
|
+
@mousedown.prevent="startResize(col, $event)"
|
|
100
|
+
>
|
|
101
|
+
<div
|
|
102
|
+
class="absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 rounded-full bg-primary/40 opacity-0 group-hover/resize:opacity-100"
|
|
103
|
+
:class="{ '!opacity-100': resizing?.column === col }"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
</th>
|
|
107
|
+
</tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody>
|
|
110
|
+
<tr v-if="allRows.length === 0">
|
|
111
|
+
<td
|
|
112
|
+
:colspan="displayedColumns.length"
|
|
113
|
+
class="py-16 text-center"
|
|
114
|
+
>
|
|
115
|
+
<div class="flex flex-col items-center gap-2">
|
|
116
|
+
<RiSearchLine
|
|
117
|
+
class="size-8 text-gray-low"
|
|
118
|
+
aria-hidden="true"
|
|
119
|
+
/>
|
|
120
|
+
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
121
|
+
</div>
|
|
122
|
+
</td>
|
|
123
|
+
</tr>
|
|
124
|
+
<tr
|
|
125
|
+
v-for="(row, i) in allRows"
|
|
126
|
+
:key="row.__id ?? i"
|
|
127
|
+
class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
|
|
128
|
+
>
|
|
129
|
+
<td
|
|
130
|
+
v-for="col in displayedColumns"
|
|
131
|
+
:key="col"
|
|
132
|
+
data-cell
|
|
133
|
+
class="p-2 align-middle whitespace-nowrap border-r border-gray-default last:border-r-0 overflow-hidden cursor-pointer hover:bg-gray-200/50"
|
|
134
|
+
:class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' }"
|
|
135
|
+
:style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
136
|
+
@click="onCellClick(col, row[col], $event)"
|
|
137
|
+
>
|
|
138
|
+
<AppLink
|
|
139
|
+
v-if="isLinkColumn(col)"
|
|
140
|
+
:to="getRowHref(row)"
|
|
141
|
+
class="link"
|
|
142
|
+
>
|
|
143
|
+
<TabularCell v-bind="cellProps(col, row)" />
|
|
144
|
+
</AppLink>
|
|
145
|
+
<TabularCell
|
|
146
|
+
v-else
|
|
147
|
+
v-bind="cellProps(col, row)"
|
|
148
|
+
/>
|
|
149
|
+
</td>
|
|
150
|
+
</tr>
|
|
151
|
+
</tbody>
|
|
152
|
+
</table>
|
|
153
|
+
<InfiniteLoader
|
|
154
|
+
v-if="hasMore"
|
|
155
|
+
:root="scrollContainerRef"
|
|
156
|
+
@intersect="loadNextPage"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- Cell popover -->
|
|
161
|
+
<TabularCellPopover
|
|
162
|
+
v-model:cell="activeCell"
|
|
163
|
+
v-model:filters="filters"
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
<!-- Mobile: card layout -->
|
|
167
|
+
<div
|
|
168
|
+
v-if="displayedColumns.length > 0"
|
|
169
|
+
class="md:hidden space-y-2 px-1 transition-opacity"
|
|
170
|
+
:class="{ 'opacity-40': isRefreshing }"
|
|
171
|
+
:aria-busy="isRefreshing"
|
|
172
|
+
>
|
|
173
|
+
<div
|
|
174
|
+
v-if="allRows.length === 0"
|
|
175
|
+
class="py-16 text-center"
|
|
176
|
+
>
|
|
177
|
+
<div class="flex flex-col items-center gap-2">
|
|
178
|
+
<RiSearchLine
|
|
179
|
+
class="size-8 text-gray-low"
|
|
180
|
+
aria-hidden="true"
|
|
181
|
+
/>
|
|
182
|
+
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div
|
|
186
|
+
v-for="(row, i) in allRows"
|
|
187
|
+
:key="row.__id ?? i"
|
|
188
|
+
data-testid="mobile-row"
|
|
189
|
+
class="border border-gray-default rounded-lg p-3 space-y-2"
|
|
190
|
+
:class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
|
|
191
|
+
>
|
|
192
|
+
<div
|
|
193
|
+
v-for="col in mobileVisibleFields(i)"
|
|
194
|
+
:key="col"
|
|
195
|
+
class="flex flex-col gap-0.5 min-w-0"
|
|
196
|
+
>
|
|
197
|
+
<div class="flex items-center gap-1 min-w-0">
|
|
198
|
+
<component
|
|
199
|
+
:is="getColumnDisplay(col).icon"
|
|
200
|
+
class="size-3 text-gray-low shrink-0"
|
|
201
|
+
aria-hidden="true"
|
|
202
|
+
/>
|
|
203
|
+
<span
|
|
204
|
+
class="text-xs text-gray-plain truncate"
|
|
205
|
+
:title="col"
|
|
206
|
+
>{{ col }}</span>
|
|
207
|
+
</div>
|
|
208
|
+
<!-- Numbers and dates take their colour from here rather than from
|
|
209
|
+
the cell, so a link wrapping one (see `rowHref`) keeps its own. -->
|
|
210
|
+
<div
|
|
211
|
+
data-cell
|
|
212
|
+
class="min-w-0 pl-4 cursor-pointer text-gray-title"
|
|
213
|
+
@click="onCellClick(col, row[col], $event)"
|
|
214
|
+
>
|
|
215
|
+
<AppLink
|
|
216
|
+
v-if="isLinkColumn(col)"
|
|
217
|
+
:to="getRowHref(row)"
|
|
218
|
+
class="link"
|
|
219
|
+
>
|
|
220
|
+
<TabularCell
|
|
221
|
+
v-bind="cellProps(col, row)"
|
|
222
|
+
compact
|
|
223
|
+
/>
|
|
224
|
+
</AppLink>
|
|
225
|
+
<TabularCell
|
|
226
|
+
v-else
|
|
227
|
+
v-bind="cellProps(col, row)"
|
|
228
|
+
compact
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
<button
|
|
233
|
+
v-if="displayedColumns.length > 4"
|
|
234
|
+
class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
|
|
235
|
+
@click="toggleMobileExpand(i)"
|
|
236
|
+
>
|
|
237
|
+
<RiArrowDownSLine
|
|
238
|
+
class="size-3 transition-transform"
|
|
239
|
+
:class="{ 'rotate-180': mobileExpandedRows.has(i) }"
|
|
240
|
+
aria-hidden="true"
|
|
241
|
+
/>
|
|
242
|
+
{{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
<!-- Each layout needs its own loader: a `display: none` sentinel never
|
|
246
|
+
intersects. No `root` here, the cards scroll with the page rather than
|
|
247
|
+
inside a container. -->
|
|
248
|
+
<InfiniteLoader
|
|
249
|
+
v-if="hasMore"
|
|
250
|
+
@intersect="loadNextPage"
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</template>
|
|
255
|
+
|
|
256
|
+
<script setup lang="ts">
|
|
257
|
+
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
|
258
|
+
import { useElementSize, useScroll } from '@vueuse/core'
|
|
259
|
+
import { RiArrowDownLine, RiArrowDownSLine, RiArrowUpLine, RiSearchLine } from '@remixicon/vue'
|
|
260
|
+
import AppLink from '../AppLink.vue'
|
|
261
|
+
import BrandedButton from '../BrandedButton.vue'
|
|
262
|
+
import InfiniteLoader from '../InfiniteLoader.vue'
|
|
263
|
+
import TabularCell from './TabularCell.vue'
|
|
264
|
+
import TabularCellPopover, { type CellInfo } from './TabularCellPopover.vue'
|
|
265
|
+
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
266
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
267
|
+
import { useTabularContext } from './useTabularContext'
|
|
268
|
+
import type { TabularRow } from './types'
|
|
269
|
+
import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
|
|
270
|
+
|
|
271
|
+
const props = defineProps<{
|
|
272
|
+
// Fill the available height (fullscreen) instead of the default 70vh cap.
|
|
273
|
+
fill?: boolean
|
|
274
|
+
// Break out of the host's padding so the table spans the whole screen while
|
|
275
|
+
// the toolbar stays inside the container. Used on the standalone explore pages.
|
|
276
|
+
fullBleed?: boolean
|
|
277
|
+
// When set, renders <a> tags inside the specified column cells for native
|
|
278
|
+
// browser UX (hover URL, ctrl+click, middle-click).
|
|
279
|
+
rowHref?: { columns: string[], href: (row: TabularRow) => string }
|
|
280
|
+
// Columns listed here render their values raw (no number formatting).
|
|
281
|
+
// Useful for identifier columns.
|
|
282
|
+
noFormatColumns?: string[]
|
|
283
|
+
}>()
|
|
284
|
+
|
|
285
|
+
const { t } = useTranslation()
|
|
286
|
+
const {
|
|
287
|
+
tableData,
|
|
288
|
+
allRows,
|
|
289
|
+
hasMore,
|
|
290
|
+
loadNextPage,
|
|
291
|
+
isRefreshing,
|
|
292
|
+
totalLines,
|
|
293
|
+
sort,
|
|
294
|
+
filters,
|
|
295
|
+
displayedColumns,
|
|
296
|
+
showAllColumns,
|
|
297
|
+
columnTypesMap,
|
|
298
|
+
getColumnType,
|
|
299
|
+
getColumnProfile,
|
|
300
|
+
getColumnDisplay,
|
|
301
|
+
getNullPercent,
|
|
302
|
+
getBooleanCounts,
|
|
303
|
+
} = useTabularContext()
|
|
304
|
+
|
|
305
|
+
function getRowHref(row: TabularRow): string | undefined {
|
|
306
|
+
return props.rowHref?.href(row)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isLinkColumn(col: string): boolean {
|
|
310
|
+
return props.rowHref?.columns.includes(col) ?? false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function cellProps(col: string, row: TabularRow) {
|
|
314
|
+
return {
|
|
315
|
+
value: row[col],
|
|
316
|
+
columnType: getColumnType(col),
|
|
317
|
+
noNumberFormat: props.noFormatColumns?.includes(col),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Column widths (table-only, never needed outside the table).
|
|
322
|
+
//
|
|
323
|
+
// Strategy:
|
|
324
|
+
// - `naturalWidths` holds each column's content-driven width, measured ONCE from the
|
|
325
|
+
// DOM while the <th> are still auto-sized. Stable base, never re-measured.
|
|
326
|
+
// - `columnWidths` is the applied result: `naturalWidths` distributed to fill the
|
|
327
|
+
// container. Recomputed whenever the displayed column set changes.
|
|
328
|
+
// - Manual resize writes directly to `columnWidths` during the drag, then stores the
|
|
329
|
+
// final width back into `naturalWidths` so it survives later redistribution.
|
|
330
|
+
const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
|
|
331
|
+
|
|
332
|
+
// Drop a shadow under the sticky header once the body is scrolled under it.
|
|
333
|
+
const { y: scrollTop } = useScroll(scrollContainerRef)
|
|
334
|
+
const scrolled = computed(() => scrollTop.value > 0)
|
|
335
|
+
|
|
336
|
+
const columnWidths = ref<Record<string, number>>({})
|
|
337
|
+
const naturalWidths = ref<Record<string, number>>({})
|
|
338
|
+
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
339
|
+
|
|
340
|
+
function measureNaturalWidths() {
|
|
341
|
+
const container = scrollContainerRef.value
|
|
342
|
+
if (!container) return
|
|
343
|
+
const ths = container.querySelectorAll('th')
|
|
344
|
+
if (!ths || ths.length === 0) return
|
|
345
|
+
|
|
346
|
+
const widths: Record<string, number> = {}
|
|
347
|
+
ths.forEach((th, i) => {
|
|
348
|
+
const col = displayedColumns.value[i]
|
|
349
|
+
if (col) widths[col] = th.offsetWidth
|
|
350
|
+
})
|
|
351
|
+
naturalWidths.value = widths
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function redistributeColumnWidths() {
|
|
355
|
+
const container = scrollContainerRef.value
|
|
356
|
+
if (!container) return
|
|
357
|
+
const cols = displayedColumns.value
|
|
358
|
+
let naturalSum = 0
|
|
359
|
+
for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
|
|
360
|
+
if (naturalSum === 0) return
|
|
361
|
+
|
|
362
|
+
const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
|
|
363
|
+
const widths: Record<string, number> = {}
|
|
364
|
+
for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
|
|
365
|
+
columnWidths.value = widths
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function ensureMeasured() {
|
|
369
|
+
if (Object.keys(naturalWidths.value).length > 0) return
|
|
370
|
+
if (displayedColumns.value.length === 0) return
|
|
371
|
+
measureNaturalWidths()
|
|
372
|
+
redistributeColumnWidths()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const { width: containerWidth } = useElementSize(scrollContainerRef)
|
|
376
|
+
|
|
377
|
+
watch(containerWidth, (width) => {
|
|
378
|
+
if (width === 0) return
|
|
379
|
+
if (Object.keys(naturalWidths.value).length === 0) {
|
|
380
|
+
ensureMeasured()
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
redistributeColumnWidths()
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
// Fallback: re-run once columns appear if they weren't available when the container
|
|
388
|
+
// was first sized (profile resolving late).
|
|
389
|
+
watch(
|
|
390
|
+
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
391
|
+
async ([dataReady, colCount]) => {
|
|
392
|
+
if (!dataReady || colCount === 0) return
|
|
393
|
+
if (Object.keys(naturalWidths.value).length > 0) return
|
|
394
|
+
await nextTick()
|
|
395
|
+
ensureMeasured()
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
// Re-stretch the remaining columns whenever the displayed set changes.
|
|
400
|
+
watch(displayedColumns, async () => {
|
|
401
|
+
if (Object.keys(naturalWidths.value).length === 0) return
|
|
402
|
+
await nextTick()
|
|
403
|
+
redistributeColumnWidths()
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
function startResize(col: string, e: MouseEvent) {
|
|
407
|
+
if (!Object.keys(naturalWidths.value).length) {
|
|
408
|
+
measureNaturalWidths()
|
|
409
|
+
redistributeColumnWidths()
|
|
410
|
+
}
|
|
411
|
+
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
412
|
+
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
413
|
+
document.addEventListener('mousemove', onResize)
|
|
414
|
+
document.addEventListener('mouseup', stopResize)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function onResize(e: MouseEvent) {
|
|
418
|
+
if (!resizing.value) return
|
|
419
|
+
const container = scrollContainerRef.value
|
|
420
|
+
// Auto-scroll when the mouse approaches the right edge, adjusting startX to compensate.
|
|
421
|
+
if (container) {
|
|
422
|
+
const rect = container.getBoundingClientRect()
|
|
423
|
+
const distFromRight = rect.right - e.clientX
|
|
424
|
+
if (distFromRight < 50) {
|
|
425
|
+
const oldScroll = container.scrollLeft
|
|
426
|
+
container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
|
|
427
|
+
resizing.value.startX -= container.scrollLeft - oldScroll
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const delta = e.clientX - resizing.value.startX
|
|
431
|
+
const newWidth = Math.max(60, resizing.value.startWidth + delta)
|
|
432
|
+
columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function stopResize() {
|
|
436
|
+
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
|
|
437
|
+
if (resizing.value) {
|
|
438
|
+
const col = resizing.value.column
|
|
439
|
+
const width = columnWidths.value[col]
|
|
440
|
+
if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
|
|
441
|
+
}
|
|
442
|
+
resizing.value = null
|
|
443
|
+
document.removeEventListener('mousemove', onResize)
|
|
444
|
+
document.removeEventListener('mouseup', stopResize)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
onUnmounted(() => {
|
|
448
|
+
document.removeEventListener('mousemove', onResize)
|
|
449
|
+
document.removeEventListener('mouseup', stopResize)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
// Cell popover (table-only)
|
|
453
|
+
const activeCell = ref<CellInfo | null>(null)
|
|
454
|
+
|
|
455
|
+
function onCellClick(col: string, value: unknown, event: MouseEvent) {
|
|
456
|
+
// A link inside a cell (see `rowHref`) owns its clicks: navigate, don't open
|
|
457
|
+
// the popover on top of the page we're leaving.
|
|
458
|
+
if ((event.target as HTMLElement).closest('a')) return
|
|
459
|
+
const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
|
|
460
|
+
if (!el) return
|
|
461
|
+
if (activeCell.value && activeCell.value.element === el) {
|
|
462
|
+
activeCell.value = null
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Mobile row expansion (table-only)
|
|
469
|
+
const mobileExpandedRows = ref(new Set<number>())
|
|
470
|
+
|
|
471
|
+
function mobileVisibleFields(index: number): string[] {
|
|
472
|
+
if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
|
|
473
|
+
return displayedColumns.value
|
|
474
|
+
}
|
|
475
|
+
return displayedColumns.value.slice(0, 4)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function toggleMobileExpand(index: number) {
|
|
479
|
+
const next = new Set(mobileExpandedRows.value)
|
|
480
|
+
if (next.has(index)) next.delete(index)
|
|
481
|
+
else next.add(index)
|
|
482
|
+
mobileExpandedRows.value = next
|
|
483
|
+
}
|
|
484
|
+
</script>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex items-center gap-2">
|
|
3
|
+
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
|
4
|
+
<TabularMobileFilterButton class="md:hidden" />
|
|
5
|
+
<div class="hidden md:block">
|
|
6
|
+
<TabularActiveFilters with-clear />
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="flex shrink-0 items-center gap-4">
|
|
10
|
+
<TabularColumnsMenu />
|
|
11
|
+
<TabularRowsInfo />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
import TabularActiveFilters from './TabularActiveFilters.vue'
|
|
18
|
+
import TabularColumnsMenu from './TabularColumnsMenu.vue'
|
|
19
|
+
import TabularMobileFilterButton from './TabularMobileFilterButton.vue'
|
|
20
|
+
import TabularRowsInfo from './TabularRowsInfo.vue'
|
|
21
|
+
|
|
22
|
+
// Framing (borders, padding, spacing) belongs to the call site: this toolbar sits in a
|
|
23
|
+
// bordered panel in the resource viewer and in the page flow on the standalone explore
|
|
24
|
+
// pages. Single root element, so the caller's `class` merges onto it.
|
|
25
|
+
</script>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Response from /api/resources/{rid}/data/ */
|
|
2
|
-
export
|
|
2
|
+
export type TabularDataResponse = {
|
|
3
3
|
data: TabularRow[]
|
|
4
4
|
meta: {
|
|
5
5
|
page: number
|
|
@@ -17,67 +17,79 @@ export interface TabularDataResponse {
|
|
|
17
17
|
export type TabularRow = Record<string, unknown> & { __id: number }
|
|
18
18
|
|
|
19
19
|
/** Response from /api/resources/{rid}/profile/ */
|
|
20
|
-
export
|
|
20
|
+
export type TabularProfileResponse = {
|
|
21
21
|
profile: TabularProfile
|
|
22
|
+
deleted_at: string | null
|
|
23
|
+
dataset_id: string
|
|
24
|
+
indexes: null
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
// Only CSV resources go through csv-detective: for the other formats the API
|
|
28
|
+
// indexes (parquet…), the profile is reduced to the column names, their types
|
|
29
|
+
// and the row count. Everything csv-detective produces is therefore optional.
|
|
30
|
+
export type TabularProfile = {
|
|
25
31
|
header: string[]
|
|
26
32
|
columns: Record<string, TabularColumnInfo>
|
|
27
|
-
formats: Record<string, string[]>
|
|
28
|
-
profile: Record<string, TabularColumnProfile>
|
|
29
|
-
encoding: string
|
|
30
|
-
separator: string
|
|
31
|
-
categorical: string[]
|
|
32
33
|
total_lines: number
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
formats?: Record<string, string[]>
|
|
35
|
+
profile?: Record<string, TabularColumnProfile>
|
|
36
|
+
encoding?: string
|
|
37
|
+
separator?: string
|
|
38
|
+
categorical?: string[]
|
|
39
|
+
nb_duplicates?: number
|
|
40
|
+
columns_fields?: Record<string, TabularColumnInfo>
|
|
41
|
+
columns_labels?: Record<string, TabularColumnInfo>
|
|
42
|
+
header_row_idx?: number
|
|
43
|
+
heading_columns?: number
|
|
44
|
+
trailing_columns?: number
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
export
|
|
42
|
-
score
|
|
47
|
+
export type TabularColumnInfo = {
|
|
48
|
+
score?: number
|
|
43
49
|
format: string
|
|
44
50
|
python_type: string
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
export
|
|
53
|
+
export type TabularColumnProfile = {
|
|
48
54
|
tops: TabularTopValue[]
|
|
49
55
|
nb_distinct: number
|
|
50
56
|
nb_missing_values: number
|
|
51
|
-
min?: number
|
|
52
|
-
max?: number
|
|
57
|
+
min?: number | string
|
|
58
|
+
max?: number | string
|
|
53
59
|
std?: number
|
|
54
60
|
mean?: number
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
export
|
|
63
|
+
export type TabularTopValue = {
|
|
58
64
|
value: string
|
|
59
65
|
count: number
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean'
|
|
68
|
+
export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
|
|
63
69
|
|
|
64
|
-
export
|
|
70
|
+
export type DateFilterOperator = 'is' | 'before' | 'after' | 'between'
|
|
71
|
+
|
|
72
|
+
export type DateFilter = {
|
|
73
|
+
operator: DateFilterOperator
|
|
74
|
+
/** ISO calendar date (YYYY-MM-DD). */
|
|
75
|
+
start: string
|
|
76
|
+
/** ISO calendar date, the inclusive upper bound. Only set when `operator` is 'between'. */
|
|
77
|
+
end?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ColumnFilters = {
|
|
65
81
|
in?: string[]
|
|
66
82
|
exact?: string
|
|
67
83
|
min?: number
|
|
68
84
|
max?: number
|
|
69
85
|
contains?: string
|
|
70
86
|
null?: 'only' | 'exclude'
|
|
87
|
+
date?: DateFilter
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
export type SortDirection = 'asc' | 'desc'
|
|
74
91
|
|
|
75
|
-
export
|
|
92
|
+
export type SortConfig = {
|
|
76
93
|
column: string
|
|
77
94
|
direction: SortDirection
|
|
78
95
|
}
|
|
79
|
-
|
|
80
|
-
export interface BadgeStyle {
|
|
81
|
-
backgroundColor: string
|
|
82
|
-
color: string
|
|
83
|
-
}
|