@datagouv/components-next 1.0.2-dev.19 → 1.0.2-dev.191
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/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-fxOwwaND.js} +9 -9
- package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
- package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
- package/dist/ImagePreview.client-XSR9THWR.js +36 -0
- package/dist/JsonPreview.client-CU1FWFIy.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
- package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-D0-Dn7jq.js} +41 -42
- package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-B7eDgq8y.js} +835 -856
- package/dist/Pmtiles.client-CCpvggkZ.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BzhNjYoY.js +60 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
- package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
- package/dist/XmlPreview.client-CyjpNh5i.js +34 -0
- package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +194 -149
- package/dist/components.css +2 -2
- package/dist/{index-BFQX7Ccz.js → index-BBnxH-Nj.js} +21842 -15810
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-Be0Ohp2k.js} +185 -187
- package/dist/{main-vqhkVhVS.js → main-C2kCZkPD.js} +41515 -73245
- package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-__BZsi8i.js} +16 -16
- package/package.json +47 -38
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +3 -2
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +320 -0
- package/src/components/Chart/ChartViewerWrapper.vue +182 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +9 -0
- 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 +9 -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/Form/Listbox.vue +101 -0
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/ObjectCardHeader.vue +11 -4
- package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
- package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
- package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
- package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
- package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
- package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
- package/src/components/OpenApiViewer/openapi.ts +150 -0
- package/src/components/OrganizationNameWithCertificate.vue +3 -2
- package/src/components/Pagination.vue +23 -58
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
- package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
- package/src/components/ResourceAccordion/Preview.vue +11 -11
- package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
- package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
- package/src/components/ResourceListItem.vue +161 -0
- package/src/components/ReuseCard.vue +14 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +2 -2
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +220 -117
- package/src/components/Search/SearchInput.vue +5 -4
- package/src/components/Search/Sidemenu.vue +4 -3
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +50 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
- 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 +481 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +81 -0
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +15 -1
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +48 -40
- package/src/composables/useSearchFilter.ts +120 -0
- package/src/composables/useStableQueryParams.ts +38 -6
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +21 -3
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +20 -42
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +99 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/dates.ts +3 -2
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +63 -60
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +136 -1
- package/src/functions/schemas.ts +27 -0
- package/src/functions/tabular.ts +214 -0
- package/src/functions/tabularApi.ts +96 -11
- package/src/main.ts +117 -14
- package/src/types/dataservices.ts +17 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/pages.ts +0 -5
- package/src/types/posts.ts +2 -2
- package/src/types/reports.ts +5 -1
- package/src/types/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +63 -1
- package/src/types/site.ts +5 -3
- package/src/types/ui.ts +2 -0
- package/src/types/users.ts +2 -1
- package/src/types/visualizations.ts +93 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
- package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
- package/dist/Swagger.client-B4y0hXME.js +0 -4
- package/dist/XmlPreview.client-LETEfNBH.js +0 -64
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /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,481 @@
|
|
|
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 class="text-sm border-collapse">
|
|
39
|
+
<thead
|
|
40
|
+
class="sticky top-0 z-10 bg-gray-some transition-shadow"
|
|
41
|
+
: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]'"
|
|
42
|
+
>
|
|
43
|
+
<tr class="border-b border-gray-default">
|
|
44
|
+
<th
|
|
45
|
+
v-for="col in displayedColumns"
|
|
46
|
+
:key="col"
|
|
47
|
+
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"
|
|
48
|
+
:style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
49
|
+
>
|
|
50
|
+
<div class="flex items-center justify-between gap-1">
|
|
51
|
+
<div class="flex min-w-0 items-center gap-1">
|
|
52
|
+
<!-- Type as an icon only; the human label is on hover. The title
|
|
53
|
+
sits on a wrapper because an SVG ignores it as an attribute,
|
|
54
|
+
and the icon carries the label for screen readers. -->
|
|
55
|
+
<span
|
|
56
|
+
class="flex shrink-0"
|
|
57
|
+
:title="getColumnDisplay(col).label"
|
|
58
|
+
>
|
|
59
|
+
<component
|
|
60
|
+
:is="getColumnDisplay(col).icon"
|
|
61
|
+
class="size-4 text-gray-plain"
|
|
62
|
+
role="img"
|
|
63
|
+
:aria-label="getColumnDisplay(col).label"
|
|
64
|
+
/>
|
|
65
|
+
</span>
|
|
66
|
+
<span
|
|
67
|
+
class="truncate text-[12px] font-bold text-gray-title"
|
|
68
|
+
:title="col"
|
|
69
|
+
>{{ col }}</span>
|
|
70
|
+
<RiArrowUpLine
|
|
71
|
+
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
72
|
+
class="size-3.5 shrink-0 text-new-primary"
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
/>
|
|
75
|
+
<RiArrowDownLine
|
|
76
|
+
v-else-if="sort?.column === col && sort.direction === 'desc'"
|
|
77
|
+
class="size-3.5 shrink-0 text-new-primary"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
<TabularFilterPopover
|
|
82
|
+
v-model:sort="sort"
|
|
83
|
+
v-model:filters="filters"
|
|
84
|
+
:column="col"
|
|
85
|
+
:column-type="getColumnType(col)"
|
|
86
|
+
:column-profile="getColumnProfile(col)"
|
|
87
|
+
:null-percent="getNullPercent(col)"
|
|
88
|
+
:total-lines="totalLines"
|
|
89
|
+
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<!-- Resize handle: wide hit zone, thin visible bar -->
|
|
93
|
+
<div
|
|
94
|
+
class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
|
|
95
|
+
:class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
|
|
96
|
+
@mousedown.prevent="startResize(col, $event)"
|
|
97
|
+
>
|
|
98
|
+
<div
|
|
99
|
+
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"
|
|
100
|
+
:class="{ '!opacity-100': resizing?.column === col }"
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</th>
|
|
104
|
+
</tr>
|
|
105
|
+
</thead>
|
|
106
|
+
<tbody>
|
|
107
|
+
<tr v-if="allRows.length === 0">
|
|
108
|
+
<td
|
|
109
|
+
:colspan="displayedColumns.length"
|
|
110
|
+
class="py-16 text-center"
|
|
111
|
+
>
|
|
112
|
+
<div class="flex flex-col items-center gap-2">
|
|
113
|
+
<RiSearchLine
|
|
114
|
+
class="size-8 text-gray-low"
|
|
115
|
+
aria-hidden="true"
|
|
116
|
+
/>
|
|
117
|
+
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
118
|
+
</div>
|
|
119
|
+
</td>
|
|
120
|
+
</tr>
|
|
121
|
+
<tr
|
|
122
|
+
v-for="(row, i) in allRows"
|
|
123
|
+
:key="row.__id ?? i"
|
|
124
|
+
class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
|
|
125
|
+
>
|
|
126
|
+
<td
|
|
127
|
+
v-for="col in displayedColumns"
|
|
128
|
+
:key="col"
|
|
129
|
+
data-cell
|
|
130
|
+
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"
|
|
131
|
+
:class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' }"
|
|
132
|
+
:style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
133
|
+
@click="onCellClick(col, row[col], $event)"
|
|
134
|
+
>
|
|
135
|
+
<AppLink
|
|
136
|
+
v-if="isLinkColumn(col)"
|
|
137
|
+
:to="getRowHref(row)"
|
|
138
|
+
class="link"
|
|
139
|
+
>
|
|
140
|
+
<TabularCell v-bind="cellProps(col, row)" />
|
|
141
|
+
</AppLink>
|
|
142
|
+
<TabularCell
|
|
143
|
+
v-else
|
|
144
|
+
v-bind="cellProps(col, row)"
|
|
145
|
+
/>
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
</tbody>
|
|
149
|
+
</table>
|
|
150
|
+
<InfiniteLoader
|
|
151
|
+
v-if="hasMore"
|
|
152
|
+
:root="scrollContainerRef"
|
|
153
|
+
@intersect="loadNextPage"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- Cell popover -->
|
|
158
|
+
<TabularCellPopover
|
|
159
|
+
v-model:cell="activeCell"
|
|
160
|
+
v-model:filters="filters"
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
<!-- Mobile: card layout -->
|
|
164
|
+
<div
|
|
165
|
+
v-if="displayedColumns.length > 0"
|
|
166
|
+
class="md:hidden space-y-2 px-1 transition-opacity"
|
|
167
|
+
:class="{ 'opacity-40': isRefreshing }"
|
|
168
|
+
:aria-busy="isRefreshing"
|
|
169
|
+
>
|
|
170
|
+
<div
|
|
171
|
+
v-if="allRows.length === 0"
|
|
172
|
+
class="py-16 text-center"
|
|
173
|
+
>
|
|
174
|
+
<div class="flex flex-col items-center gap-2">
|
|
175
|
+
<RiSearchLine
|
|
176
|
+
class="size-8 text-gray-low"
|
|
177
|
+
aria-hidden="true"
|
|
178
|
+
/>
|
|
179
|
+
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div
|
|
183
|
+
v-for="(row, i) in allRows"
|
|
184
|
+
:key="row.__id ?? i"
|
|
185
|
+
data-testid="mobile-row"
|
|
186
|
+
class="border border-gray-default rounded-lg p-3 space-y-2"
|
|
187
|
+
:class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
|
|
188
|
+
>
|
|
189
|
+
<div
|
|
190
|
+
v-for="col in mobileVisibleFields(i)"
|
|
191
|
+
:key="col"
|
|
192
|
+
class="flex flex-col gap-0.5 min-w-0"
|
|
193
|
+
>
|
|
194
|
+
<div class="flex items-center gap-1 min-w-0">
|
|
195
|
+
<component
|
|
196
|
+
:is="getColumnDisplay(col).icon"
|
|
197
|
+
class="size-3 text-gray-low shrink-0"
|
|
198
|
+
aria-hidden="true"
|
|
199
|
+
/>
|
|
200
|
+
<span
|
|
201
|
+
class="text-xs text-gray-plain truncate"
|
|
202
|
+
:title="col"
|
|
203
|
+
>{{ col }}</span>
|
|
204
|
+
</div>
|
|
205
|
+
<!-- Numbers and dates take their colour from here rather than from
|
|
206
|
+
the cell, so a link wrapping one (see `rowHref`) keeps its own. -->
|
|
207
|
+
<div
|
|
208
|
+
data-cell
|
|
209
|
+
class="min-w-0 pl-4 cursor-pointer text-gray-title"
|
|
210
|
+
@click="onCellClick(col, row[col], $event)"
|
|
211
|
+
>
|
|
212
|
+
<AppLink
|
|
213
|
+
v-if="isLinkColumn(col)"
|
|
214
|
+
:to="getRowHref(row)"
|
|
215
|
+
class="link"
|
|
216
|
+
>
|
|
217
|
+
<TabularCell
|
|
218
|
+
v-bind="cellProps(col, row)"
|
|
219
|
+
compact
|
|
220
|
+
/>
|
|
221
|
+
</AppLink>
|
|
222
|
+
<TabularCell
|
|
223
|
+
v-else
|
|
224
|
+
v-bind="cellProps(col, row)"
|
|
225
|
+
compact
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
<button
|
|
230
|
+
v-if="displayedColumns.length > 4"
|
|
231
|
+
class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
|
|
232
|
+
@click="toggleMobileExpand(i)"
|
|
233
|
+
>
|
|
234
|
+
<RiArrowDownSLine
|
|
235
|
+
class="size-3 transition-transform"
|
|
236
|
+
:class="{ 'rotate-180': mobileExpandedRows.has(i) }"
|
|
237
|
+
aria-hidden="true"
|
|
238
|
+
/>
|
|
239
|
+
{{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
<!-- Each layout needs its own loader: a `display: none` sentinel never
|
|
243
|
+
intersects. No `root` here, the cards scroll with the page rather than
|
|
244
|
+
inside a container. -->
|
|
245
|
+
<InfiniteLoader
|
|
246
|
+
v-if="hasMore"
|
|
247
|
+
@intersect="loadNextPage"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</template>
|
|
252
|
+
|
|
253
|
+
<script setup lang="ts">
|
|
254
|
+
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
|
255
|
+
import { useElementSize, useScroll } from '@vueuse/core'
|
|
256
|
+
import { RiArrowDownLine, RiArrowDownSLine, RiArrowUpLine, RiSearchLine } from '@remixicon/vue'
|
|
257
|
+
import AppLink from '../AppLink.vue'
|
|
258
|
+
import BrandedButton from '../BrandedButton.vue'
|
|
259
|
+
import InfiniteLoader from '../InfiniteLoader.vue'
|
|
260
|
+
import TabularCell from './TabularCell.vue'
|
|
261
|
+
import TabularCellPopover, { type CellInfo } from './TabularCellPopover.vue'
|
|
262
|
+
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
263
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
264
|
+
import { useTabularContext } from './useTabularContext'
|
|
265
|
+
import type { TabularRow } from './types'
|
|
266
|
+
import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
|
|
267
|
+
|
|
268
|
+
const props = defineProps<{
|
|
269
|
+
// Fill the available height (fullscreen) instead of the default 70vh cap.
|
|
270
|
+
fill?: boolean
|
|
271
|
+
// Break out of the host's padding so the table spans the whole screen while
|
|
272
|
+
// the toolbar stays inside the container. Used on the standalone explore pages.
|
|
273
|
+
fullBleed?: boolean
|
|
274
|
+
// When set, renders <a> tags inside the specified column cells for native
|
|
275
|
+
// browser UX (hover URL, ctrl+click, middle-click).
|
|
276
|
+
rowHref?: { columns: string[], href: (row: TabularRow) => string }
|
|
277
|
+
// Columns listed here render their values raw (no number formatting).
|
|
278
|
+
// Useful for identifier columns.
|
|
279
|
+
noFormatColumns?: string[]
|
|
280
|
+
}>()
|
|
281
|
+
|
|
282
|
+
const { t } = useTranslation()
|
|
283
|
+
const {
|
|
284
|
+
tableData,
|
|
285
|
+
allRows,
|
|
286
|
+
hasMore,
|
|
287
|
+
loadNextPage,
|
|
288
|
+
isRefreshing,
|
|
289
|
+
totalLines,
|
|
290
|
+
sort,
|
|
291
|
+
filters,
|
|
292
|
+
displayedColumns,
|
|
293
|
+
showAllColumns,
|
|
294
|
+
columnTypesMap,
|
|
295
|
+
getColumnType,
|
|
296
|
+
getColumnProfile,
|
|
297
|
+
getColumnDisplay,
|
|
298
|
+
getNullPercent,
|
|
299
|
+
getBooleanCounts,
|
|
300
|
+
} = useTabularContext()
|
|
301
|
+
|
|
302
|
+
function getRowHref(row: TabularRow): string | undefined {
|
|
303
|
+
return props.rowHref?.href(row)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isLinkColumn(col: string): boolean {
|
|
307
|
+
return props.rowHref?.columns.includes(col) ?? false
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function cellProps(col: string, row: TabularRow) {
|
|
311
|
+
return {
|
|
312
|
+
value: row[col],
|
|
313
|
+
columnType: getColumnType(col),
|
|
314
|
+
noNumberFormat: props.noFormatColumns?.includes(col),
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Column widths (table-only, never needed outside the table).
|
|
319
|
+
//
|
|
320
|
+
// Strategy:
|
|
321
|
+
// - `naturalWidths` holds each column's content-driven width, measured ONCE from the
|
|
322
|
+
// DOM while the <th> are still auto-sized. Stable base, never re-measured.
|
|
323
|
+
// - `columnWidths` is the applied result: `naturalWidths` distributed to fill the
|
|
324
|
+
// container. Recomputed whenever the displayed column set changes.
|
|
325
|
+
// - Manual resize writes directly to `columnWidths` during the drag, then stores the
|
|
326
|
+
// final width back into `naturalWidths` so it survives later redistribution.
|
|
327
|
+
const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
|
|
328
|
+
|
|
329
|
+
// Drop a shadow under the sticky header once the body is scrolled under it.
|
|
330
|
+
const { y: scrollTop } = useScroll(scrollContainerRef)
|
|
331
|
+
const scrolled = computed(() => scrollTop.value > 0)
|
|
332
|
+
|
|
333
|
+
const columnWidths = ref<Record<string, number>>({})
|
|
334
|
+
const naturalWidths = ref<Record<string, number>>({})
|
|
335
|
+
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
336
|
+
|
|
337
|
+
function measureNaturalWidths() {
|
|
338
|
+
const container = scrollContainerRef.value
|
|
339
|
+
if (!container) return
|
|
340
|
+
const ths = container.querySelectorAll('th')
|
|
341
|
+
if (!ths || ths.length === 0) return
|
|
342
|
+
|
|
343
|
+
const widths: Record<string, number> = {}
|
|
344
|
+
ths.forEach((th, i) => {
|
|
345
|
+
const col = displayedColumns.value[i]
|
|
346
|
+
if (col) widths[col] = th.offsetWidth
|
|
347
|
+
})
|
|
348
|
+
naturalWidths.value = widths
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function redistributeColumnWidths() {
|
|
352
|
+
const container = scrollContainerRef.value
|
|
353
|
+
if (!container) return
|
|
354
|
+
const cols = displayedColumns.value
|
|
355
|
+
let naturalSum = 0
|
|
356
|
+
for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
|
|
357
|
+
if (naturalSum === 0) return
|
|
358
|
+
|
|
359
|
+
const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
|
|
360
|
+
const widths: Record<string, number> = {}
|
|
361
|
+
for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
|
|
362
|
+
columnWidths.value = widths
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function ensureMeasured() {
|
|
366
|
+
if (Object.keys(naturalWidths.value).length > 0) return
|
|
367
|
+
if (displayedColumns.value.length === 0) return
|
|
368
|
+
measureNaturalWidths()
|
|
369
|
+
redistributeColumnWidths()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const { width: containerWidth } = useElementSize(scrollContainerRef)
|
|
373
|
+
|
|
374
|
+
watch(containerWidth, (width) => {
|
|
375
|
+
if (width === 0) return
|
|
376
|
+
if (Object.keys(naturalWidths.value).length === 0) {
|
|
377
|
+
ensureMeasured()
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
redistributeColumnWidths()
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// Fallback: re-run once columns appear if they weren't available when the container
|
|
385
|
+
// was first sized (profile resolving late).
|
|
386
|
+
watch(
|
|
387
|
+
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
388
|
+
async ([dataReady, colCount]) => {
|
|
389
|
+
if (!dataReady || colCount === 0) return
|
|
390
|
+
if (Object.keys(naturalWidths.value).length > 0) return
|
|
391
|
+
await nextTick()
|
|
392
|
+
ensureMeasured()
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
// Re-stretch the remaining columns whenever the displayed set changes.
|
|
397
|
+
watch(displayedColumns, async () => {
|
|
398
|
+
if (Object.keys(naturalWidths.value).length === 0) return
|
|
399
|
+
await nextTick()
|
|
400
|
+
redistributeColumnWidths()
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
function startResize(col: string, e: MouseEvent) {
|
|
404
|
+
if (!Object.keys(naturalWidths.value).length) {
|
|
405
|
+
measureNaturalWidths()
|
|
406
|
+
redistributeColumnWidths()
|
|
407
|
+
}
|
|
408
|
+
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
409
|
+
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
410
|
+
document.addEventListener('mousemove', onResize)
|
|
411
|
+
document.addEventListener('mouseup', stopResize)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function onResize(e: MouseEvent) {
|
|
415
|
+
if (!resizing.value) return
|
|
416
|
+
const container = scrollContainerRef.value
|
|
417
|
+
// Auto-scroll when the mouse approaches the right edge, adjusting startX to compensate.
|
|
418
|
+
if (container) {
|
|
419
|
+
const rect = container.getBoundingClientRect()
|
|
420
|
+
const distFromRight = rect.right - e.clientX
|
|
421
|
+
if (distFromRight < 50) {
|
|
422
|
+
const oldScroll = container.scrollLeft
|
|
423
|
+
container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
|
|
424
|
+
resizing.value.startX -= container.scrollLeft - oldScroll
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const delta = e.clientX - resizing.value.startX
|
|
428
|
+
const newWidth = Math.max(60, resizing.value.startWidth + delta)
|
|
429
|
+
columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function stopResize() {
|
|
433
|
+
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
|
|
434
|
+
if (resizing.value) {
|
|
435
|
+
const col = resizing.value.column
|
|
436
|
+
const width = columnWidths.value[col]
|
|
437
|
+
if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
|
|
438
|
+
}
|
|
439
|
+
resizing.value = null
|
|
440
|
+
document.removeEventListener('mousemove', onResize)
|
|
441
|
+
document.removeEventListener('mouseup', stopResize)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
onUnmounted(() => {
|
|
445
|
+
document.removeEventListener('mousemove', onResize)
|
|
446
|
+
document.removeEventListener('mouseup', stopResize)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// Cell popover (table-only)
|
|
450
|
+
const activeCell = ref<CellInfo | null>(null)
|
|
451
|
+
|
|
452
|
+
function onCellClick(col: string, value: unknown, event: MouseEvent) {
|
|
453
|
+
// A link inside a cell (see `rowHref`) owns its clicks: navigate, don't open
|
|
454
|
+
// the popover on top of the page we're leaving.
|
|
455
|
+
if ((event.target as HTMLElement).closest('a')) return
|
|
456
|
+
const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
|
|
457
|
+
if (!el) return
|
|
458
|
+
if (activeCell.value && activeCell.value.element === el) {
|
|
459
|
+
activeCell.value = null
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Mobile row expansion (table-only)
|
|
466
|
+
const mobileExpandedRows = ref(new Set<number>())
|
|
467
|
+
|
|
468
|
+
function mobileVisibleFields(index: number): string[] {
|
|
469
|
+
if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
|
|
470
|
+
return displayedColumns.value
|
|
471
|
+
}
|
|
472
|
+
return displayedColumns.value.slice(0, 4)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function toggleMobileExpand(index: number) {
|
|
476
|
+
const next = new Set(mobileExpandedRows.value)
|
|
477
|
+
if (next.has(index)) next.delete(index)
|
|
478
|
+
else next.add(index)
|
|
479
|
+
mobileExpandedRows.value = next
|
|
480
|
+
}
|
|
481
|
+
</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>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Response from /api/resources/{rid}/data/ */
|
|
2
|
+
export type TabularDataResponse = {
|
|
3
|
+
data: TabularRow[]
|
|
4
|
+
meta: {
|
|
5
|
+
page: number
|
|
6
|
+
page_size: number
|
|
7
|
+
total: number
|
|
8
|
+
}
|
|
9
|
+
links: {
|
|
10
|
+
profile: string
|
|
11
|
+
swagger: string
|
|
12
|
+
next: string | null
|
|
13
|
+
prev: string | null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TabularRow = Record<string, unknown> & { __id: number }
|
|
18
|
+
|
|
19
|
+
/** Response from /api/resources/{rid}/profile/ */
|
|
20
|
+
export type TabularProfileResponse = {
|
|
21
|
+
profile: TabularProfile
|
|
22
|
+
deleted_at: string | null
|
|
23
|
+
dataset_id: string
|
|
24
|
+
indexes: null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TabularProfile = {
|
|
28
|
+
header: string[]
|
|
29
|
+
columns: Record<string, TabularColumnInfo>
|
|
30
|
+
formats: Record<string, string[]>
|
|
31
|
+
profile: Record<string, TabularColumnProfile>
|
|
32
|
+
encoding: string
|
|
33
|
+
separator: string
|
|
34
|
+
categorical: string[]
|
|
35
|
+
total_lines: number
|
|
36
|
+
nb_duplicates: number
|
|
37
|
+
columns_fields: Record<string, TabularColumnInfo>
|
|
38
|
+
columns_labels: Record<string, TabularColumnInfo>
|
|
39
|
+
header_row_idx: number
|
|
40
|
+
heading_columns: number
|
|
41
|
+
trailing_columns: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type TabularColumnInfo = {
|
|
45
|
+
score: number
|
|
46
|
+
format: string
|
|
47
|
+
python_type: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TabularColumnProfile = {
|
|
51
|
+
tops: TabularTopValue[]
|
|
52
|
+
nb_distinct: number
|
|
53
|
+
nb_missing_values: number
|
|
54
|
+
min?: number | string
|
|
55
|
+
max?: number | string
|
|
56
|
+
std?: number
|
|
57
|
+
mean?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type TabularTopValue = {
|
|
61
|
+
value: string
|
|
62
|
+
count: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
|
|
66
|
+
|
|
67
|
+
export type ColumnFilters = {
|
|
68
|
+
in?: string[]
|
|
69
|
+
exact?: string
|
|
70
|
+
min?: number
|
|
71
|
+
max?: number
|
|
72
|
+
contains?: string
|
|
73
|
+
null?: 'only' | 'exclude'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type SortDirection = 'asc' | 'desc'
|
|
77
|
+
|
|
78
|
+
export type SortConfig = {
|
|
79
|
+
column: string
|
|
80
|
+
direction: SortDirection
|
|
81
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { computed, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
|
|
2
|
+
import type { TranslationFunction } from '../../composables/useTranslation'
|
|
3
|
+
import {
|
|
4
|
+
buildFormatConfig,
|
|
5
|
+
buildTypeConfig,
|
|
6
|
+
GENERIC_FORMATS,
|
|
7
|
+
humanizeFormat,
|
|
8
|
+
isFalsy,
|
|
9
|
+
isTruthy,
|
|
10
|
+
resolveColumnType,
|
|
11
|
+
} from '../../functions/tabular'
|
|
12
|
+
import type { ColumnDisplay } from './useTabularContext'
|
|
13
|
+
import type {
|
|
14
|
+
ColumnType,
|
|
15
|
+
TabularColumnProfile,
|
|
16
|
+
TabularProfileResponse,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
// Pure derivations of the csv-detective profile: a column's display type, its
|
|
20
|
+
// human-facing label/icon, null ratio and boolean counts.
|
|
21
|
+
// Kept out of TabularExplorer so the fetch/pagination/filter state doesn't drown
|
|
22
|
+
// under a hundred lines of metadata math — and so this logic stays unit-testable
|
|
23
|
+
// in isolation from the component.
|
|
24
|
+
export function useColumnMetadata(
|
|
25
|
+
profileData: Readonly<Ref<TabularProfileResponse | null>>,
|
|
26
|
+
allColumns: MaybeRefOrGetter<string[]>,
|
|
27
|
+
t: TranslationFunction,
|
|
28
|
+
) {
|
|
29
|
+
const typeConfig = buildTypeConfig(t)
|
|
30
|
+
const formatConfig = buildFormatConfig(t)
|
|
31
|
+
|
|
32
|
+
function getColumnProfile(col: string): TabularColumnProfile | null {
|
|
33
|
+
return profileData.value?.profile?.profile?.[col] ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getColumnTypeFromName(col: string): ColumnType {
|
|
37
|
+
const profile = profileData.value?.profile
|
|
38
|
+
if (!profile) return 'text'
|
|
39
|
+
const colInfo = profile.columns[col]
|
|
40
|
+
if (!colInfo) return 'text'
|
|
41
|
+
return resolveColumnType(colInfo, profile.categorical.includes(col))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const columnTypesMap = computed<Record<string, ColumnType>>(() => {
|
|
45
|
+
const map: Record<string, ColumnType> = {}
|
|
46
|
+
for (const col of toValue(allColumns)) {
|
|
47
|
+
map[col] = getColumnTypeFromName(col)
|
|
48
|
+
}
|
|
49
|
+
return map
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function getColumnType(col: string): ColumnType {
|
|
53
|
+
return columnTypesMap.value[col] ?? 'text'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getNullPercent(col: string): string {
|
|
57
|
+
const colProfile = getColumnProfile(col)
|
|
58
|
+
const total = profileData.value?.profile?.total_lines
|
|
59
|
+
if (!colProfile || !total) return '0%'
|
|
60
|
+
return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getTypeConfig(col: string) {
|
|
64
|
+
return typeConfig[getColumnType(col)]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Header display: prefer the rich semantic format detected by csv-detective
|
|
68
|
+
// (code INSEE, UAI, département, SIREN, email…) over the generic display type.
|
|
69
|
+
// Tolerant: generic formats fall back to the type config, and unknown semantic
|
|
70
|
+
// formats are humanized rather than dropped.
|
|
71
|
+
function getColumnDisplay(col: string): ColumnDisplay {
|
|
72
|
+
const format = profileData.value?.profile?.columns?.[col]?.format
|
|
73
|
+
const fallback = getTypeConfig(col)
|
|
74
|
+
if (!format || GENERIC_FORMATS.has(format)) return fallback
|
|
75
|
+
return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
|
|
79
|
+
const profile = getColumnProfile(col)
|
|
80
|
+
if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
|
|
81
|
+
let trueCount = 0
|
|
82
|
+
let falseCount = 0
|
|
83
|
+
for (const top of profile.tops) {
|
|
84
|
+
if (isTruthy(top.value ?? '')) trueCount += top.count
|
|
85
|
+
else if (isFalsy(top.value ?? '')) falseCount += top.count
|
|
86
|
+
}
|
|
87
|
+
return { trueCount, falseCount }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
columnTypesMap,
|
|
92
|
+
getColumnType,
|
|
93
|
+
getColumnProfile,
|
|
94
|
+
getColumnDisplay,
|
|
95
|
+
getNullPercent,
|
|
96
|
+
getBooleanCounts,
|
|
97
|
+
}
|
|
98
|
+
}
|