@datagouv/components-next 1.0.2-dev.17 → 1.0.2-dev.171
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-B-OG5WtK.js} +4 -4
- 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-D0c-ifUi.js +36 -0
- package/dist/JsonPreview.client-Dwh9sW9M.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
- package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-C2AZ7sOB.js} +38 -39
- package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-8NzNfW8b.js} +823 -855
- package/dist/Pmtiles.client-BFH4rTB7.js +26324 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BOgE441d.js +58 -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-DMOUO508.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 +183 -146
- package/dist/components.css +2 -2
- package/dist/{index-BFQX7Ccz.js → index-C9obT9DJ.js} +21834 -15810
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-B4AZU0vr.js} +185 -187
- package/dist/{main-vqhkVhVS.js → main-DNoYWfit.js} +40723 -73240
- 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-Cp7Cu5ME.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/Chart/ChartViewer.vue +320 -0
- package/src/components/Chart/ChartViewerWrapper.vue +182 -0
- 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/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 +8 -5
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
- package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
- package/src/components/ResourceAccordion/Preview.vue +11 -11
- package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +19 -113
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
- package/src/components/ResourceExplorer/ResourceExplorer.vue +22 -19
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +64 -148
- package/src/components/ResourceExplorer/ResourceSelector.vue +113 -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/Search/GlobalSearch.vue +219 -117
- package/src/components/Search/SearchInput.vue +5 -4
- package/src/components/TabularExplorer/TabularCell.vue +52 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +1226 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +370 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +107 -0
- package/src/components/TabularExplorer/types.ts +86 -0
- package/src/composables/useHasTabularData.ts +15 -1
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useResourceCapabilities.ts +28 -15
- package/src/composables/useSearchFilter.ts +120 -0
- package/src/composables/useStableQueryParams.ts +38 -6
- package/src/composables/useTabularProfile.ts +70 -0
- 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/metrics.ts +6 -4
- package/src/functions/resources.ts +117 -1
- package/src/functions/tabular.ts +214 -0
- package/src/functions/tabularApi.ts +96 -11
- package/src/main.ts +109 -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,1226 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<!-- Outside the branches below: this slot holds the query controls (search
|
|
4
|
+
input…), so it has to stay reachable when the query itself failed. -->
|
|
5
|
+
<slot name="toolbar-top" />
|
|
6
|
+
|
|
7
|
+
<div
|
|
8
|
+
v-if="previewError"
|
|
9
|
+
class="max-w-3xl mx-auto"
|
|
10
|
+
>
|
|
11
|
+
<SimpleBanner
|
|
12
|
+
type="warning"
|
|
13
|
+
class="mb-4"
|
|
14
|
+
>
|
|
15
|
+
{{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
|
|
16
|
+
<pre class="text-xs mt-2 whitespace-pre-wrap break-words">{{ previewError }}</pre>
|
|
17
|
+
</SimpleBanner>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div
|
|
21
|
+
v-else-if="previewLoading"
|
|
22
|
+
class="animate-pulse-placeholder"
|
|
23
|
+
:aria-label="t('Chargement de l\'aperçu…')"
|
|
24
|
+
role="status"
|
|
25
|
+
>
|
|
26
|
+
<div>
|
|
27
|
+
<div class="flex items-center justify-end gap-4 py-3">
|
|
28
|
+
<div class="h-4 w-20 bg-gray-200" />
|
|
29
|
+
<div class="h-4 w-20 bg-gray-200" />
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="overflow-hidden">
|
|
33
|
+
<table class="w-full text-sm border-collapse">
|
|
34
|
+
<thead class="shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
35
|
+
<tr>
|
|
36
|
+
<th
|
|
37
|
+
v-for="i in 6"
|
|
38
|
+
:key="i"
|
|
39
|
+
class="px-3 py-2 text-left"
|
|
40
|
+
>
|
|
41
|
+
<div class="h-4 w-24 bg-gray-200" />
|
|
42
|
+
</th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
<tr
|
|
47
|
+
v-for="row in 8"
|
|
48
|
+
:key="row"
|
|
49
|
+
class="border-b border-gray-100"
|
|
50
|
+
>
|
|
51
|
+
<td
|
|
52
|
+
v-for="col in 6"
|
|
53
|
+
:key="col"
|
|
54
|
+
class="px-3 py-2.5"
|
|
55
|
+
>
|
|
56
|
+
<div class="h-3 bg-gray-200" />
|
|
57
|
+
</td>
|
|
58
|
+
</tr>
|
|
59
|
+
</tbody>
|
|
60
|
+
</table>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<template v-else-if="tableData && profileData">
|
|
65
|
+
<!-- Toolbar & active filters inherit the host's 1rem horizontal padding
|
|
66
|
+
(px-4 on ResourceExplorerViewer's TabPanel, .container on
|
|
67
|
+
ExploreResourceView), so they line up with the resource title /
|
|
68
|
+
download button above. Only the table itself goes edge-to-edge
|
|
69
|
+
(via -mx-4 below). -->
|
|
70
|
+
<div>
|
|
71
|
+
<div class="flex items-center py-3 gap-2">
|
|
72
|
+
<!-- Mobile: filter & sort button -->
|
|
73
|
+
<BrandedButton
|
|
74
|
+
class="md:hidden"
|
|
75
|
+
color="tertiary"
|
|
76
|
+
size="2xs"
|
|
77
|
+
:icon="RiFilterLine"
|
|
78
|
+
keep-margins-even-without-borders
|
|
79
|
+
@click="mobileFilterOpen = true"
|
|
80
|
+
>
|
|
81
|
+
{{ t('Filtres & tri') }}
|
|
82
|
+
</BrandedButton>
|
|
83
|
+
|
|
84
|
+
<div class="flex-1" />
|
|
85
|
+
|
|
86
|
+
<!-- Right: Stats -->
|
|
87
|
+
<div class="flex items-center gap-4">
|
|
88
|
+
<!-- Columns (clickable, opens column popover) -->
|
|
89
|
+
<Popover
|
|
90
|
+
ref="columnAnchor"
|
|
91
|
+
v-slot="{ open }"
|
|
92
|
+
class="relative"
|
|
93
|
+
>
|
|
94
|
+
<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">
|
|
95
|
+
<RiLayoutColumnLine
|
|
96
|
+
class="size-3"
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
/>
|
|
99
|
+
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
100
|
+
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
101
|
+
<RiArrowDownSLine
|
|
102
|
+
class="size-3 opacity-50"
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
/>
|
|
105
|
+
</PopoverButton>
|
|
106
|
+
|
|
107
|
+
<ClientOnly>
|
|
108
|
+
<Teleport to="#tooltips">
|
|
109
|
+
<PopoverPanel
|
|
110
|
+
v-show="open"
|
|
111
|
+
ref="columnPanel"
|
|
112
|
+
static
|
|
113
|
+
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
114
|
+
:style="columnFloatingStyles"
|
|
115
|
+
>
|
|
116
|
+
<div class="px-3 py-2 border-b border-gray-default space-y-1.5">
|
|
117
|
+
<span class="block text-xs font-medium text-gray-title">
|
|
118
|
+
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
119
|
+
</span>
|
|
120
|
+
<div class="flex items-center gap-1">
|
|
121
|
+
<BrandedButton
|
|
122
|
+
v-if="hiddenCount"
|
|
123
|
+
color="tertiary"
|
|
124
|
+
size="2xs"
|
|
125
|
+
@click="showAllColumns"
|
|
126
|
+
>
|
|
127
|
+
{{ t('Tout afficher') }}
|
|
128
|
+
</BrandedButton>
|
|
129
|
+
<BrandedButton
|
|
130
|
+
v-if="visibleColumns.size > 0"
|
|
131
|
+
color="tertiary"
|
|
132
|
+
size="2xs"
|
|
133
|
+
@click="hideAllColumns"
|
|
134
|
+
>
|
|
135
|
+
{{ t('Tout masquer') }}
|
|
136
|
+
</BrandedButton>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="max-h-64 overflow-auto p-1">
|
|
140
|
+
<div
|
|
141
|
+
v-for="col in allColumns"
|
|
142
|
+
:key="col"
|
|
143
|
+
class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
|
|
144
|
+
>
|
|
145
|
+
<label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
|
|
146
|
+
<input
|
|
147
|
+
type="checkbox"
|
|
148
|
+
:checked="visibleColumns.has(col)"
|
|
149
|
+
class="size-3.5 accent-new-primary"
|
|
150
|
+
@change="toggleColumn(col)"
|
|
151
|
+
>
|
|
152
|
+
<span class="truncate">{{ col }}</span>
|
|
153
|
+
</label>
|
|
154
|
+
<button
|
|
155
|
+
class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
|
|
156
|
+
@click="selectOnlyColumn(col)"
|
|
157
|
+
>
|
|
158
|
+
{{ t('Uniquement') }}
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</PopoverPanel>
|
|
163
|
+
</Teleport>
|
|
164
|
+
</ClientOnly>
|
|
165
|
+
</Popover>
|
|
166
|
+
|
|
167
|
+
<!-- Rows -->
|
|
168
|
+
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
169
|
+
<RiLoader5Line
|
|
170
|
+
v-if="isRefreshing"
|
|
171
|
+
class="size-3 text-new-primary animate-spin"
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
/>
|
|
174
|
+
<RiLayoutRowLine
|
|
175
|
+
v-else
|
|
176
|
+
class="size-3 text-mention-grey"
|
|
177
|
+
aria-hidden="true"
|
|
178
|
+
/>
|
|
179
|
+
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
180
|
+
<!-- The count stays in place while refreshing: the spinner already
|
|
181
|
+
says it is stale, and swapping it for a word of another width
|
|
182
|
+
would shift the whole toolbar on every search. -->
|
|
183
|
+
<span
|
|
184
|
+
data-testid="row-count"
|
|
185
|
+
class="font-mono tabular-nums"
|
|
186
|
+
>{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<!-- Active filters & sort -->
|
|
192
|
+
<div
|
|
193
|
+
v-if="activeFilters.length > 0 || sort"
|
|
194
|
+
class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
|
|
195
|
+
>
|
|
196
|
+
<!-- Header -->
|
|
197
|
+
<div class="flex items-center justify-between">
|
|
198
|
+
<div class="flex items-center gap-2">
|
|
199
|
+
<RiFilterLine
|
|
200
|
+
class="size-3.5"
|
|
201
|
+
aria-hidden="true"
|
|
202
|
+
/>
|
|
203
|
+
<span class="text-xs text-gray-plain">{{ activeFilters.length > 0 ? t('Filtres actifs') : t('Tri actif') }}</span>
|
|
204
|
+
<span
|
|
205
|
+
v-if="activeFilters.length > 0"
|
|
206
|
+
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"
|
|
207
|
+
>
|
|
208
|
+
{{ activeFilters.length }}
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
<BrandedButton
|
|
212
|
+
color="tertiary"
|
|
213
|
+
size="2xs"
|
|
214
|
+
:icon="RiCloseLine"
|
|
215
|
+
@click="clearAllFilters(); sort = null"
|
|
216
|
+
>
|
|
217
|
+
{{ t('Tout effacer') }}
|
|
218
|
+
</BrandedButton>
|
|
219
|
+
</div>
|
|
220
|
+
<!-- Chips -->
|
|
221
|
+
<div class="flex flex-wrap gap-1.5">
|
|
222
|
+
<!-- Sort chip -->
|
|
223
|
+
<span
|
|
224
|
+
v-if="sort"
|
|
225
|
+
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
226
|
+
>
|
|
227
|
+
<component
|
|
228
|
+
:is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
|
|
229
|
+
class="size-3 text-new-primary"
|
|
230
|
+
aria-hidden="true"
|
|
231
|
+
/>
|
|
232
|
+
<span class="text-gray-plain">{{ t('Tri') }}</span>
|
|
233
|
+
<span class="text-gray-title">{{ sort.column }}</span>
|
|
234
|
+
<span class="text-gray-plain">{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
|
|
235
|
+
<button
|
|
236
|
+
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
237
|
+
@click="sort = null"
|
|
238
|
+
>
|
|
239
|
+
<RiCloseLine
|
|
240
|
+
class="size-3"
|
|
241
|
+
aria-hidden="true"
|
|
242
|
+
/>
|
|
243
|
+
<span class="sr-only">{{ t('Supprimer le tri') }}</span>
|
|
244
|
+
</button>
|
|
245
|
+
</span>
|
|
246
|
+
<!-- Filter chips -->
|
|
247
|
+
<span
|
|
248
|
+
v-for="af in activeFilters"
|
|
249
|
+
:key="af.column"
|
|
250
|
+
:data-testid="`active-filter-${af.column}`"
|
|
251
|
+
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
252
|
+
>
|
|
253
|
+
<component
|
|
254
|
+
:is="getColumnDisplay(af.column).icon"
|
|
255
|
+
class="size-3 text-gray-title"
|
|
256
|
+
aria-hidden="true"
|
|
257
|
+
/>
|
|
258
|
+
<span class="text-gray-title">{{ af.column }}</span>
|
|
259
|
+
<span class="text-gray-plain">{{ af.label }}</span>
|
|
260
|
+
<button
|
|
261
|
+
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
262
|
+
@click="removeFilter(af.column)"
|
|
263
|
+
>
|
|
264
|
+
<RiCloseLine
|
|
265
|
+
class="size-3"
|
|
266
|
+
aria-hidden="true"
|
|
267
|
+
/>
|
|
268
|
+
<span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
|
|
269
|
+
</button>
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
<!-- /toolbar & active filters -->
|
|
275
|
+
|
|
276
|
+
<!-- No column selected -->
|
|
277
|
+
<div
|
|
278
|
+
v-if="displayedColumns.length === 0"
|
|
279
|
+
class="flex flex-col items-center py-12"
|
|
280
|
+
>
|
|
281
|
+
<img
|
|
282
|
+
:src="noColumnsSrc"
|
|
283
|
+
class="h-32"
|
|
284
|
+
alt=""
|
|
285
|
+
>
|
|
286
|
+
<p class="font-bold text-gray-title my-3">
|
|
287
|
+
{{ t('Aucune colonne sélectionnée') }}
|
|
288
|
+
</p>
|
|
289
|
+
<BrandedButton
|
|
290
|
+
color="secondary"
|
|
291
|
+
size="xs"
|
|
292
|
+
@click="showAllColumns"
|
|
293
|
+
>
|
|
294
|
+
{{ t('Afficher toutes les colonnes') }}
|
|
295
|
+
</BrandedButton>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<!-- Desktop: scrollable table -->
|
|
299
|
+
<!-- The table goes edge-to-edge while the toolbar / active-filter rows
|
|
300
|
+
above keep the host's horizontal padding:
|
|
301
|
+
- default: `-mx-4` cancels the host's 1rem padding (px-4 on
|
|
302
|
+
ResourceExplorerViewer's TabPanel), so the table reaches the
|
|
303
|
+
host box edge.
|
|
304
|
+
- `fullBleed`: the table breaks out of the (max-width) container to
|
|
305
|
+
span almost the whole viewport (explore page), keeping a 2rem
|
|
306
|
+
gutter on each side so it doesn't touch the screen edges. Centered
|
|
307
|
+
on the viewport via the classic `left-1/2` + `-translate-x-1/2`
|
|
308
|
+
trick (works because the container is itself centered). -->
|
|
309
|
+
<div
|
|
310
|
+
v-if="displayedColumns.length > 0"
|
|
311
|
+
ref="scrollContainer"
|
|
312
|
+
class="hidden md:block overflow-auto max-h-[70vh] transition-opacity"
|
|
313
|
+
:class="[
|
|
314
|
+
fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4',
|
|
315
|
+
{ 'opacity-40': isRefreshing },
|
|
316
|
+
]"
|
|
317
|
+
:aria-busy="isRefreshing"
|
|
318
|
+
>
|
|
319
|
+
<table class="text-sm border-collapse">
|
|
320
|
+
<thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
321
|
+
<tr class="border-b border-gray-default">
|
|
322
|
+
<th
|
|
323
|
+
v-for="col in displayedColumns"
|
|
324
|
+
:key="col"
|
|
325
|
+
class="group/th relative h-14 px-2 last:pr-5 text-left align-middle whitespace-nowrap border-r border-gray-default last:border-r-0"
|
|
326
|
+
:style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
327
|
+
>
|
|
328
|
+
<div class="flex items-center gap-0.5 min-w-0">
|
|
329
|
+
<span
|
|
330
|
+
class="font-extrabold text-sm truncate"
|
|
331
|
+
:title="col"
|
|
332
|
+
>{{ col }}</span>
|
|
333
|
+
<RiArrowUpLine
|
|
334
|
+
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
335
|
+
class="size-3.5 shrink-0 text-new-primary"
|
|
336
|
+
aria-hidden="true"
|
|
337
|
+
/>
|
|
338
|
+
<RiArrowDownLine
|
|
339
|
+
v-else-if="sort?.column === col && sort.direction === 'desc'"
|
|
340
|
+
class="size-3.5 shrink-0 text-new-primary"
|
|
341
|
+
aria-hidden="true"
|
|
342
|
+
/>
|
|
343
|
+
<TabularFilterPopover
|
|
344
|
+
v-model:sort="sort"
|
|
345
|
+
v-model:filters="filters"
|
|
346
|
+
class="mt-1"
|
|
347
|
+
:column="col"
|
|
348
|
+
:column-type="getColumnType(col)"
|
|
349
|
+
:column-profile="getColumnProfile(col)"
|
|
350
|
+
:null-percent="getNullPercent(col)"
|
|
351
|
+
:total-lines="totalLines"
|
|
352
|
+
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
353
|
+
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
<!-- Column type (semantic format when available) -->
|
|
357
|
+
<span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1 max-w-full">
|
|
358
|
+
<component
|
|
359
|
+
:is="getColumnDisplay(col).icon"
|
|
360
|
+
class="size-3 shrink-0"
|
|
361
|
+
aria-hidden="true"
|
|
362
|
+
/>
|
|
363
|
+
<span class="mt-px truncate">{{ getColumnDisplay(col).label }}</span>
|
|
364
|
+
</span>
|
|
365
|
+
<!-- Resize handle: wide hit zone, thin visible bar -->
|
|
366
|
+
<div
|
|
367
|
+
class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
|
|
368
|
+
:class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
|
|
369
|
+
@mousedown.prevent="startResize(col, $event)"
|
|
370
|
+
>
|
|
371
|
+
<div
|
|
372
|
+
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"
|
|
373
|
+
:class="{ '!opacity-100': resizing?.column === col }"
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
</th>
|
|
377
|
+
</tr>
|
|
378
|
+
</thead>
|
|
379
|
+
<tbody>
|
|
380
|
+
<tr v-if="allRows.length === 0">
|
|
381
|
+
<td
|
|
382
|
+
:colspan="displayedColumns.length"
|
|
383
|
+
class="py-16 text-center"
|
|
384
|
+
>
|
|
385
|
+
<div class="flex flex-col items-center gap-2">
|
|
386
|
+
<RiSearchLine
|
|
387
|
+
class="size-8 text-gray-low"
|
|
388
|
+
aria-hidden="true"
|
|
389
|
+
/>
|
|
390
|
+
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
391
|
+
</div>
|
|
392
|
+
</td>
|
|
393
|
+
</tr>
|
|
394
|
+
<tr
|
|
395
|
+
v-for="(row, i) in allRows"
|
|
396
|
+
:key="row.__id ?? i"
|
|
397
|
+
class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
|
|
398
|
+
>
|
|
399
|
+
<td
|
|
400
|
+
v-for="col in displayedColumns"
|
|
401
|
+
:key="col"
|
|
402
|
+
data-cell
|
|
403
|
+
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"
|
|
404
|
+
:class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' }"
|
|
405
|
+
:style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
406
|
+
@click="onCellClick(col, row[col], $event)"
|
|
407
|
+
>
|
|
408
|
+
<AppLink
|
|
409
|
+
v-if="isLinkColumn(col)"
|
|
410
|
+
:to="getRowHref(row)"
|
|
411
|
+
class="link"
|
|
412
|
+
>
|
|
413
|
+
<TabularCell v-bind="cellProps(col, row)" />
|
|
414
|
+
</AppLink>
|
|
415
|
+
<TabularCell
|
|
416
|
+
v-else
|
|
417
|
+
v-bind="cellProps(col, row)"
|
|
418
|
+
/>
|
|
419
|
+
</td>
|
|
420
|
+
</tr>
|
|
421
|
+
</tbody>
|
|
422
|
+
</table>
|
|
423
|
+
<InfiniteLoader
|
|
424
|
+
v-if="hasMore"
|
|
425
|
+
:root="scrollContainerRef"
|
|
426
|
+
@intersect="loadNextPage"
|
|
427
|
+
/>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
<!-- Cell popover -->
|
|
431
|
+
<TabularCellPopover
|
|
432
|
+
v-model:cell="activeCell"
|
|
433
|
+
v-model:filters="filters"
|
|
434
|
+
/>
|
|
435
|
+
|
|
436
|
+
<!-- Mobile: card layout -->
|
|
437
|
+
<div
|
|
438
|
+
v-if="displayedColumns.length > 0"
|
|
439
|
+
class="md:hidden space-y-2 px-1 transition-opacity"
|
|
440
|
+
:class="{ 'opacity-40': isRefreshing }"
|
|
441
|
+
:aria-busy="isRefreshing"
|
|
442
|
+
>
|
|
443
|
+
<div
|
|
444
|
+
v-if="allRows.length === 0"
|
|
445
|
+
class="py-16 text-center"
|
|
446
|
+
>
|
|
447
|
+
<div class="flex flex-col items-center gap-2">
|
|
448
|
+
<RiSearchLine
|
|
449
|
+
class="size-8 text-gray-low"
|
|
450
|
+
aria-hidden="true"
|
|
451
|
+
/>
|
|
452
|
+
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
<div
|
|
456
|
+
v-for="(row, i) in allRows"
|
|
457
|
+
:key="row.__id ?? i"
|
|
458
|
+
class="border border-gray-default rounded-lg p-3 space-y-2"
|
|
459
|
+
:class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
|
|
460
|
+
>
|
|
461
|
+
<div
|
|
462
|
+
v-for="col in mobileVisibleFields(i)"
|
|
463
|
+
:key="col"
|
|
464
|
+
class="flex flex-col gap-0.5 min-w-0"
|
|
465
|
+
>
|
|
466
|
+
<div class="flex items-center gap-1 min-w-0">
|
|
467
|
+
<component
|
|
468
|
+
:is="getColumnDisplay(col).icon"
|
|
469
|
+
class="size-3 text-gray-low shrink-0"
|
|
470
|
+
aria-hidden="true"
|
|
471
|
+
/>
|
|
472
|
+
<span
|
|
473
|
+
class="text-xs text-gray-plain truncate"
|
|
474
|
+
:title="col"
|
|
475
|
+
>{{ col }}</span>
|
|
476
|
+
</div>
|
|
477
|
+
<!-- Numbers and dates take their colour from here rather than from
|
|
478
|
+
the cell, so a link wrapping one (see `rowHref`) keeps its own. -->
|
|
479
|
+
<div
|
|
480
|
+
data-cell
|
|
481
|
+
class="min-w-0 pl-4 cursor-pointer text-gray-title"
|
|
482
|
+
@click="onCellClick(col, row[col], $event)"
|
|
483
|
+
>
|
|
484
|
+
<AppLink
|
|
485
|
+
v-if="isLinkColumn(col)"
|
|
486
|
+
:to="getRowHref(row)"
|
|
487
|
+
class="link"
|
|
488
|
+
>
|
|
489
|
+
<TabularCell
|
|
490
|
+
v-bind="cellProps(col, row)"
|
|
491
|
+
compact
|
|
492
|
+
/>
|
|
493
|
+
</AppLink>
|
|
494
|
+
<TabularCell
|
|
495
|
+
v-else
|
|
496
|
+
v-bind="cellProps(col, row)"
|
|
497
|
+
compact
|
|
498
|
+
/>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
<button
|
|
502
|
+
v-if="displayedColumns.length > 4"
|
|
503
|
+
class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
|
|
504
|
+
@click="toggleMobileExpand(i)"
|
|
505
|
+
>
|
|
506
|
+
<RiArrowDownSLine
|
|
507
|
+
class="size-3 transition-transform"
|
|
508
|
+
:class="{ 'rotate-180': mobileExpandedRows.has(i) }"
|
|
509
|
+
aria-hidden="true"
|
|
510
|
+
/>
|
|
511
|
+
{{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
|
|
512
|
+
</button>
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
<!-- Mobile: filter bottom sheet -->
|
|
517
|
+
<TransitionRoot
|
|
518
|
+
:show="mobileFilterOpen"
|
|
519
|
+
as="template"
|
|
520
|
+
>
|
|
521
|
+
<Dialog
|
|
522
|
+
class="relative z-[900]"
|
|
523
|
+
@close="mobileFilterOpen = false"
|
|
524
|
+
>
|
|
525
|
+
<!-- Backdrop -->
|
|
526
|
+
<TransitionChild
|
|
527
|
+
as="template"
|
|
528
|
+
enter="ease-out duration-300"
|
|
529
|
+
enter-from="opacity-0"
|
|
530
|
+
enter-to="opacity-100"
|
|
531
|
+
leave="ease-in duration-200"
|
|
532
|
+
leave-from="opacity-100"
|
|
533
|
+
leave-to="opacity-0"
|
|
534
|
+
>
|
|
535
|
+
<div class="fixed inset-0 bg-black/30" />
|
|
536
|
+
</TransitionChild>
|
|
537
|
+
<!-- Panel -->
|
|
538
|
+
<TransitionChild
|
|
539
|
+
as="template"
|
|
540
|
+
enter="ease-out duration-300"
|
|
541
|
+
enter-from="translate-y-full"
|
|
542
|
+
enter-to="translate-y-0"
|
|
543
|
+
leave="ease-in duration-200"
|
|
544
|
+
leave-from="translate-y-0"
|
|
545
|
+
leave-to="translate-y-full"
|
|
546
|
+
>
|
|
547
|
+
<div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
|
|
548
|
+
<!-- Header -->
|
|
549
|
+
<DialogPanel class="flex flex-col max-h-[80vh]">
|
|
550
|
+
<div class="px-4 pt-4 pb-2 border-b border-gray-default">
|
|
551
|
+
<DialogTitle class="text-sm font-bold">
|
|
552
|
+
{{ t('Filtres & tri par colonne') }}
|
|
553
|
+
</DialogTitle>
|
|
554
|
+
<p class="text-xs text-gray-low">
|
|
555
|
+
{{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
|
|
556
|
+
</p>
|
|
557
|
+
</div>
|
|
558
|
+
<!-- Column list -->
|
|
559
|
+
<div class="flex-1 overflow-y-auto">
|
|
560
|
+
<div
|
|
561
|
+
v-for="col in allColumns"
|
|
562
|
+
:key="col"
|
|
563
|
+
class="border-b border-gray-default last:border-b-0"
|
|
564
|
+
>
|
|
565
|
+
<button
|
|
566
|
+
class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
|
|
567
|
+
:class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
|
|
568
|
+
@click="toggleMobileFilterColumn(col)"
|
|
569
|
+
>
|
|
570
|
+
<component
|
|
571
|
+
:is="getColumnDisplay(col).icon"
|
|
572
|
+
class="size-3.5 text-gray-low shrink-0"
|
|
573
|
+
aria-hidden="true"
|
|
574
|
+
/>
|
|
575
|
+
<span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
|
|
576
|
+
<RiArrowUpLine
|
|
577
|
+
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
578
|
+
class="size-3 text-new-primary shrink-0"
|
|
579
|
+
aria-hidden="true"
|
|
580
|
+
/>
|
|
581
|
+
<RiArrowDownLine
|
|
582
|
+
v-if="sort?.column === col && sort.direction === 'desc'"
|
|
583
|
+
class="size-3 text-new-primary shrink-0"
|
|
584
|
+
aria-hidden="true"
|
|
585
|
+
/>
|
|
586
|
+
<span
|
|
587
|
+
v-if="hasFilterForColumn(col)"
|
|
588
|
+
class="size-2 rounded-full bg-new-primary shrink-0"
|
|
589
|
+
/>
|
|
590
|
+
<RiArrowDownSLine
|
|
591
|
+
class="size-3.5 text-gray-low shrink-0 transition-transform"
|
|
592
|
+
:class="{ 'rotate-180': mobileFilterExpandedCol === col }"
|
|
593
|
+
aria-hidden="true"
|
|
594
|
+
/>
|
|
595
|
+
</button>
|
|
596
|
+
<div
|
|
597
|
+
v-if="mobileFilterExpandedCol === col"
|
|
598
|
+
class="pb-1"
|
|
599
|
+
>
|
|
600
|
+
<TabularFilterContent
|
|
601
|
+
v-model:sort="sort"
|
|
602
|
+
v-model:filters="filters"
|
|
603
|
+
:column="col"
|
|
604
|
+
:column-type="getColumnType(col)"
|
|
605
|
+
:column-profile="getColumnProfile(col)"
|
|
606
|
+
:null-percent="getNullPercent(col)"
|
|
607
|
+
:total-lines="totalLines"
|
|
608
|
+
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
609
|
+
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
610
|
+
/>
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
<!-- Reset all -->
|
|
615
|
+
<div
|
|
616
|
+
v-if="activeFilters.length > 0 || sort"
|
|
617
|
+
class="border-t border-gray-default px-4 py-3"
|
|
618
|
+
>
|
|
619
|
+
<BrandedButton
|
|
620
|
+
color="secondary"
|
|
621
|
+
size="xs"
|
|
622
|
+
class="w-full"
|
|
623
|
+
@click="clearAllFilters(); sort = null; mobileFilterOpen = false"
|
|
624
|
+
>
|
|
625
|
+
{{ t('Tout réinitialiser') }}
|
|
626
|
+
</BrandedButton>
|
|
627
|
+
</div>
|
|
628
|
+
</DialogPanel>
|
|
629
|
+
</div>
|
|
630
|
+
</TransitionChild>
|
|
631
|
+
</Dialog>
|
|
632
|
+
</TransitionRoot>
|
|
633
|
+
</template>
|
|
634
|
+
</div>
|
|
635
|
+
</template>
|
|
636
|
+
|
|
637
|
+
<script setup lang="ts">
|
|
638
|
+
import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
|
|
639
|
+
import { ofetch } from 'ofetch'
|
|
640
|
+
import { useElementSize } from '@vueuse/core'
|
|
641
|
+
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
642
|
+
import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
643
|
+
import {
|
|
644
|
+
RiLayoutColumnLine,
|
|
645
|
+
RiLayoutRowLine,
|
|
646
|
+
RiArrowDownSLine,
|
|
647
|
+
RiArrowUpLine,
|
|
648
|
+
RiArrowDownLine,
|
|
649
|
+
RiFilterLine,
|
|
650
|
+
RiCloseLine,
|
|
651
|
+
RiSearchLine,
|
|
652
|
+
RiLoader5Line,
|
|
653
|
+
} from '@remixicon/vue'
|
|
654
|
+
import { useFetch } from '../../functions/api'
|
|
655
|
+
import { useComponentsConfig } from '../../config'
|
|
656
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
657
|
+
import { injectTabularProfile } from '../../composables/useTabularProfile'
|
|
658
|
+
import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy, resolveColumnType, buildGlobalSearchConditions } from '../../functions/tabular'
|
|
659
|
+
import AppLink from '../AppLink.vue'
|
|
660
|
+
import ClientOnly from '../ClientOnly.vue'
|
|
661
|
+
import SimpleBanner from '../SimpleBanner.vue'
|
|
662
|
+
import BrandedButton from '../BrandedButton.vue'
|
|
663
|
+
import InfiniteLoader from '../InfiniteLoader.vue'
|
|
664
|
+
import TabularCell from './TabularCell.vue'
|
|
665
|
+
import TabularCellPopover from './TabularCellPopover.vue'
|
|
666
|
+
import type { CellInfo } from './TabularCellPopover.vue'
|
|
667
|
+
import TabularFilterContent from './TabularFilterContent.vue'
|
|
668
|
+
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
669
|
+
import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
|
|
670
|
+
import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
|
|
671
|
+
|
|
672
|
+
const props = defineProps<{
|
|
673
|
+
resourceId: string
|
|
674
|
+
// When true, the table breaks out to the full viewport width instead of just
|
|
675
|
+
// the host's padding. Used on the standalone explore page where the table
|
|
676
|
+
// should span the whole screen while the toolbar stays inside the container.
|
|
677
|
+
fullBleed?: boolean
|
|
678
|
+
// When set, searches across multiple columns using the Tabular API's or(...)
|
|
679
|
+
// parameter. Text and categorical columns get a __contains filter; number
|
|
680
|
+
// columns get a __exact filter (since __contains is not supported for numbers
|
|
681
|
+
// by the API). Year, date and boolean columns are excluded.
|
|
682
|
+
// Note: combined via AND with any existing column-specific `contains` filters,
|
|
683
|
+
// so it acts as an additional narrowing constraint, not a replacement.
|
|
684
|
+
globalSearch?: string
|
|
685
|
+
// Filters seeded on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
|
|
686
|
+
// The explorer owns them afterwards: later changes to this prop are ignored,
|
|
687
|
+
// so pass a fresh instance (or remount) to reset them.
|
|
688
|
+
initialFilters?: Record<string, ColumnFilters>
|
|
689
|
+
// When set, renders <a> tags inside the specified column cells for native
|
|
690
|
+
// browser UX (hover URL, ctrl+click, middle-click).
|
|
691
|
+
rowHref?: { columns: string[], href: (row: TabularRow) => string }
|
|
692
|
+
// Columns listed here render their values raw (no number formatting).
|
|
693
|
+
// Useful for identifier columns.
|
|
694
|
+
noFormatColumns?: string[]
|
|
695
|
+
}>()
|
|
696
|
+
|
|
697
|
+
function getRowHref(row: TabularRow): string | undefined {
|
|
698
|
+
return props.rowHref?.href(row)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function isLinkColumn(col: string): boolean {
|
|
702
|
+
return props.rowHref?.columns.includes(col) ?? false
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function cellProps(col: string, row: TabularRow) {
|
|
706
|
+
const columnType = getColumnType(col)
|
|
707
|
+
return {
|
|
708
|
+
value: row[col],
|
|
709
|
+
columnType,
|
|
710
|
+
categoryBadgeStyle: columnType === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined,
|
|
711
|
+
noNumberFormat: props.noFormatColumns?.includes(col),
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const { t } = useTranslation()
|
|
716
|
+
const config = useComponentsConfig()
|
|
717
|
+
|
|
718
|
+
// Column selector popover positioning
|
|
719
|
+
const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
|
|
720
|
+
const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
|
|
721
|
+
const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
|
|
722
|
+
const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
|
|
723
|
+
|
|
724
|
+
const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
|
|
725
|
+
placement: 'bottom-start',
|
|
726
|
+
middleware: [flip(), shift()],
|
|
727
|
+
whileElementsMounted: autoUpdate,
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
const dataUrl = computed(() =>
|
|
731
|
+
`${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
// Profile is shared with sibling components (e.g. DataStructure) via
|
|
735
|
+
// `provideTabularProfile` in the parent. Falls back to a local fetch
|
|
736
|
+
// when no parent provides it (standalone usage).
|
|
737
|
+
// Fetched before the rows: `dataQuery` reads the column types to build the
|
|
738
|
+
// global search, so the profile has to be resolved by the time it is evaluated.
|
|
739
|
+
const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
|
|
740
|
+
|
|
741
|
+
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
|
|
742
|
+
|
|
743
|
+
// Column type helpers
|
|
744
|
+
function getColumnTypeFromName(col: string): ColumnType {
|
|
745
|
+
const profile = profileData.value?.profile
|
|
746
|
+
if (!profile) return 'text'
|
|
747
|
+
const colInfo = profile.columns[col]
|
|
748
|
+
if (!colInfo) return 'text'
|
|
749
|
+
return resolveColumnType(colInfo, profile.categorical.includes(col))
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const columnTypesMap = computed(() => {
|
|
753
|
+
const map: Record<string, ColumnType> = {}
|
|
754
|
+
for (const col of allColumns.value) {
|
|
755
|
+
map[col] = getColumnTypeFromName(col)
|
|
756
|
+
}
|
|
757
|
+
return map
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
function getColumnType(col: string): ColumnType {
|
|
761
|
+
return columnTypesMap.value[col] ?? 'text'
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Sort & filter state
|
|
765
|
+
const sort = ref<SortConfig | null>(null)
|
|
766
|
+
const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
|
|
767
|
+
|
|
768
|
+
const PAGE_SIZE = 50
|
|
769
|
+
|
|
770
|
+
const dataQuery = computed(() => {
|
|
771
|
+
const q: Record<string, string | number> = { page: 1, page_size: PAGE_SIZE }
|
|
772
|
+
if (sort.value) {
|
|
773
|
+
q[`${sort.value.column}__sort`] = sort.value.direction
|
|
774
|
+
}
|
|
775
|
+
for (const [col, filter] of Object.entries(filters.value)) {
|
|
776
|
+
if (filter.in?.length) {
|
|
777
|
+
q[`${col}__in`] = filter.in.join(',')
|
|
778
|
+
}
|
|
779
|
+
if (filter.exact != null) {
|
|
780
|
+
q[`${col}__exact`] = filter.exact
|
|
781
|
+
}
|
|
782
|
+
if (Number.isFinite(filter.min)) {
|
|
783
|
+
q[`${col}__greater`] = filter.min!
|
|
784
|
+
}
|
|
785
|
+
if (Number.isFinite(filter.max)) {
|
|
786
|
+
q[`${col}__less`] = filter.max!
|
|
787
|
+
}
|
|
788
|
+
if (filter.contains) {
|
|
789
|
+
q[`${col}__contains`] = filter.contains
|
|
790
|
+
}
|
|
791
|
+
if (filter.null === 'only') {
|
|
792
|
+
q[`${col}__isnull`] = ''
|
|
793
|
+
}
|
|
794
|
+
else if (filter.null === 'exclude') {
|
|
795
|
+
q[`${col}__isnotnull`] = ''
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (props.globalSearch && profileData.value?.profile) {
|
|
799
|
+
const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
|
|
800
|
+
q.or = '(' + conditions.join(',') + ')'
|
|
801
|
+
}
|
|
802
|
+
return q
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
const { data: tableData, error, status: dataStatus } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
|
|
806
|
+
|
|
807
|
+
// The component renders nothing useful until the profile is available
|
|
808
|
+
// (allColumns is derived from it). Surface a clear loading / error state
|
|
809
|
+
// so we don't end up with an empty table + a spinner running forever.
|
|
810
|
+
const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
|
|
811
|
+
const previewError = computed(() => error.value || profileError.value)
|
|
812
|
+
const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
|
|
813
|
+
// A search / filter / sort change refetches while the previous rows stay on
|
|
814
|
+
// screen: without a signal, the table looks unchanged for several seconds.
|
|
815
|
+
const isRefreshing = computed(() => dataStatus.value === 'pending' && !previewLoading.value)
|
|
816
|
+
|
|
817
|
+
// Infinite scroll state
|
|
818
|
+
const allRows = ref<TabularRow[]>([])
|
|
819
|
+
const currentPage = ref(1)
|
|
820
|
+
const hasMore = ref(false)
|
|
821
|
+
const loadingMore = ref(false)
|
|
822
|
+
const generation = ref(0)
|
|
823
|
+
const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
|
|
824
|
+
|
|
825
|
+
watch(() => tableData.value, (data) => {
|
|
826
|
+
generation.value++
|
|
827
|
+
if (data) {
|
|
828
|
+
allRows.value = [...data.data]
|
|
829
|
+
currentPage.value = 1
|
|
830
|
+
hasMore.value = data.data.length < data.meta.total
|
|
831
|
+
}
|
|
832
|
+
}, { immediate: true })
|
|
833
|
+
|
|
834
|
+
async function loadNextPage() {
|
|
835
|
+
if (loadingMore.value || !hasMore.value || !tableData.value) return
|
|
836
|
+
loadingMore.value = true
|
|
837
|
+
const gen = generation.value
|
|
838
|
+
try {
|
|
839
|
+
const nextPage = currentPage.value + 1
|
|
840
|
+
const query = { ...dataQuery.value, page: nextPage }
|
|
841
|
+
const data = await ofetch<TabularDataResponse>(dataUrl.value, { params: query })
|
|
842
|
+
// Discard stale response if filters/sort changed during the fetch
|
|
843
|
+
if (gen !== generation.value) return
|
|
844
|
+
allRows.value = [...allRows.value, ...data.data]
|
|
845
|
+
currentPage.value = nextPage
|
|
846
|
+
hasMore.value = allRows.value.length < tableData.value.meta.total
|
|
847
|
+
}
|
|
848
|
+
finally {
|
|
849
|
+
loadingMore.value = false
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
|
|
854
|
+
|
|
855
|
+
const visibleColumns = ref(new Set(allColumns.value))
|
|
856
|
+
|
|
857
|
+
watch(allColumns, (cols) => {
|
|
858
|
+
if (cols.length > 0 && visibleColumns.value.size === 0) {
|
|
859
|
+
visibleColumns.value = new Set(cols)
|
|
860
|
+
}
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
const displayedColumns = computed(() =>
|
|
864
|
+
allColumns.value.filter(col => visibleColumns.value.has(col)),
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
const hiddenCount = computed(() =>
|
|
868
|
+
allColumns.value.length - visibleColumns.value.size,
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
function toggleColumn(col: string) {
|
|
872
|
+
const next = new Set(visibleColumns.value)
|
|
873
|
+
if (next.has(col)) {
|
|
874
|
+
next.delete(col)
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
next.add(col)
|
|
878
|
+
}
|
|
879
|
+
visibleColumns.value = next
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function showAllColumns() {
|
|
883
|
+
visibleColumns.value = new Set(allColumns.value)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function hideAllColumns() {
|
|
887
|
+
visibleColumns.value = new Set()
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function selectOnlyColumn(col: string) {
|
|
891
|
+
visibleColumns.value = new Set([col])
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Column widths
|
|
895
|
+
//
|
|
896
|
+
// Strategy:
|
|
897
|
+
// - `naturalWidths` holds each column's content-driven width, measured ONCE
|
|
898
|
+
// from the DOM while the <th> are still auto-sized. It's the stable base and
|
|
899
|
+
// never re-measured (re-measuring would require unlocking the widths first).
|
|
900
|
+
// - `columnWidths` is the applied result: `naturalWidths` distributed to fill
|
|
901
|
+
// the container. It's recomputed from the base whenever the set of displayed
|
|
902
|
+
// columns changes (toggling, isolating or hiding columns via the menu), so
|
|
903
|
+
// reducing to a few columns re-stretches them instead of leaving blank space.
|
|
904
|
+
// Pagination / filtering / sorting don't change the column set, so they keep
|
|
905
|
+
// the locked widths and never trigger a layout shift.
|
|
906
|
+
// - Manual resize writes directly to `columnWidths` during the drag, then stores
|
|
907
|
+
// the final width back into `naturalWidths` so it survives later redistribution.
|
|
908
|
+
const columnWidths = ref<Record<string, number>>({})
|
|
909
|
+
const naturalWidths = ref<Record<string, number>>({})
|
|
910
|
+
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
911
|
+
|
|
912
|
+
// Measure content-driven widths once, while the <th> are still auto-sized.
|
|
913
|
+
function measureNaturalWidths() {
|
|
914
|
+
const container = scrollContainerRef.value
|
|
915
|
+
if (!container) return
|
|
916
|
+
const ths = container.querySelectorAll('th')
|
|
917
|
+
if (!ths || ths.length === 0) return
|
|
918
|
+
|
|
919
|
+
const widths: Record<string, number> = {}
|
|
920
|
+
ths.forEach((th, i) => {
|
|
921
|
+
const col = displayedColumns.value[i]
|
|
922
|
+
if (col) widths[col] = th.offsetWidth
|
|
923
|
+
})
|
|
924
|
+
naturalWidths.value = widths
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Distribute the natural widths of the currently displayed columns to fill the
|
|
928
|
+
// container (inflating proportionally when they don't naturally cover it).
|
|
929
|
+
function redistributeColumnWidths() {
|
|
930
|
+
const container = scrollContainerRef.value
|
|
931
|
+
if (!container) return
|
|
932
|
+
const cols = displayedColumns.value
|
|
933
|
+
let naturalSum = 0
|
|
934
|
+
for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
|
|
935
|
+
if (naturalSum === 0) return
|
|
936
|
+
|
|
937
|
+
const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
|
|
938
|
+
const widths: Record<string, number> = {}
|
|
939
|
+
for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
|
|
940
|
+
columnWidths.value = widths
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Measure the natural widths once (the <th> must still be auto-sized) and
|
|
944
|
+
// distribute them. No-op once measured or while no column is displayed.
|
|
945
|
+
function ensureMeasured() {
|
|
946
|
+
if (Object.keys(naturalWidths.value).length > 0) return
|
|
947
|
+
if (displayedColumns.value.length === 0) return
|
|
948
|
+
measureNaturalWidths()
|
|
949
|
+
redistributeColumnWidths()
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Drive measurement and re-stretch off the container's reactive width
|
|
953
|
+
// (`useElementSize` wraps a ResizeObserver and disconnects it on unmount). The
|
|
954
|
+
// width goes 0 -> N once the table is laid out in the DOM, which is also when
|
|
955
|
+
// the <thead> carries its content-driven natural widths: the first non-zero
|
|
956
|
+
// width measures the stable base, later changes (window resize) only
|
|
957
|
+
// redistribute it.
|
|
958
|
+
const { width: containerWidth } = useElementSize(scrollContainerRef)
|
|
959
|
+
|
|
960
|
+
watch(containerWidth, (width) => {
|
|
961
|
+
if (width === 0) return
|
|
962
|
+
if (Object.keys(naturalWidths.value).length === 0) {
|
|
963
|
+
ensureMeasured()
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
redistributeColumnWidths()
|
|
967
|
+
}
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
// Fallback: re-run once columns appear if they weren't available when the
|
|
971
|
+
// container was first sized (standalone usage where the profile resolves late).
|
|
972
|
+
watch(
|
|
973
|
+
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
974
|
+
async ([dataReady, colCount]) => {
|
|
975
|
+
if (!dataReady || colCount === 0) return
|
|
976
|
+
if (Object.keys(naturalWidths.value).length > 0) return
|
|
977
|
+
await nextTick()
|
|
978
|
+
ensureMeasured()
|
|
979
|
+
},
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
// Re-stretch the remaining columns whenever the displayed set changes (columns
|
|
983
|
+
// toggled / hidden / isolated from the menu). No DOM measurement needed — we
|
|
984
|
+
// redistribute the already-measured natural widths over the new container space.
|
|
985
|
+
watch(displayedColumns, async () => {
|
|
986
|
+
if (Object.keys(naturalWidths.value).length === 0) return
|
|
987
|
+
// Wait for the table to (re)mount before reading the container: the desktop
|
|
988
|
+
// table is `v-if`-ed out when no column is displayed, so going from 0 → N
|
|
989
|
+
// columns means `scrollContainer` only exists after the next render.
|
|
990
|
+
await nextTick()
|
|
991
|
+
redistributeColumnWidths()
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
function startResize(col: string, e: MouseEvent) {
|
|
995
|
+
// Fallback in case the user grabs the handle before the initial measure ran.
|
|
996
|
+
if (!Object.keys(naturalWidths.value).length) {
|
|
997
|
+
measureNaturalWidths()
|
|
998
|
+
redistributeColumnWidths()
|
|
999
|
+
}
|
|
1000
|
+
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
1001
|
+
// Disable smooth scroll during resize
|
|
1002
|
+
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
1003
|
+
document.addEventListener('mousemove', onResize)
|
|
1004
|
+
document.addEventListener('mouseup', stopResize)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function onResize(e: MouseEvent) {
|
|
1008
|
+
if (!resizing.value) return
|
|
1009
|
+
const container = scrollContainerRef.value
|
|
1010
|
+
// Auto-scroll when mouse approaches the right edge, and adjust startX to compensate
|
|
1011
|
+
if (container) {
|
|
1012
|
+
const rect = container.getBoundingClientRect()
|
|
1013
|
+
const distFromRight = rect.right - e.clientX
|
|
1014
|
+
if (distFromRight < 50) {
|
|
1015
|
+
const oldScroll = container.scrollLeft
|
|
1016
|
+
container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
|
|
1017
|
+
resizing.value.startX -= container.scrollLeft - oldScroll
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
const delta = e.clientX - resizing.value.startX
|
|
1021
|
+
const newWidth = Math.max(60, resizing.value.startWidth + delta)
|
|
1022
|
+
columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function stopResize() {
|
|
1026
|
+
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
|
|
1027
|
+
// Persist the manual width as the column's new base so it survives later
|
|
1028
|
+
// redistribution when other columns are toggled.
|
|
1029
|
+
if (resizing.value) {
|
|
1030
|
+
const col = resizing.value.column
|
|
1031
|
+
const width = columnWidths.value[col]
|
|
1032
|
+
if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
|
|
1033
|
+
}
|
|
1034
|
+
resizing.value = null
|
|
1035
|
+
document.removeEventListener('mousemove', onResize)
|
|
1036
|
+
document.removeEventListener('mouseup', stopResize)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
onUnmounted(() => {
|
|
1040
|
+
document.removeEventListener('mousemove', onResize)
|
|
1041
|
+
document.removeEventListener('mouseup', stopResize)
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
// Cell popover
|
|
1045
|
+
const activeCell = ref<CellInfo | null>(null)
|
|
1046
|
+
|
|
1047
|
+
function onCellClick(col: string, value: unknown, event: MouseEvent) {
|
|
1048
|
+
// A link inside a cell (see `rowHref`) owns its clicks: navigate, don't open
|
|
1049
|
+
// the popover on top of the page we're leaving.
|
|
1050
|
+
if ((event.target as HTMLElement).closest('a')) return
|
|
1051
|
+
const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
|
|
1052
|
+
if (!el) return
|
|
1053
|
+
if (activeCell.value && activeCell.value.element === el) {
|
|
1054
|
+
activeCell.value = null
|
|
1055
|
+
return
|
|
1056
|
+
}
|
|
1057
|
+
activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Active filters
|
|
1061
|
+
interface ActiveFilter {
|
|
1062
|
+
column: string
|
|
1063
|
+
label: string
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const activeFilters = computed<ActiveFilter[]>(() => {
|
|
1067
|
+
const result: ActiveFilter[] = []
|
|
1068
|
+
for (const [col, filter] of Object.entries(filters.value)) {
|
|
1069
|
+
const parts: string[] = []
|
|
1070
|
+
if (filter.in?.length) {
|
|
1071
|
+
parts.push(`= ${filter.in.join(', ')}`)
|
|
1072
|
+
}
|
|
1073
|
+
if (filter.exact != null) {
|
|
1074
|
+
if (getColumnType(col) === 'boolean') {
|
|
1075
|
+
parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
parts.push(`= ${filter.exact}`)
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (filter.contains) {
|
|
1082
|
+
parts.push(`${t('contient')} "${filter.contains}"`)
|
|
1083
|
+
}
|
|
1084
|
+
if (filter.null === 'only') {
|
|
1085
|
+
parts.push(t('null uniquement'))
|
|
1086
|
+
}
|
|
1087
|
+
else if (filter.null === 'exclude') {
|
|
1088
|
+
parts.push(t('sans null'))
|
|
1089
|
+
}
|
|
1090
|
+
if (filter.min != null && filter.max != null) {
|
|
1091
|
+
parts.push(`${filter.min} – ${filter.max}`)
|
|
1092
|
+
}
|
|
1093
|
+
else if (filter.min != null) {
|
|
1094
|
+
parts.push(`≥ ${filter.min}`)
|
|
1095
|
+
}
|
|
1096
|
+
else if (filter.max != null) {
|
|
1097
|
+
parts.push(`≤ ${filter.max}`)
|
|
1098
|
+
}
|
|
1099
|
+
if (parts.length) {
|
|
1100
|
+
result.push({ column: col, label: parts.join(', ') })
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return result
|
|
1104
|
+
})
|
|
1105
|
+
|
|
1106
|
+
function removeFilter(column: string) {
|
|
1107
|
+
const { [column]: _, ...rest } = filters.value
|
|
1108
|
+
filters.value = rest
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function clearAllFilters() {
|
|
1112
|
+
filters.value = {}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function hasFilterForColumn(col: string): boolean {
|
|
1116
|
+
return _hasFilterForColumn(filters.value, col)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Mobile state
|
|
1120
|
+
const mobileFilterOpen = ref(false)
|
|
1121
|
+
const mobileExpandedRows = ref(new Set<number>())
|
|
1122
|
+
const mobileFilterExpandedCol = ref<string | null>(null)
|
|
1123
|
+
|
|
1124
|
+
function mobileVisibleFields(index: number): string[] {
|
|
1125
|
+
if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
|
|
1126
|
+
return displayedColumns.value
|
|
1127
|
+
}
|
|
1128
|
+
return displayedColumns.value.slice(0, 4)
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function toggleMobileExpand(index: number) {
|
|
1132
|
+
const next = new Set(mobileExpandedRows.value)
|
|
1133
|
+
if (next.has(index)) next.delete(index)
|
|
1134
|
+
else next.add(index)
|
|
1135
|
+
mobileExpandedRows.value = next
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function toggleMobileFilterColumn(col: string) {
|
|
1139
|
+
mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function getColumnProfile(col: string) {
|
|
1143
|
+
return profileData.value?.profile?.profile?.[col] ?? null
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function getTopsEntries(col: string) {
|
|
1147
|
+
return getColumnProfile(col)?.tops ?? []
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function getNullPercent(col: string) {
|
|
1151
|
+
const colProfile = getColumnProfile(col)
|
|
1152
|
+
const total = profileData.value?.profile?.total_lines
|
|
1153
|
+
if (!colProfile || !total) return '0%'
|
|
1154
|
+
return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const typeConfig = buildTypeConfig(t)
|
|
1158
|
+
const formatConfig = buildFormatConfig(t)
|
|
1159
|
+
|
|
1160
|
+
function getTypeConfig(col: string) {
|
|
1161
|
+
return typeConfig[getColumnType(col)]
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Header display: prefer the rich semantic format detected by csv-detective
|
|
1165
|
+
// (code INSEE, UAI, département, SIREN, email…) over the generic display type.
|
|
1166
|
+
// Tolerant: generic formats fall back to the type config, and unknown semantic
|
|
1167
|
+
// formats are humanized rather than dropped.
|
|
1168
|
+
function getColumnDisplay(col: string) {
|
|
1169
|
+
const format = profileData.value?.profile?.columns?.[col]?.format
|
|
1170
|
+
const fallback = getTypeConfig(col)
|
|
1171
|
+
if (!format || GENERIC_FORMATS.has(format)) return fallback
|
|
1172
|
+
return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const BADGE_PALETTE = [
|
|
1176
|
+
{ bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
|
|
1177
|
+
{ bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
|
|
1178
|
+
{ bg: '#FEECC2', text: '#695240' }, // yellow-tournesol
|
|
1179
|
+
{ bg: '#C7F6FC', text: '#006A6F' }, // green-archipel
|
|
1180
|
+
{ bg: '#E9EDFE', text: '#2323FF' }, // blue-ecume
|
|
1181
|
+
{ bg: '#FFE0C7', text: '#885B40' }, // orange-terre-battue
|
|
1182
|
+
{ bg: '#F2E9DB', text: '#6B4C35' }, // brown-cafe-creme
|
|
1183
|
+
{ bg: '#BAFAEE', text: '#297254' }, // green-menthe
|
|
1184
|
+
{ bg: '#FEE0EB', text: '#8D5368' }, // pink-macaron
|
|
1185
|
+
{ bg: '#FCE164', text: '#695240' }, // yellow-moutarde
|
|
1186
|
+
] as const
|
|
1187
|
+
|
|
1188
|
+
const BADGE_FALLBACK = { bg: '#F0E0CF', text: '#745B47' } // brown-opera
|
|
1189
|
+
|
|
1190
|
+
const badgeColorMap = computed(() => {
|
|
1191
|
+
const map = new Map<string, { bg: string, text: string }>()
|
|
1192
|
+
const profile = profileData.value?.profile
|
|
1193
|
+
if (!profile) return map
|
|
1194
|
+
for (const col of profile.categorical) {
|
|
1195
|
+
getTopsEntries(col).forEach((top, i) => {
|
|
1196
|
+
map.set(`${col}::${top.value}`, BADGE_PALETTE[i % BADGE_PALETTE.length]!)
|
|
1197
|
+
})
|
|
1198
|
+
}
|
|
1199
|
+
return map
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
|
|
1203
|
+
const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
|
|
1204
|
+
return { backgroundColor: colors.bg, color: colors.text }
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
|
|
1208
|
+
const styles: Record<string, BadgeStyle> = {}
|
|
1209
|
+
for (const top of getTopsEntries(col)) {
|
|
1210
|
+
styles[top.value] = getCategoryBadgeStyle(col, top.value)
|
|
1211
|
+
}
|
|
1212
|
+
return styles
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
|
|
1216
|
+
const profile = getColumnProfile(col)
|
|
1217
|
+
if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
|
|
1218
|
+
let trueCount = 0
|
|
1219
|
+
let falseCount = 0
|
|
1220
|
+
for (const top of profile.tops) {
|
|
1221
|
+
if (isTruthy(top.value ?? '')) trueCount += top.count
|
|
1222
|
+
else if (isFalsy(top.value ?? '')) falseCount += top.count
|
|
1223
|
+
}
|
|
1224
|
+
return { trueCount, falseCount }
|
|
1225
|
+
}
|
|
1226
|
+
</script>
|