@datagouv/components-next 1.0.2-dev.7 → 1.0.2-dev.71
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/assets/main.css +4 -0
- package/dist/Datafair.client-BzW-ctDf.js +30 -0
- package/dist/JsonPreview.client-BfMSzR07.js +40 -0
- package/dist/{MapContainer.client-DRkAmdOc.js → MapContainer.client-CLs-im9i.js} +35 -38
- package/dist/{PdfPreview.client-C-w6-w44.js → PdfPreview.client-C13PQCU_.js} +822 -865
- package/dist/{Pmtiles.client-BR7_ldHY.js → Pmtiles.client-CL7PXXDl.js} +574 -579
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C6XnsZ-7.js +61 -0
- package/dist/XmlPreview.client-KaENrbbG.js +34 -0
- package/dist/components-next.css +3 -3
- package/dist/components-next.js +166 -148
- package/dist/components.css +1 -1
- package/dist/{index-SrYZwgCT.js → index-C7WVVGgD.js} +1 -1
- package/dist/{main-B2kXxWRG.js → main-K-42Oe8-.js} +91315 -75834
- package/dist/{vue3-xml-viewer.common-BRxsqI9j.js → vue3-xml-viewer.common-sHPSE-jD.js} +1 -1
- package/package.json +16 -10
- package/src/components/ActivityList/ActivityList.vue +0 -2
- package/src/components/Chart/ChartViewer.vue +226 -0
- package/src/components/Chart/ChartViewerWrapper.vue +170 -0
- package/src/components/Form/Listbox.vue +101 -0
- package/src/components/Form/SearchableSelect.vue +2 -1
- package/src/components/InfiniteLoader.vue +53 -0
- 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/ReadMore.vue +1 -1
- package/src/components/ResourceAccordion/Datafair.client.vue +4 -10
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -121
- package/src/components/ResourceAccordion/MapContainer.client.vue +7 -11
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -103
- package/src/components/ResourceAccordion/Pmtiles.client.vue +5 -10
- package/src/components/ResourceAccordion/Preview.vue +16 -21
- package/src/components/ResourceAccordion/PreviewLoader.vue +1 -2
- package/src/components/ResourceAccordion/PreviewUnavailable.vue +22 -0
- package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -7
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -115
- package/src/components/ResourceExplorer/ResourceExplorer.vue +81 -13
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -11
- package/src/components/Search/GlobalSearch.vue +173 -108
- package/src/components/Search/SearchInput.vue +3 -3
- package/src/components/TabularExplorer/TabularCell.vue +51 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +870 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
- package/src/components/TabularExplorer/types.ts +83 -0
- package/src/composables/useHasTabularData.ts +6 -0
- package/src/composables/useResourceCapabilities.ts +1 -1
- package/src/composables/useSearchFilter.ts +118 -0
- package/src/composables/useStableQueryParams.ts +31 -3
- package/src/config.ts +3 -0
- package/src/functions/api.ts +34 -33
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +68 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/resources.ts +56 -1
- package/src/functions/tabular.ts +60 -0
- package/src/functions/tabularApi.ts +138 -11
- package/src/main.ts +55 -7
- package/src/types/dataservices.ts +2 -0
- package/src/types/pages.ts +0 -5
- package/src/types/posts.ts +2 -2
- package/src/types/reports.ts +3 -0
- package/src/types/search.ts +52 -1
- package/src/types/site.ts +5 -3
- package/src/types/users.ts +0 -1
- package/src/types/visualizations.ts +89 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/Datafair.client-E5D6ePRC.js +0 -35
- package/dist/JsonPreview.client-C-6eBbPw.js +0 -87
- package/dist/Swagger.client-D4-F6yEf.js +0 -4
- package/dist/XmlPreview.client-Dl2VCgXF.js +0 -79
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /package/assets/illustrations/{_microscope.svg → microscope.svg} +0 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<SimpleBanner
|
|
4
|
+
v-if="error"
|
|
5
|
+
type="warning"
|
|
6
|
+
class="mb-4"
|
|
7
|
+
>
|
|
8
|
+
{{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
|
|
9
|
+
<pre class="text-xs mt-2">{{ error }}</pre>
|
|
10
|
+
</SimpleBanner>
|
|
11
|
+
|
|
12
|
+
<template v-else-if="tableData">
|
|
13
|
+
<!-- Toolbar -->
|
|
14
|
+
<div class="flex items-center py-3 gap-2">
|
|
15
|
+
<!-- Mobile: filter & sort button -->
|
|
16
|
+
<BrandedButton
|
|
17
|
+
class="md:hidden"
|
|
18
|
+
color="tertiary"
|
|
19
|
+
size="2xs"
|
|
20
|
+
:icon="RiFilter2Line"
|
|
21
|
+
keep-margins-even-without-borders
|
|
22
|
+
@click="mobileFilterOpen = true"
|
|
23
|
+
>
|
|
24
|
+
{{ t('Filtres & tri') }}
|
|
25
|
+
</BrandedButton>
|
|
26
|
+
|
|
27
|
+
<div class="flex-1" />
|
|
28
|
+
|
|
29
|
+
<!-- Right: Stats -->
|
|
30
|
+
<div class="flex items-center gap-4">
|
|
31
|
+
<!-- Columns (clickable, opens column popover) -->
|
|
32
|
+
<Popover
|
|
33
|
+
ref="columnAnchor"
|
|
34
|
+
v-slot="{ open }"
|
|
35
|
+
class="relative"
|
|
36
|
+
>
|
|
37
|
+
<PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
|
|
38
|
+
<RiLayoutColumnLine
|
|
39
|
+
class="size-3"
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
/>
|
|
42
|
+
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
43
|
+
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
44
|
+
<RiArrowDownSLine
|
|
45
|
+
class="size-3 opacity-50"
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
/>
|
|
48
|
+
</PopoverButton>
|
|
49
|
+
|
|
50
|
+
<ClientOnly>
|
|
51
|
+
<Teleport to="#tooltips">
|
|
52
|
+
<PopoverPanel
|
|
53
|
+
v-show="open"
|
|
54
|
+
ref="columnPanel"
|
|
55
|
+
static
|
|
56
|
+
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
57
|
+
:style="columnFloatingStyles"
|
|
58
|
+
>
|
|
59
|
+
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
|
|
60
|
+
<span class="text-xs font-medium text-gray-title">
|
|
61
|
+
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
62
|
+
</span>
|
|
63
|
+
<BrandedButton
|
|
64
|
+
v-if="hiddenCount"
|
|
65
|
+
color="tertiary"
|
|
66
|
+
size="2xs"
|
|
67
|
+
@click="showAllColumns"
|
|
68
|
+
>
|
|
69
|
+
{{ t('Tout afficher') }}
|
|
70
|
+
</BrandedButton>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="max-h-64 overflow-auto p-1">
|
|
73
|
+
<label
|
|
74
|
+
v-for="col in allColumns"
|
|
75
|
+
:key="col"
|
|
76
|
+
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
|
|
77
|
+
>
|
|
78
|
+
<input
|
|
79
|
+
type="checkbox"
|
|
80
|
+
:checked="visibleColumns.has(col)"
|
|
81
|
+
class="size-3.5 accent-new-primary"
|
|
82
|
+
@change="toggleColumn(col)"
|
|
83
|
+
>
|
|
84
|
+
<span class="truncate">{{ col }}</span>
|
|
85
|
+
</label>
|
|
86
|
+
</div>
|
|
87
|
+
</PopoverPanel>
|
|
88
|
+
</Teleport>
|
|
89
|
+
</ClientOnly>
|
|
90
|
+
</Popover>
|
|
91
|
+
|
|
92
|
+
<!-- Rows -->
|
|
93
|
+
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
94
|
+
<RiLayoutRowLine
|
|
95
|
+
class="size-3 text-mention-grey"
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
/>
|
|
98
|
+
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
99
|
+
<span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- Active filters -->
|
|
105
|
+
<div
|
|
106
|
+
v-if="activeFilters.length > 0"
|
|
107
|
+
class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
|
|
108
|
+
>
|
|
109
|
+
<!-- Header -->
|
|
110
|
+
<div class="flex items-center justify-between">
|
|
111
|
+
<div class="flex items-center gap-2">
|
|
112
|
+
<RiFilter2Line
|
|
113
|
+
class="size-3.5"
|
|
114
|
+
aria-hidden="true"
|
|
115
|
+
/>
|
|
116
|
+
<span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
|
|
117
|
+
<span class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5">
|
|
118
|
+
{{ activeFilters.length }}
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
<BrandedButton
|
|
122
|
+
color="tertiary"
|
|
123
|
+
size="2xs"
|
|
124
|
+
:icon="RiCloseLine"
|
|
125
|
+
@click="clearAllFilters"
|
|
126
|
+
>
|
|
127
|
+
{{ t('Tout effacer') }}
|
|
128
|
+
</BrandedButton>
|
|
129
|
+
</div>
|
|
130
|
+
<!-- Chips -->
|
|
131
|
+
<div class="flex flex-wrap gap-1.5">
|
|
132
|
+
<span
|
|
133
|
+
v-for="af in activeFilters"
|
|
134
|
+
:key="af.column"
|
|
135
|
+
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
136
|
+
>
|
|
137
|
+
<component
|
|
138
|
+
:is="getTypeConfig(af.column).icon"
|
|
139
|
+
class="size-3 text-gray-title"
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
/>
|
|
142
|
+
<span class="text-gray-title">{{ af.column }}</span>
|
|
143
|
+
<span class="text-gray-plain">{{ af.label }}</span>
|
|
144
|
+
<button
|
|
145
|
+
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
146
|
+
@click="removeFilter(af.column)"
|
|
147
|
+
>
|
|
148
|
+
<RiCloseLine
|
|
149
|
+
class="size-3"
|
|
150
|
+
aria-hidden="true"
|
|
151
|
+
/>
|
|
152
|
+
<span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
|
|
153
|
+
</button>
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Desktop: scrollable table -->
|
|
159
|
+
<div
|
|
160
|
+
ref="scrollContainer"
|
|
161
|
+
class="hidden md:block overflow-auto max-h-[70vh]"
|
|
162
|
+
>
|
|
163
|
+
<table class="text-sm border-collapse">
|
|
164
|
+
<thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
165
|
+
<tr class="border-b border-gray-default">
|
|
166
|
+
<th
|
|
167
|
+
v-for="col in displayedColumns"
|
|
168
|
+
:key="col"
|
|
169
|
+
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"
|
|
170
|
+
:style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
171
|
+
>
|
|
172
|
+
<div class="flex items-center gap-0.5 min-w-0">
|
|
173
|
+
<span
|
|
174
|
+
class="font-extrabold text-sm truncate"
|
|
175
|
+
:title="col"
|
|
176
|
+
>{{ col }}</span>
|
|
177
|
+
<RiArrowUpLine
|
|
178
|
+
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
179
|
+
class="size-3.5 shrink-0 text-new-primary"
|
|
180
|
+
aria-hidden="true"
|
|
181
|
+
/>
|
|
182
|
+
<RiArrowDownLine
|
|
183
|
+
v-else-if="sort?.column === col && sort.direction === 'desc'"
|
|
184
|
+
class="size-3.5 shrink-0 text-new-primary"
|
|
185
|
+
aria-hidden="true"
|
|
186
|
+
/>
|
|
187
|
+
<TabularFilterPopover
|
|
188
|
+
v-model:sort="sort"
|
|
189
|
+
v-model:filters="filters"
|
|
190
|
+
class="mt-1"
|
|
191
|
+
:column="col"
|
|
192
|
+
:column-type="getColumnType(col)"
|
|
193
|
+
:column-profile="getColumnProfile(col)"
|
|
194
|
+
:null-percent="getNullPercent(col)"
|
|
195
|
+
:total-lines="totalLines"
|
|
196
|
+
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
197
|
+
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
<!-- Column type -->
|
|
201
|
+
<span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1">
|
|
202
|
+
<component
|
|
203
|
+
:is="getTypeConfig(col).icon"
|
|
204
|
+
class="size-3"
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
/>
|
|
207
|
+
<span class="mt-px">{{ getTypeConfig(col).label }}</span>
|
|
208
|
+
</span>
|
|
209
|
+
<!-- Resize handle: wide hit zone, thin visible bar -->
|
|
210
|
+
<div
|
|
211
|
+
class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
|
|
212
|
+
:class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
|
|
213
|
+
@mousedown.prevent="startResize(col, $event)"
|
|
214
|
+
>
|
|
215
|
+
<div
|
|
216
|
+
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"
|
|
217
|
+
:class="{ '!opacity-100': resizing?.column === col }"
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
</th>
|
|
221
|
+
</tr>
|
|
222
|
+
</thead>
|
|
223
|
+
<tbody>
|
|
224
|
+
<tr v-if="allRows.length === 0">
|
|
225
|
+
<td
|
|
226
|
+
:colspan="displayedColumns.length"
|
|
227
|
+
class="py-16 text-center"
|
|
228
|
+
>
|
|
229
|
+
<div class="flex flex-col items-center gap-2">
|
|
230
|
+
<RiSearchLine
|
|
231
|
+
class="size-8 text-gray-low"
|
|
232
|
+
aria-hidden="true"
|
|
233
|
+
/>
|
|
234
|
+
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
235
|
+
</div>
|
|
236
|
+
</td>
|
|
237
|
+
</tr>
|
|
238
|
+
<tr
|
|
239
|
+
v-for="(row, i) in allRows"
|
|
240
|
+
:key="row.__id ?? i"
|
|
241
|
+
class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
|
|
242
|
+
>
|
|
243
|
+
<td
|
|
244
|
+
v-for="col in displayedColumns"
|
|
245
|
+
:key="col"
|
|
246
|
+
data-cell
|
|
247
|
+
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"
|
|
248
|
+
:class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' }"
|
|
249
|
+
:style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
250
|
+
@click="onCellClick(col, row[col], $event)"
|
|
251
|
+
>
|
|
252
|
+
<TabularCell
|
|
253
|
+
:value="row[col]"
|
|
254
|
+
:column-type="getColumnType(col)"
|
|
255
|
+
:category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
|
|
256
|
+
/>
|
|
257
|
+
</td>
|
|
258
|
+
</tr>
|
|
259
|
+
</tbody>
|
|
260
|
+
</table>
|
|
261
|
+
<InfiniteLoader
|
|
262
|
+
v-if="hasMore"
|
|
263
|
+
:root="scrollContainerRef"
|
|
264
|
+
@intersect="loadNextPage"
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<!-- Cell popover -->
|
|
269
|
+
<TabularCellPopover
|
|
270
|
+
v-model:cell="activeCell"
|
|
271
|
+
v-model:filters="filters"
|
|
272
|
+
/>
|
|
273
|
+
|
|
274
|
+
<!-- Mobile: card layout -->
|
|
275
|
+
<div class="md:hidden space-y-2 px-1">
|
|
276
|
+
<div
|
|
277
|
+
v-if="allRows.length === 0"
|
|
278
|
+
class="py-16 text-center"
|
|
279
|
+
>
|
|
280
|
+
<div class="flex flex-col items-center gap-2">
|
|
281
|
+
<RiSearchLine
|
|
282
|
+
class="size-8 text-gray-low"
|
|
283
|
+
aria-hidden="true"
|
|
284
|
+
/>
|
|
285
|
+
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
<div
|
|
289
|
+
v-for="(row, i) in allRows"
|
|
290
|
+
:key="row.__id ?? i"
|
|
291
|
+
class="border border-gray-default rounded-lg p-3 space-y-2"
|
|
292
|
+
:class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
|
|
293
|
+
>
|
|
294
|
+
<div
|
|
295
|
+
v-for="col in mobileVisibleFields(i)"
|
|
296
|
+
:key="col"
|
|
297
|
+
class="flex flex-col gap-0.5 min-w-0"
|
|
298
|
+
>
|
|
299
|
+
<div class="flex items-center gap-1 min-w-0">
|
|
300
|
+
<component
|
|
301
|
+
:is="getTypeConfig(col).icon"
|
|
302
|
+
class="size-3 text-gray-low shrink-0"
|
|
303
|
+
aria-hidden="true"
|
|
304
|
+
/>
|
|
305
|
+
<span
|
|
306
|
+
class="text-xs text-gray-plain truncate"
|
|
307
|
+
:title="col"
|
|
308
|
+
>{{ col }}</span>
|
|
309
|
+
</div>
|
|
310
|
+
<div
|
|
311
|
+
data-cell
|
|
312
|
+
class="min-w-0 pl-4 cursor-pointer"
|
|
313
|
+
@click="onCellClick(col, row[col], $event)"
|
|
314
|
+
>
|
|
315
|
+
<TabularCell
|
|
316
|
+
:value="row[col]"
|
|
317
|
+
:column-type="getColumnType(col)"
|
|
318
|
+
:category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
|
|
319
|
+
compact
|
|
320
|
+
/>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
<button
|
|
324
|
+
v-if="displayedColumns.length > 4"
|
|
325
|
+
class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
|
|
326
|
+
@click="toggleMobileExpand(i)"
|
|
327
|
+
>
|
|
328
|
+
<RiArrowDownSLine
|
|
329
|
+
class="size-3 transition-transform"
|
|
330
|
+
:class="{ 'rotate-180': mobileExpandedRows.has(i) }"
|
|
331
|
+
aria-hidden="true"
|
|
332
|
+
/>
|
|
333
|
+
{{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
|
|
334
|
+
</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<!-- Mobile: filter bottom sheet -->
|
|
339
|
+
<TransitionRoot
|
|
340
|
+
:show="mobileFilterOpen"
|
|
341
|
+
as="template"
|
|
342
|
+
>
|
|
343
|
+
<Dialog
|
|
344
|
+
class="relative z-[900]"
|
|
345
|
+
@close="mobileFilterOpen = false"
|
|
346
|
+
>
|
|
347
|
+
<!-- Backdrop -->
|
|
348
|
+
<TransitionChild
|
|
349
|
+
as="template"
|
|
350
|
+
enter="ease-out duration-300"
|
|
351
|
+
enter-from="opacity-0"
|
|
352
|
+
enter-to="opacity-100"
|
|
353
|
+
leave="ease-in duration-200"
|
|
354
|
+
leave-from="opacity-100"
|
|
355
|
+
leave-to="opacity-0"
|
|
356
|
+
>
|
|
357
|
+
<div class="fixed inset-0 bg-black/30" />
|
|
358
|
+
</TransitionChild>
|
|
359
|
+
<!-- Panel -->
|
|
360
|
+
<TransitionChild
|
|
361
|
+
as="template"
|
|
362
|
+
enter="ease-out duration-300"
|
|
363
|
+
enter-from="translate-y-full"
|
|
364
|
+
enter-to="translate-y-0"
|
|
365
|
+
leave="ease-in duration-200"
|
|
366
|
+
leave-from="translate-y-0"
|
|
367
|
+
leave-to="translate-y-full"
|
|
368
|
+
>
|
|
369
|
+
<div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
|
|
370
|
+
<!-- Header -->
|
|
371
|
+
<DialogPanel class="flex flex-col max-h-[80vh]">
|
|
372
|
+
<div class="px-4 pt-4 pb-2 border-b border-gray-default">
|
|
373
|
+
<DialogTitle class="text-sm font-bold">
|
|
374
|
+
{{ t('Filtres & tri par colonne') }}
|
|
375
|
+
</DialogTitle>
|
|
376
|
+
<p class="text-xs text-gray-low">
|
|
377
|
+
{{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
|
|
378
|
+
</p>
|
|
379
|
+
</div>
|
|
380
|
+
<!-- Column list -->
|
|
381
|
+
<div class="flex-1 overflow-y-auto">
|
|
382
|
+
<div
|
|
383
|
+
v-for="col in allColumns"
|
|
384
|
+
:key="col"
|
|
385
|
+
class="border-b border-gray-default last:border-b-0"
|
|
386
|
+
>
|
|
387
|
+
<button
|
|
388
|
+
class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
|
|
389
|
+
:class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
|
|
390
|
+
@click="toggleMobileFilterColumn(col)"
|
|
391
|
+
>
|
|
392
|
+
<component
|
|
393
|
+
:is="getTypeConfig(col).icon"
|
|
394
|
+
class="size-3.5 text-gray-low shrink-0"
|
|
395
|
+
aria-hidden="true"
|
|
396
|
+
/>
|
|
397
|
+
<span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
|
|
398
|
+
<RiArrowUpLine
|
|
399
|
+
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
400
|
+
class="size-3 text-new-primary shrink-0"
|
|
401
|
+
aria-hidden="true"
|
|
402
|
+
/>
|
|
403
|
+
<RiArrowDownLine
|
|
404
|
+
v-if="sort?.column === col && sort.direction === 'desc'"
|
|
405
|
+
class="size-3 text-new-primary shrink-0"
|
|
406
|
+
aria-hidden="true"
|
|
407
|
+
/>
|
|
408
|
+
<span
|
|
409
|
+
v-if="hasFilterForColumn(col)"
|
|
410
|
+
class="size-2 rounded-full bg-new-primary shrink-0"
|
|
411
|
+
/>
|
|
412
|
+
<RiArrowDownSLine
|
|
413
|
+
class="size-3.5 text-gray-low shrink-0 transition-transform"
|
|
414
|
+
:class="{ 'rotate-180': mobileFilterExpandedCol === col }"
|
|
415
|
+
aria-hidden="true"
|
|
416
|
+
/>
|
|
417
|
+
</button>
|
|
418
|
+
<div
|
|
419
|
+
v-if="mobileFilterExpandedCol === col"
|
|
420
|
+
class="pb-1"
|
|
421
|
+
>
|
|
422
|
+
<TabularFilterContent
|
|
423
|
+
v-model:sort="sort"
|
|
424
|
+
v-model:filters="filters"
|
|
425
|
+
:column="col"
|
|
426
|
+
:column-type="getColumnType(col)"
|
|
427
|
+
:column-profile="getColumnProfile(col)"
|
|
428
|
+
:null-percent="getNullPercent(col)"
|
|
429
|
+
:total-lines="totalLines"
|
|
430
|
+
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
431
|
+
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
432
|
+
/>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
<!-- Reset all -->
|
|
437
|
+
<div
|
|
438
|
+
v-if="activeFilters.length > 0 || sort"
|
|
439
|
+
class="border-t border-gray-default px-4 py-3"
|
|
440
|
+
>
|
|
441
|
+
<BrandedButton
|
|
442
|
+
color="secondary"
|
|
443
|
+
size="xs"
|
|
444
|
+
class="w-full"
|
|
445
|
+
@click="clearAllFilters(); sort = null; mobileFilterOpen = false"
|
|
446
|
+
>
|
|
447
|
+
{{ t('Tout réinitialiser') }}
|
|
448
|
+
</BrandedButton>
|
|
449
|
+
</div>
|
|
450
|
+
</DialogPanel>
|
|
451
|
+
</div>
|
|
452
|
+
</TransitionChild>
|
|
453
|
+
</Dialog>
|
|
454
|
+
</TransitionRoot>
|
|
455
|
+
</template>
|
|
456
|
+
</div>
|
|
457
|
+
</template>
|
|
458
|
+
|
|
459
|
+
<script setup lang="ts">
|
|
460
|
+
import { computed, onUnmounted, ref, watch, useTemplateRef } from 'vue'
|
|
461
|
+
import { ofetch } from 'ofetch'
|
|
462
|
+
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
463
|
+
import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
464
|
+
import {
|
|
465
|
+
RiLayoutColumnLine,
|
|
466
|
+
RiLayoutRowLine,
|
|
467
|
+
RiArrowDownSLine,
|
|
468
|
+
RiArrowUpLine,
|
|
469
|
+
RiArrowDownLine,
|
|
470
|
+
RiFilter2Line,
|
|
471
|
+
RiCloseLine,
|
|
472
|
+
RiSearchLine,
|
|
473
|
+
} from '@remixicon/vue'
|
|
474
|
+
import { useFetch } from '../../functions/api'
|
|
475
|
+
import { useComponentsConfig } from '../../config'
|
|
476
|
+
import { useTranslation } from '../../composables/useTranslation'
|
|
477
|
+
import { buildTypeConfig, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy } from '../../functions/tabular'
|
|
478
|
+
import ClientOnly from '../ClientOnly.vue'
|
|
479
|
+
import SimpleBanner from '../SimpleBanner.vue'
|
|
480
|
+
import BrandedButton from '../BrandedButton.vue'
|
|
481
|
+
import InfiniteLoader from '../InfiniteLoader.vue'
|
|
482
|
+
import TabularCell from './TabularCell.vue'
|
|
483
|
+
import TabularCellPopover from './TabularCellPopover.vue'
|
|
484
|
+
import type { CellInfo } from './TabularCellPopover.vue'
|
|
485
|
+
import TabularFilterContent from './TabularFilterContent.vue'
|
|
486
|
+
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
487
|
+
import type { TabularDataResponse, TabularProfileResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
|
|
488
|
+
|
|
489
|
+
const props = defineProps<{
|
|
490
|
+
resourceId: string
|
|
491
|
+
}>()
|
|
492
|
+
|
|
493
|
+
const { t } = useTranslation()
|
|
494
|
+
const config = useComponentsConfig()
|
|
495
|
+
|
|
496
|
+
// Column selector popover positioning
|
|
497
|
+
const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
|
|
498
|
+
const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
|
|
499
|
+
const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
|
|
500
|
+
const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
|
|
501
|
+
|
|
502
|
+
const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
|
|
503
|
+
placement: 'bottom-start',
|
|
504
|
+
middleware: [flip(), shift()],
|
|
505
|
+
whileElementsMounted: autoUpdate,
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
const dataUrl = computed(() =>
|
|
509
|
+
`${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
const profileUrl = computed(() =>
|
|
513
|
+
`${config.tabularApiUrl}/api/resources/${props.resourceId}/profile/`,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
// Sort & filter state
|
|
517
|
+
const sort = ref<SortConfig | null>(null)
|
|
518
|
+
const filters = ref<Record<string, ColumnFilters>>({})
|
|
519
|
+
|
|
520
|
+
const PAGE_SIZE = 50
|
|
521
|
+
|
|
522
|
+
const dataQuery = computed(() => {
|
|
523
|
+
const q: Record<string, string | number> = { page: 1, page_size: PAGE_SIZE }
|
|
524
|
+
if (sort.value) {
|
|
525
|
+
q[`${sort.value.column}__sort`] = sort.value.direction
|
|
526
|
+
}
|
|
527
|
+
for (const [col, filter] of Object.entries(filters.value)) {
|
|
528
|
+
if (filter.in?.length) {
|
|
529
|
+
q[`${col}__in`] = filter.in.join(',')
|
|
530
|
+
}
|
|
531
|
+
if (filter.exact != null) {
|
|
532
|
+
q[`${col}__exact`] = filter.exact
|
|
533
|
+
}
|
|
534
|
+
if (Number.isFinite(filter.min)) {
|
|
535
|
+
q[`${col}__greater`] = filter.min!
|
|
536
|
+
}
|
|
537
|
+
if (Number.isFinite(filter.max)) {
|
|
538
|
+
q[`${col}__less`] = filter.max!
|
|
539
|
+
}
|
|
540
|
+
if (filter.contains) {
|
|
541
|
+
q[`${col}__contains`] = filter.contains
|
|
542
|
+
}
|
|
543
|
+
if (filter.null === 'only') {
|
|
544
|
+
q[`${col}__isnull`] = ''
|
|
545
|
+
}
|
|
546
|
+
else if (filter.null === 'exclude') {
|
|
547
|
+
q[`${col}__isnotnull`] = ''
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return q
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
|
|
554
|
+
|
|
555
|
+
const { data: profileData } = await useFetch<TabularProfileResponse>(profileUrl, { raw: true })
|
|
556
|
+
|
|
557
|
+
// Infinite scroll state
|
|
558
|
+
const allRows = ref<TabularRow[]>([])
|
|
559
|
+
const currentPage = ref(1)
|
|
560
|
+
const hasMore = ref(false)
|
|
561
|
+
const loadingMore = ref(false)
|
|
562
|
+
const generation = ref(0)
|
|
563
|
+
const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
|
|
564
|
+
|
|
565
|
+
watch(() => tableData.value, (data) => {
|
|
566
|
+
generation.value++
|
|
567
|
+
if (data) {
|
|
568
|
+
allRows.value = [...data.data]
|
|
569
|
+
currentPage.value = 1
|
|
570
|
+
hasMore.value = data.data.length < data.meta.total
|
|
571
|
+
}
|
|
572
|
+
}, { immediate: true })
|
|
573
|
+
|
|
574
|
+
async function loadNextPage() {
|
|
575
|
+
if (loadingMore.value || !hasMore.value || !tableData.value) return
|
|
576
|
+
loadingMore.value = true
|
|
577
|
+
const gen = generation.value
|
|
578
|
+
try {
|
|
579
|
+
const nextPage = currentPage.value + 1
|
|
580
|
+
const query = { ...dataQuery.value, page: nextPage }
|
|
581
|
+
const data = await ofetch<TabularDataResponse>(dataUrl.value, { params: query })
|
|
582
|
+
// Discard stale response if filters/sort changed during the fetch
|
|
583
|
+
if (gen !== generation.value) return
|
|
584
|
+
allRows.value = [...allRows.value, ...data.data]
|
|
585
|
+
currentPage.value = nextPage
|
|
586
|
+
hasMore.value = allRows.value.length < tableData.value.meta.total
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
loadingMore.value = false
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
|
|
594
|
+
|
|
595
|
+
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
|
|
596
|
+
|
|
597
|
+
const visibleColumns = ref(new Set(allColumns.value))
|
|
598
|
+
|
|
599
|
+
watch(allColumns, (cols) => {
|
|
600
|
+
if (cols.length > 0 && visibleColumns.value.size === 0) {
|
|
601
|
+
visibleColumns.value = new Set(cols)
|
|
602
|
+
}
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
const displayedColumns = computed(() =>
|
|
606
|
+
allColumns.value.filter(col => visibleColumns.value.has(col)),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
const hiddenCount = computed(() =>
|
|
610
|
+
allColumns.value.length - visibleColumns.value.size,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
function toggleColumn(col: string) {
|
|
614
|
+
const next = new Set(visibleColumns.value)
|
|
615
|
+
if (next.has(col)) {
|
|
616
|
+
if (next.size > 1) next.delete(col)
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
next.add(col)
|
|
620
|
+
}
|
|
621
|
+
visibleColumns.value = next
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function showAllColumns() {
|
|
625
|
+
visibleColumns.value = new Set(allColumns.value)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Column resize
|
|
629
|
+
const columnWidths = ref<Record<string, number>>({})
|
|
630
|
+
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
631
|
+
|
|
632
|
+
function initColumnWidths() {
|
|
633
|
+
const ths = scrollContainerRef.value?.querySelectorAll('th')
|
|
634
|
+
if (!ths) return
|
|
635
|
+
const widths: Record<string, number> = {}
|
|
636
|
+
ths.forEach((th, i) => {
|
|
637
|
+
const col = displayedColumns.value[i]
|
|
638
|
+
if (col) widths[col] = th.offsetWidth
|
|
639
|
+
})
|
|
640
|
+
columnWidths.value = widths
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function startResize(col: string, e: MouseEvent) {
|
|
644
|
+
if (!Object.keys(columnWidths.value).length) initColumnWidths()
|
|
645
|
+
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
646
|
+
// Disable smooth scroll during resize
|
|
647
|
+
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
648
|
+
document.addEventListener('mousemove', onResize)
|
|
649
|
+
document.addEventListener('mouseup', stopResize)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function onResize(e: MouseEvent) {
|
|
653
|
+
if (!resizing.value) return
|
|
654
|
+
const container = scrollContainerRef.value
|
|
655
|
+
// Auto-scroll when mouse approaches the right edge, and adjust startX to compensate
|
|
656
|
+
if (container) {
|
|
657
|
+
const rect = container.getBoundingClientRect()
|
|
658
|
+
const distFromRight = rect.right - e.clientX
|
|
659
|
+
if (distFromRight < 50) {
|
|
660
|
+
const oldScroll = container.scrollLeft
|
|
661
|
+
container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
|
|
662
|
+
resizing.value.startX -= container.scrollLeft - oldScroll
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const delta = e.clientX - resizing.value.startX
|
|
666
|
+
const newWidth = Math.max(60, resizing.value.startWidth + delta)
|
|
667
|
+
columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function stopResize() {
|
|
671
|
+
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
|
|
672
|
+
resizing.value = null
|
|
673
|
+
document.removeEventListener('mousemove', onResize)
|
|
674
|
+
document.removeEventListener('mouseup', stopResize)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
onUnmounted(() => {
|
|
678
|
+
document.removeEventListener('mousemove', onResize)
|
|
679
|
+
document.removeEventListener('mouseup', stopResize)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
// Cell popover
|
|
683
|
+
const activeCell = ref<CellInfo | null>(null)
|
|
684
|
+
|
|
685
|
+
function onCellClick(col: string, value: unknown, event: MouseEvent) {
|
|
686
|
+
const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
|
|
687
|
+
if (!el) return
|
|
688
|
+
if (activeCell.value && activeCell.value.element === el) {
|
|
689
|
+
activeCell.value = null
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Active filters
|
|
696
|
+
interface ActiveFilter {
|
|
697
|
+
column: string
|
|
698
|
+
label: string
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const activeFilters = computed<ActiveFilter[]>(() => {
|
|
702
|
+
const result: ActiveFilter[] = []
|
|
703
|
+
for (const [col, filter] of Object.entries(filters.value)) {
|
|
704
|
+
const parts: string[] = []
|
|
705
|
+
if (filter.in?.length) {
|
|
706
|
+
parts.push(`= ${filter.in.join(', ')}`)
|
|
707
|
+
}
|
|
708
|
+
if (filter.exact != null) {
|
|
709
|
+
parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
|
|
710
|
+
}
|
|
711
|
+
if (filter.contains) {
|
|
712
|
+
parts.push(`${t('contient')} "${filter.contains}"`)
|
|
713
|
+
}
|
|
714
|
+
if (filter.null === 'only') {
|
|
715
|
+
parts.push(t('null uniquement'))
|
|
716
|
+
}
|
|
717
|
+
else if (filter.null === 'exclude') {
|
|
718
|
+
parts.push(t('sans null'))
|
|
719
|
+
}
|
|
720
|
+
if (filter.min != null && filter.max != null) {
|
|
721
|
+
parts.push(`${filter.min} – ${filter.max}`)
|
|
722
|
+
}
|
|
723
|
+
else if (filter.min != null) {
|
|
724
|
+
parts.push(`≥ ${filter.min}`)
|
|
725
|
+
}
|
|
726
|
+
else if (filter.max != null) {
|
|
727
|
+
parts.push(`≤ ${filter.max}`)
|
|
728
|
+
}
|
|
729
|
+
if (parts.length) {
|
|
730
|
+
result.push({ column: col, label: parts.join(', ') })
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return result
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
function removeFilter(column: string) {
|
|
737
|
+
const { [column]: _, ...rest } = filters.value
|
|
738
|
+
filters.value = rest
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function clearAllFilters() {
|
|
742
|
+
filters.value = {}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function hasFilterForColumn(col: string): boolean {
|
|
746
|
+
return _hasFilterForColumn(filters.value, col)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Mobile state
|
|
750
|
+
const mobileFilterOpen = ref(false)
|
|
751
|
+
const mobileExpandedRows = ref(new Set<number>())
|
|
752
|
+
const mobileFilterExpandedCol = ref<string | null>(null)
|
|
753
|
+
|
|
754
|
+
function mobileVisibleFields(index: number): string[] {
|
|
755
|
+
if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
|
|
756
|
+
return displayedColumns.value
|
|
757
|
+
}
|
|
758
|
+
return displayedColumns.value.slice(0, 4)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function toggleMobileExpand(index: number) {
|
|
762
|
+
const next = new Set(mobileExpandedRows.value)
|
|
763
|
+
if (next.has(index)) next.delete(index)
|
|
764
|
+
else next.add(index)
|
|
765
|
+
mobileExpandedRows.value = next
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function toggleMobileFilterColumn(col: string) {
|
|
769
|
+
mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Column type helpers
|
|
773
|
+
function resolveColumnType(col: string): ColumnType {
|
|
774
|
+
const profile = profileData.value?.profile
|
|
775
|
+
if (!profile) return 'text'
|
|
776
|
+
const colInfo = profile.columns[col]
|
|
777
|
+
if (!colInfo) return 'text'
|
|
778
|
+
if (['int', 'float'].includes(colInfo.python_type)) return 'number'
|
|
779
|
+
if (colInfo.format === 'year') return 'date'
|
|
780
|
+
if (['date', 'datetime'].includes(colInfo.python_type)) return 'date'
|
|
781
|
+
if (colInfo.python_type === 'bool') return 'boolean'
|
|
782
|
+
if (profile.categorical.includes(col)) return 'categorical'
|
|
783
|
+
return 'text'
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const columnTypesMap = computed(() => {
|
|
787
|
+
const map: Record<string, ColumnType> = {}
|
|
788
|
+
for (const col of allColumns.value) {
|
|
789
|
+
map[col] = resolveColumnType(col)
|
|
790
|
+
}
|
|
791
|
+
return map
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
function getColumnType(col: string): ColumnType {
|
|
795
|
+
return columnTypesMap.value[col] ?? 'text'
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function getColumnProfile(col: string) {
|
|
799
|
+
return profileData.value?.profile?.profile?.[col] ?? null
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function getTopsEntries(col: string) {
|
|
803
|
+
return getColumnProfile(col)?.tops ?? []
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function getNullPercent(col: string) {
|
|
807
|
+
const colProfile = getColumnProfile(col)
|
|
808
|
+
const total = profileData.value?.profile?.total_lines
|
|
809
|
+
if (!colProfile || !total) return '0%'
|
|
810
|
+
return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const typeConfig = buildTypeConfig(t)
|
|
814
|
+
|
|
815
|
+
function getTypeConfig(col: string) {
|
|
816
|
+
return typeConfig[getColumnType(col)]
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const BADGE_PALETTE = [
|
|
820
|
+
{ bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
|
|
821
|
+
{ bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
|
|
822
|
+
{ bg: '#FEECC2', text: '#695240' }, // yellow-tournesol
|
|
823
|
+
{ bg: '#C7F6FC', text: '#006A6F' }, // green-archipel
|
|
824
|
+
{ bg: '#E9EDFE', text: '#2323FF' }, // blue-ecume
|
|
825
|
+
{ bg: '#FFE0C7', text: '#885B40' }, // orange-terre-battue
|
|
826
|
+
{ bg: '#F2E9DB', text: '#6B4C35' }, // brown-cafe-creme
|
|
827
|
+
{ bg: '#BAFAEE', text: '#297254' }, // green-menthe
|
|
828
|
+
{ bg: '#FEE0EB', text: '#8D5368' }, // pink-macaron
|
|
829
|
+
{ bg: '#FCE164', text: '#695240' }, // yellow-moutarde
|
|
830
|
+
] as const
|
|
831
|
+
|
|
832
|
+
const BADGE_FALLBACK = { bg: '#F0E0CF', text: '#745B47' } // brown-opera
|
|
833
|
+
|
|
834
|
+
const badgeColorMap = computed(() => {
|
|
835
|
+
const map = new Map<string, { bg: string, text: string }>()
|
|
836
|
+
const profile = profileData.value?.profile
|
|
837
|
+
if (!profile) return map
|
|
838
|
+
for (const col of profile.categorical) {
|
|
839
|
+
getTopsEntries(col).forEach((top, i) => {
|
|
840
|
+
map.set(`${col}::${top.value}`, BADGE_PALETTE[i % BADGE_PALETTE.length]!)
|
|
841
|
+
})
|
|
842
|
+
}
|
|
843
|
+
return map
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
|
|
847
|
+
const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
|
|
848
|
+
return { backgroundColor: colors.bg, color: colors.text }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
|
|
852
|
+
const styles: Record<string, BadgeStyle> = {}
|
|
853
|
+
for (const top of getTopsEntries(col)) {
|
|
854
|
+
styles[top.value] = getCategoryBadgeStyle(col, top.value)
|
|
855
|
+
}
|
|
856
|
+
return styles
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
|
|
860
|
+
const profile = getColumnProfile(col)
|
|
861
|
+
if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
|
|
862
|
+
let trueCount = 0
|
|
863
|
+
let falseCount = 0
|
|
864
|
+
for (const top of profile.tops) {
|
|
865
|
+
if (isTruthy(top.value ?? '')) trueCount += top.count
|
|
866
|
+
else if (isFalsy(top.value ?? '')) falseCount += top.count
|
|
867
|
+
}
|
|
868
|
+
return { trueCount, falseCount }
|
|
869
|
+
}
|
|
870
|
+
</script>
|