@datagouv/components-next 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/{Control-ZFh5ta_U.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-rf4T1IkA.js → Datafair.client-D_6yzJUG.js} +9 -9
- package/dist/{Event-DSQcW7OF.js → Event-iXDrp-fy.js} +11 -11
- package/dist/{Image-BijNEG0p.js → Image-mNMBOofs.js} +5 -5
- package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
- package/dist/{JsonPreview.client-dzar6iuh.js → JsonPreview.client-DSl1kiwC.js} +5 -5
- package/dist/{Map-BUtPf5GN.js → Map-BfqKMw0S.js} +1306 -1166
- package/dist/{MapContainer.client-D-MoRNhG.js → MapContainer.client-BSfpk_rT.js} +15 -15
- package/dist/{OSM-D4MTdBtk.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DoDYLmJD.js → PdfPreview.client-BANFeM6K.js} +441 -434
- package/dist/Pmtiles.client-DH9ZrsZR.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
- package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-Dcl7oIVu.js → Tile-CSpn0Ca8.js} +153 -156
- package/dist/{TileImage-BJeHipMX.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-xp_P_OHw.js → View-BYBpt-AU.js} +167 -117
- package/dist/{XmlPreview.client-cOhwff6P.js → XmlPreview.client-KCB5mwVf.js} +5 -5
- package/dist/{common-BjQlan3k.js → common-BoeTUnDZ.js} +26 -26
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +201 -170
- package/dist/components.css +2 -2
- package/dist/{index-NofRBuyf.js → index-DHWzX5vK.js} +5999 -5472
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-pVQfonBI.js} +162 -164
- package/dist/{main-Iz1ZCL6k.js → main-BDcYkhOc.js} +36671 -29402
- package/dist/{proj-CsNo9yH1.js → proj-DxGbobp7.js} +312 -296
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-A0fLnBZr.js → tilecoord-DvX7WWiA.js} +264 -255
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-tVI9uXUz.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
- package/package.json +40 -39
- package/src/components/ActivityList/ActivityList.vue +21 -12
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +111 -17
- package/src/components/Chart/ChartViewerWrapper.vue +28 -16
- package/src/components/ChartCard.vue +112 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +23 -6
- package/src/components/DataserviceQuality.vue +82 -0
- package/src/components/DataserviceQualityInline.vue +31 -0
- package/src/components/DataserviceQualityTooltipContent.vue +79 -0
- package/src/components/DatasetCard.vue +17 -6
- package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
- package/src/components/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- package/src/components/DiscussionMessageCard.vue +14 -15
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/FormattedDate.vue +69 -0
- package/src/components/LoadingBlock.vue +4 -1
- package/src/components/ObjectCard.vue +4 -1
- package/src/components/ObjectCardOwner.vue +13 -1
- package/src/components/Pagination.vue +18 -58
- package/src/components/Placeholder.vue +3 -2
- package/src/components/PostCard.vue +13 -15
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +19 -7
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
- package/src/components/ResourceAccordion/Metadata.vue +21 -5
- package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +30 -19
- package/src/components/ResourceAccordion/Preview.vue +8 -4
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +31 -11
- package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +140 -184
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
- package/src/components/ResourceListItem.vue +168 -0
- package/src/components/ReuseCard.vue +18 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +4 -3
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +28 -10
- package/src/components/Search/SearchInput.vue +1 -1
- package/src/components/Search/Sidemenu.vue +4 -3
- package/src/components/SmallChart.vue +11 -10
- package/src/components/StatBox.vue +13 -5
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +7 -8
- package/src/components/TabularExplorer/TabularCellPopover.vue +21 -5
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularDateField.vue +41 -0
- package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +127 -780
- package/src/components/TabularExplorer/TabularFilterContent.vue +48 -38
- package/src/components/TabularExplorer/TabularFilterPopover.vue +27 -32
- package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
- package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
- package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
- package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
- package/src/components/TabularExplorer/TabularTable.vue +484 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +40 -28
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/components/TopicCard.vue +15 -14
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +2 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +48 -40
- package/src/composables/useSearchFilter.ts +7 -5
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +1 -0
- package/src/functions/api.ts +13 -7
- package/src/functions/charts.ts +32 -1
- package/src/functions/dates.ts +26 -7
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +57 -56
- package/src/functions/owned.ts +21 -0
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +88 -0
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +222 -5
- package/src/functions/tabularApi.ts +1 -43
- package/src/main.ts +32 -6
- package/src/types/dataservices.ts +15 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +2 -2
- package/src/types/visualizations.ts +5 -0
- package/dist/Pmtiles.client-Dzm01Zfm.js +0 -25185
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js +0 -61
- /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
- /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
- /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
|
@@ -1,572 +1,87 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div>
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<div
|
|
17
|
-
v-else-if="previewLoading"
|
|
18
|
-
class="animate-pulse-placeholder"
|
|
19
|
-
:aria-label="t('Chargement de l\'aperçu…')"
|
|
20
|
-
role="status"
|
|
21
|
-
>
|
|
22
|
-
<div class="container">
|
|
23
|
-
<div class="flex items-center justify-end gap-4 py-3">
|
|
24
|
-
<div class="h-4 w-20 bg-gray-200" />
|
|
25
|
-
<div class="h-4 w-20 bg-gray-200" />
|
|
26
|
-
</div>
|
|
27
|
-
</div>
|
|
28
|
-
<div class="overflow-hidden">
|
|
29
|
-
<table class="w-full text-sm border-collapse">
|
|
30
|
-
<thead class="shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
31
|
-
<tr>
|
|
32
|
-
<th
|
|
33
|
-
v-for="i in 6"
|
|
34
|
-
:key="i"
|
|
35
|
-
class="px-3 py-2 text-left"
|
|
36
|
-
>
|
|
37
|
-
<div class="h-4 w-24 bg-gray-200" />
|
|
38
|
-
</th>
|
|
39
|
-
</tr>
|
|
40
|
-
</thead>
|
|
41
|
-
<tbody>
|
|
42
|
-
<tr
|
|
43
|
-
v-for="row in 8"
|
|
44
|
-
:key="row"
|
|
45
|
-
class="border-b border-gray-100"
|
|
46
|
-
>
|
|
47
|
-
<td
|
|
48
|
-
v-for="col in 6"
|
|
49
|
-
:key="col"
|
|
50
|
-
class="px-3 py-2.5"
|
|
51
|
-
>
|
|
52
|
-
<div class="h-3 bg-gray-200" />
|
|
53
|
-
</td>
|
|
54
|
-
</tr>
|
|
55
|
-
</tbody>
|
|
56
|
-
</table>
|
|
57
|
-
</div>
|
|
58
|
-
</div>
|
|
59
|
-
|
|
2
|
+
<div class="flex min-h-0 flex-1 flex-col">
|
|
3
|
+
<PreviewUnavailable v-if="previewError">
|
|
4
|
+
{{ t("L'aperçu de ce fichier n'a pas pu être chargé.") }}
|
|
5
|
+
<br>
|
|
6
|
+
<span class="text-xs break-words">{{ previewError }}</span>
|
|
7
|
+
</PreviewUnavailable>
|
|
8
|
+
|
|
9
|
+
<!-- Same skeleton as the Suspense fallback above us: in both cases the slot isn't
|
|
10
|
+
rendered yet, so its toolbar is a placeholder too. -->
|
|
11
|
+
<TabularSkeleton v-else-if="previewLoading" />
|
|
12
|
+
|
|
13
|
+
<!-- Loaded: the consumer composes the parts (toolbar, table, mobile sheet) from
|
|
14
|
+
the provided context, so it controls the framing and layout. -->
|
|
60
15
|
<template v-else-if="tableData && profileData">
|
|
61
|
-
|
|
62
|
-
<div class="container">
|
|
63
|
-
<div class="flex items-center py-3 gap-2">
|
|
64
|
-
<!-- Mobile: filter & sort button -->
|
|
65
|
-
<BrandedButton
|
|
66
|
-
class="md:hidden"
|
|
67
|
-
color="tertiary"
|
|
68
|
-
size="2xs"
|
|
69
|
-
:icon="RiFilter2Line"
|
|
70
|
-
keep-margins-even-without-borders
|
|
71
|
-
@click="mobileFilterOpen = true"
|
|
72
|
-
>
|
|
73
|
-
{{ t('Filtres & tri') }}
|
|
74
|
-
</BrandedButton>
|
|
75
|
-
|
|
76
|
-
<div class="flex-1" />
|
|
77
|
-
|
|
78
|
-
<!-- Right: Stats -->
|
|
79
|
-
<div class="flex items-center gap-4">
|
|
80
|
-
<!-- Columns (clickable, opens column popover) -->
|
|
81
|
-
<Popover
|
|
82
|
-
ref="columnAnchor"
|
|
83
|
-
v-slot="{ open }"
|
|
84
|
-
class="relative"
|
|
85
|
-
>
|
|
86
|
-
<PopoverButton class="flex items-center gap-1.5 text-xs text-gray-plain rounded px-2 py-1 hover:bg-gray-50 focus:outline-none">
|
|
87
|
-
<RiLayoutColumnLine
|
|
88
|
-
class="size-3"
|
|
89
|
-
aria-hidden="true"
|
|
90
|
-
/>
|
|
91
|
-
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
92
|
-
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
93
|
-
<RiArrowDownSLine
|
|
94
|
-
class="size-3 opacity-50"
|
|
95
|
-
aria-hidden="true"
|
|
96
|
-
/>
|
|
97
|
-
</PopoverButton>
|
|
98
|
-
|
|
99
|
-
<ClientOnly>
|
|
100
|
-
<Teleport to="#tooltips">
|
|
101
|
-
<PopoverPanel
|
|
102
|
-
v-show="open"
|
|
103
|
-
ref="columnPanel"
|
|
104
|
-
static
|
|
105
|
-
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
106
|
-
:style="columnFloatingStyles"
|
|
107
|
-
>
|
|
108
|
-
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-default">
|
|
109
|
-
<span class="text-xs font-medium text-gray-title">
|
|
110
|
-
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
111
|
-
</span>
|
|
112
|
-
<BrandedButton
|
|
113
|
-
v-if="hiddenCount"
|
|
114
|
-
color="tertiary"
|
|
115
|
-
size="2xs"
|
|
116
|
-
@click="showAllColumns"
|
|
117
|
-
>
|
|
118
|
-
{{ t('Tout afficher') }}
|
|
119
|
-
</BrandedButton>
|
|
120
|
-
</div>
|
|
121
|
-
<div class="max-h-64 overflow-auto p-1">
|
|
122
|
-
<label
|
|
123
|
-
v-for="col in allColumns"
|
|
124
|
-
:key="col"
|
|
125
|
-
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
|
|
126
|
-
>
|
|
127
|
-
<input
|
|
128
|
-
type="checkbox"
|
|
129
|
-
:checked="visibleColumns.has(col)"
|
|
130
|
-
class="size-3.5 accent-new-primary"
|
|
131
|
-
@change="toggleColumn(col)"
|
|
132
|
-
>
|
|
133
|
-
<span class="truncate">{{ col }}</span>
|
|
134
|
-
</label>
|
|
135
|
-
</div>
|
|
136
|
-
</PopoverPanel>
|
|
137
|
-
</Teleport>
|
|
138
|
-
</ClientOnly>
|
|
139
|
-
</Popover>
|
|
140
|
-
|
|
141
|
-
<!-- Rows -->
|
|
142
|
-
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
143
|
-
<RiLayoutRowLine
|
|
144
|
-
class="size-3 text-mention-grey"
|
|
145
|
-
aria-hidden="true"
|
|
146
|
-
/>
|
|
147
|
-
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
148
|
-
<span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
149
|
-
</span>
|
|
150
|
-
</div>
|
|
151
|
-
</div>
|
|
152
|
-
|
|
153
|
-
<!-- Active filters -->
|
|
154
|
-
<div
|
|
155
|
-
v-if="activeFilters.length > 0"
|
|
156
|
-
class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
|
|
157
|
-
>
|
|
158
|
-
<!-- Header -->
|
|
159
|
-
<div class="flex items-center justify-between">
|
|
160
|
-
<div class="flex items-center gap-2">
|
|
161
|
-
<RiFilter2Line
|
|
162
|
-
class="size-3.5"
|
|
163
|
-
aria-hidden="true"
|
|
164
|
-
/>
|
|
165
|
-
<span class="text-xs text-gray-plain">{{ t('Filtres actifs') }}</span>
|
|
166
|
-
<span class="inline-flex items-center justify-center rounded-full bg-new-primary/10 text-new-primary text-xs tabular-nums min-w-5 h-5 px-1.5">
|
|
167
|
-
{{ activeFilters.length }}
|
|
168
|
-
</span>
|
|
169
|
-
</div>
|
|
170
|
-
<BrandedButton
|
|
171
|
-
color="tertiary"
|
|
172
|
-
size="2xs"
|
|
173
|
-
:icon="RiCloseLine"
|
|
174
|
-
@click="clearAllFilters"
|
|
175
|
-
>
|
|
176
|
-
{{ t('Tout effacer') }}
|
|
177
|
-
</BrandedButton>
|
|
178
|
-
</div>
|
|
179
|
-
<!-- Chips -->
|
|
180
|
-
<div class="flex flex-wrap gap-1.5">
|
|
181
|
-
<span
|
|
182
|
-
v-for="af in activeFilters"
|
|
183
|
-
:key="af.column"
|
|
184
|
-
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
185
|
-
>
|
|
186
|
-
<component
|
|
187
|
-
:is="getTypeConfig(af.column).icon"
|
|
188
|
-
class="size-3 text-gray-title"
|
|
189
|
-
aria-hidden="true"
|
|
190
|
-
/>
|
|
191
|
-
<span class="text-gray-title">{{ af.column }}</span>
|
|
192
|
-
<span class="text-gray-plain">{{ af.label }}</span>
|
|
193
|
-
<button
|
|
194
|
-
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
195
|
-
@click="removeFilter(af.column)"
|
|
196
|
-
>
|
|
197
|
-
<RiCloseLine
|
|
198
|
-
class="size-3"
|
|
199
|
-
aria-hidden="true"
|
|
200
|
-
/>
|
|
201
|
-
<span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
|
|
202
|
-
</button>
|
|
203
|
-
</span>
|
|
204
|
-
</div>
|
|
205
|
-
</div>
|
|
206
|
-
</div>
|
|
207
|
-
<!-- /container (toolbar + active filters) -->
|
|
208
|
-
|
|
209
|
-
<!-- Desktop: scrollable table -->
|
|
210
|
-
<!-- `-mx-4` lets the table extend edge-to-edge of the parent's px-4 wrapper
|
|
211
|
-
(used in both ResourceExplorerViewer's TabPanel and pages/explore.vue),
|
|
212
|
-
while the toolbar and active-filter rows above keep that padding. -->
|
|
213
|
-
<div
|
|
214
|
-
ref="scrollContainer"
|
|
215
|
-
class="hidden md:block overflow-auto max-h-[70vh] -mx-4"
|
|
216
|
-
>
|
|
217
|
-
<table class="text-sm border-collapse">
|
|
218
|
-
<thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
219
|
-
<tr class="border-b border-gray-default">
|
|
220
|
-
<th
|
|
221
|
-
v-for="col in displayedColumns"
|
|
222
|
-
:key="col"
|
|
223
|
-
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"
|
|
224
|
-
:style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
225
|
-
>
|
|
226
|
-
<div class="flex items-center gap-0.5 min-w-0">
|
|
227
|
-
<span
|
|
228
|
-
class="font-extrabold text-sm truncate"
|
|
229
|
-
:title="col"
|
|
230
|
-
>{{ col }}</span>
|
|
231
|
-
<RiArrowUpLine
|
|
232
|
-
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
233
|
-
class="size-3.5 shrink-0 text-new-primary"
|
|
234
|
-
aria-hidden="true"
|
|
235
|
-
/>
|
|
236
|
-
<RiArrowDownLine
|
|
237
|
-
v-else-if="sort?.column === col && sort.direction === 'desc'"
|
|
238
|
-
class="size-3.5 shrink-0 text-new-primary"
|
|
239
|
-
aria-hidden="true"
|
|
240
|
-
/>
|
|
241
|
-
<TabularFilterPopover
|
|
242
|
-
v-model:sort="sort"
|
|
243
|
-
v-model:filters="filters"
|
|
244
|
-
class="mt-1"
|
|
245
|
-
:column="col"
|
|
246
|
-
:column-type="getColumnType(col)"
|
|
247
|
-
:column-profile="getColumnProfile(col)"
|
|
248
|
-
:null-percent="getNullPercent(col)"
|
|
249
|
-
:total-lines="totalLines"
|
|
250
|
-
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
251
|
-
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
252
|
-
/>
|
|
253
|
-
</div>
|
|
254
|
-
<!-- Column type -->
|
|
255
|
-
<span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1">
|
|
256
|
-
<component
|
|
257
|
-
:is="getTypeConfig(col).icon"
|
|
258
|
-
class="size-3"
|
|
259
|
-
aria-hidden="true"
|
|
260
|
-
/>
|
|
261
|
-
<span class="mt-px">{{ getTypeConfig(col).label }}</span>
|
|
262
|
-
</span>
|
|
263
|
-
<!-- Resize handle: wide hit zone, thin visible bar -->
|
|
264
|
-
<div
|
|
265
|
-
class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
|
|
266
|
-
:class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
|
|
267
|
-
@mousedown.prevent="startResize(col, $event)"
|
|
268
|
-
>
|
|
269
|
-
<div
|
|
270
|
-
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"
|
|
271
|
-
:class="{ '!opacity-100': resizing?.column === col }"
|
|
272
|
-
/>
|
|
273
|
-
</div>
|
|
274
|
-
</th>
|
|
275
|
-
</tr>
|
|
276
|
-
</thead>
|
|
277
|
-
<tbody>
|
|
278
|
-
<tr v-if="allRows.length === 0">
|
|
279
|
-
<td
|
|
280
|
-
:colspan="displayedColumns.length"
|
|
281
|
-
class="py-16 text-center"
|
|
282
|
-
>
|
|
283
|
-
<div class="flex flex-col items-center gap-2">
|
|
284
|
-
<RiSearchLine
|
|
285
|
-
class="size-8 text-gray-low"
|
|
286
|
-
aria-hidden="true"
|
|
287
|
-
/>
|
|
288
|
-
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
289
|
-
</div>
|
|
290
|
-
</td>
|
|
291
|
-
</tr>
|
|
292
|
-
<tr
|
|
293
|
-
v-for="(row, i) in allRows"
|
|
294
|
-
:key="row.__id ?? i"
|
|
295
|
-
class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
|
|
296
|
-
>
|
|
297
|
-
<td
|
|
298
|
-
v-for="col in displayedColumns"
|
|
299
|
-
:key="col"
|
|
300
|
-
data-cell
|
|
301
|
-
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"
|
|
302
|
-
:class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' }"
|
|
303
|
-
:style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
304
|
-
@click="onCellClick(col, row[col], $event)"
|
|
305
|
-
>
|
|
306
|
-
<TabularCell
|
|
307
|
-
:value="row[col]"
|
|
308
|
-
:column-type="getColumnType(col)"
|
|
309
|
-
:category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
|
|
310
|
-
/>
|
|
311
|
-
</td>
|
|
312
|
-
</tr>
|
|
313
|
-
</tbody>
|
|
314
|
-
</table>
|
|
315
|
-
<InfiniteLoader
|
|
316
|
-
v-if="hasMore"
|
|
317
|
-
:root="scrollContainerRef"
|
|
318
|
-
@intersect="loadNextPage"
|
|
319
|
-
/>
|
|
320
|
-
</div>
|
|
321
|
-
|
|
322
|
-
<!-- Cell popover -->
|
|
323
|
-
<TabularCellPopover
|
|
324
|
-
v-model:cell="activeCell"
|
|
325
|
-
v-model:filters="filters"
|
|
326
|
-
/>
|
|
327
|
-
|
|
328
|
-
<!-- Mobile: card layout -->
|
|
329
|
-
<div class="md:hidden space-y-2 px-1">
|
|
330
|
-
<div
|
|
331
|
-
v-if="allRows.length === 0"
|
|
332
|
-
class="py-16 text-center"
|
|
333
|
-
>
|
|
334
|
-
<div class="flex flex-col items-center gap-2">
|
|
335
|
-
<RiSearchLine
|
|
336
|
-
class="size-8 text-gray-low"
|
|
337
|
-
aria-hidden="true"
|
|
338
|
-
/>
|
|
339
|
-
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
340
|
-
</div>
|
|
341
|
-
</div>
|
|
342
|
-
<div
|
|
343
|
-
v-for="(row, i) in allRows"
|
|
344
|
-
:key="row.__id ?? i"
|
|
345
|
-
class="border border-gray-default rounded-lg p-3 space-y-2"
|
|
346
|
-
:class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
|
|
347
|
-
>
|
|
348
|
-
<div
|
|
349
|
-
v-for="col in mobileVisibleFields(i)"
|
|
350
|
-
:key="col"
|
|
351
|
-
class="flex flex-col gap-0.5 min-w-0"
|
|
352
|
-
>
|
|
353
|
-
<div class="flex items-center gap-1 min-w-0">
|
|
354
|
-
<component
|
|
355
|
-
:is="getTypeConfig(col).icon"
|
|
356
|
-
class="size-3 text-gray-low shrink-0"
|
|
357
|
-
aria-hidden="true"
|
|
358
|
-
/>
|
|
359
|
-
<span
|
|
360
|
-
class="text-xs text-gray-plain truncate"
|
|
361
|
-
:title="col"
|
|
362
|
-
>{{ col }}</span>
|
|
363
|
-
</div>
|
|
364
|
-
<div
|
|
365
|
-
data-cell
|
|
366
|
-
class="min-w-0 pl-4 cursor-pointer"
|
|
367
|
-
@click="onCellClick(col, row[col], $event)"
|
|
368
|
-
>
|
|
369
|
-
<TabularCell
|
|
370
|
-
:value="row[col]"
|
|
371
|
-
:column-type="getColumnType(col)"
|
|
372
|
-
:category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
|
|
373
|
-
compact
|
|
374
|
-
/>
|
|
375
|
-
</div>
|
|
376
|
-
</div>
|
|
377
|
-
<button
|
|
378
|
-
v-if="displayedColumns.length > 4"
|
|
379
|
-
class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
|
|
380
|
-
@click="toggleMobileExpand(i)"
|
|
381
|
-
>
|
|
382
|
-
<RiArrowDownSLine
|
|
383
|
-
class="size-3 transition-transform"
|
|
384
|
-
:class="{ 'rotate-180': mobileExpandedRows.has(i) }"
|
|
385
|
-
aria-hidden="true"
|
|
386
|
-
/>
|
|
387
|
-
{{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
|
|
388
|
-
</button>
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
391
|
-
|
|
392
|
-
<!-- Mobile: filter bottom sheet -->
|
|
393
|
-
<TransitionRoot
|
|
394
|
-
:show="mobileFilterOpen"
|
|
395
|
-
as="template"
|
|
396
|
-
>
|
|
397
|
-
<Dialog
|
|
398
|
-
class="relative z-[900]"
|
|
399
|
-
@close="mobileFilterOpen = false"
|
|
400
|
-
>
|
|
401
|
-
<!-- Backdrop -->
|
|
402
|
-
<TransitionChild
|
|
403
|
-
as="template"
|
|
404
|
-
enter="ease-out duration-300"
|
|
405
|
-
enter-from="opacity-0"
|
|
406
|
-
enter-to="opacity-100"
|
|
407
|
-
leave="ease-in duration-200"
|
|
408
|
-
leave-from="opacity-100"
|
|
409
|
-
leave-to="opacity-0"
|
|
410
|
-
>
|
|
411
|
-
<div class="fixed inset-0 bg-black/30" />
|
|
412
|
-
</TransitionChild>
|
|
413
|
-
<!-- Panel -->
|
|
414
|
-
<TransitionChild
|
|
415
|
-
as="template"
|
|
416
|
-
enter="ease-out duration-300"
|
|
417
|
-
enter-from="translate-y-full"
|
|
418
|
-
enter-to="translate-y-0"
|
|
419
|
-
leave="ease-in duration-200"
|
|
420
|
-
leave-from="translate-y-0"
|
|
421
|
-
leave-to="translate-y-full"
|
|
422
|
-
>
|
|
423
|
-
<div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
|
|
424
|
-
<!-- Header -->
|
|
425
|
-
<DialogPanel class="flex flex-col max-h-[80vh]">
|
|
426
|
-
<div class="px-4 pt-4 pb-2 border-b border-gray-default">
|
|
427
|
-
<DialogTitle class="text-sm font-bold">
|
|
428
|
-
{{ t('Filtres & tri par colonne') }}
|
|
429
|
-
</DialogTitle>
|
|
430
|
-
<p class="text-xs text-gray-low">
|
|
431
|
-
{{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
|
|
432
|
-
</p>
|
|
433
|
-
</div>
|
|
434
|
-
<!-- Column list -->
|
|
435
|
-
<div class="flex-1 overflow-y-auto">
|
|
436
|
-
<div
|
|
437
|
-
v-for="col in allColumns"
|
|
438
|
-
:key="col"
|
|
439
|
-
class="border-b border-gray-default last:border-b-0"
|
|
440
|
-
>
|
|
441
|
-
<button
|
|
442
|
-
class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
|
|
443
|
-
:class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
|
|
444
|
-
@click="toggleMobileFilterColumn(col)"
|
|
445
|
-
>
|
|
446
|
-
<component
|
|
447
|
-
:is="getTypeConfig(col).icon"
|
|
448
|
-
class="size-3.5 text-gray-low shrink-0"
|
|
449
|
-
aria-hidden="true"
|
|
450
|
-
/>
|
|
451
|
-
<span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
|
|
452
|
-
<RiArrowUpLine
|
|
453
|
-
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
454
|
-
class="size-3 text-new-primary shrink-0"
|
|
455
|
-
aria-hidden="true"
|
|
456
|
-
/>
|
|
457
|
-
<RiArrowDownLine
|
|
458
|
-
v-if="sort?.column === col && sort.direction === 'desc'"
|
|
459
|
-
class="size-3 text-new-primary shrink-0"
|
|
460
|
-
aria-hidden="true"
|
|
461
|
-
/>
|
|
462
|
-
<span
|
|
463
|
-
v-if="hasFilterForColumn(col)"
|
|
464
|
-
class="size-2 rounded-full bg-new-primary shrink-0"
|
|
465
|
-
/>
|
|
466
|
-
<RiArrowDownSLine
|
|
467
|
-
class="size-3.5 text-gray-low shrink-0 transition-transform"
|
|
468
|
-
:class="{ 'rotate-180': mobileFilterExpandedCol === col }"
|
|
469
|
-
aria-hidden="true"
|
|
470
|
-
/>
|
|
471
|
-
</button>
|
|
472
|
-
<div
|
|
473
|
-
v-if="mobileFilterExpandedCol === col"
|
|
474
|
-
class="pb-1"
|
|
475
|
-
>
|
|
476
|
-
<TabularFilterContent
|
|
477
|
-
v-model:sort="sort"
|
|
478
|
-
v-model:filters="filters"
|
|
479
|
-
:column="col"
|
|
480
|
-
:column-type="getColumnType(col)"
|
|
481
|
-
:column-profile="getColumnProfile(col)"
|
|
482
|
-
:null-percent="getNullPercent(col)"
|
|
483
|
-
:total-lines="totalLines"
|
|
484
|
-
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
485
|
-
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
486
|
-
/>
|
|
487
|
-
</div>
|
|
488
|
-
</div>
|
|
489
|
-
</div>
|
|
490
|
-
<!-- Reset all -->
|
|
491
|
-
<div
|
|
492
|
-
v-if="activeFilters.length > 0 || sort"
|
|
493
|
-
class="border-t border-gray-default px-4 py-3"
|
|
494
|
-
>
|
|
495
|
-
<BrandedButton
|
|
496
|
-
color="secondary"
|
|
497
|
-
size="xs"
|
|
498
|
-
class="w-full"
|
|
499
|
-
@click="clearAllFilters(); sort = null; mobileFilterOpen = false"
|
|
500
|
-
>
|
|
501
|
-
{{ t('Tout réinitialiser') }}
|
|
502
|
-
</BrandedButton>
|
|
503
|
-
</div>
|
|
504
|
-
</DialogPanel>
|
|
505
|
-
</div>
|
|
506
|
-
</TransitionChild>
|
|
507
|
-
</Dialog>
|
|
508
|
-
</TransitionRoot>
|
|
16
|
+
<slot />
|
|
509
17
|
</template>
|
|
510
18
|
</div>
|
|
511
19
|
</template>
|
|
512
20
|
|
|
513
21
|
<script setup lang="ts">
|
|
514
|
-
import { computed,
|
|
22
|
+
import { computed, ref, watch } from 'vue'
|
|
515
23
|
import { ofetch } from 'ofetch'
|
|
516
|
-
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
517
|
-
import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
518
|
-
import {
|
|
519
|
-
RiLayoutColumnLine,
|
|
520
|
-
RiLayoutRowLine,
|
|
521
|
-
RiArrowDownSLine,
|
|
522
|
-
RiArrowUpLine,
|
|
523
|
-
RiArrowDownLine,
|
|
524
|
-
RiFilter2Line,
|
|
525
|
-
RiCloseLine,
|
|
526
|
-
RiSearchLine,
|
|
527
|
-
} from '@remixicon/vue'
|
|
528
24
|
import { useFetch } from '../../functions/api'
|
|
529
25
|
import { useComponentsConfig } from '../../config'
|
|
530
26
|
import { useTranslation } from '../../composables/useTranslation'
|
|
531
27
|
import { injectTabularProfile } from '../../composables/useTabularProfile'
|
|
532
|
-
import {
|
|
533
|
-
import
|
|
534
|
-
import
|
|
535
|
-
import
|
|
536
|
-
import
|
|
537
|
-
import
|
|
538
|
-
import TabularCellPopover from './TabularCellPopover.vue'
|
|
539
|
-
import type { CellInfo } from './TabularCellPopover.vue'
|
|
540
|
-
import TabularFilterContent from './TabularFilterContent.vue'
|
|
541
|
-
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
542
|
-
import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
|
|
28
|
+
import { hasFilterForColumn as _hasFilterForColumn, buildDateFilterParams, buildGlobalSearchConditions, useFormatTabular } from '../../functions/tabular'
|
|
29
|
+
import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
|
|
30
|
+
import TabularSkeleton from './TabularSkeleton.vue'
|
|
31
|
+
import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters, DateFilter } from './types'
|
|
32
|
+
import { provideTabularContext, type ActiveFilter } from './useTabularContext'
|
|
33
|
+
import { useColumnMetadata } from './useColumnMetadata'
|
|
543
34
|
|
|
544
35
|
const props = defineProps<{
|
|
545
36
|
resourceId: string
|
|
37
|
+
// When set, searches across multiple columns using the Tabular API's or(...)
|
|
38
|
+
// parameter. Text and categorical columns get a __contains filter; number
|
|
39
|
+
// columns get a __exact filter (since __contains is not supported for numbers
|
|
40
|
+
// by the API). Year, date and boolean columns are excluded.
|
|
41
|
+
// Note: combined via AND with any existing column-specific `contains` filters,
|
|
42
|
+
// so it acts as an additional narrowing constraint, not a replacement.
|
|
43
|
+
globalSearch?: string
|
|
44
|
+
// Filters seeded on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
|
|
45
|
+
// The explorer owns them afterwards: later changes to this prop are ignored,
|
|
46
|
+
// so pass a fresh instance (or remount) to reset them.
|
|
47
|
+
initialFilters?: Record<string, ColumnFilters>
|
|
48
|
+
// Sort seeded on mount, e.g. { column: 'Séance', direction: 'desc' }.
|
|
49
|
+
// Same ownership rule as `initialFilters`: the explorer owns it afterwards,
|
|
50
|
+
// and the user can drop it from the active-sort chip.
|
|
51
|
+
initialSort?: SortConfig
|
|
546
52
|
}>()
|
|
547
53
|
|
|
548
54
|
const { t } = useTranslation()
|
|
549
55
|
const config = useComponentsConfig()
|
|
550
56
|
|
|
551
|
-
// Column selector popover positioning
|
|
552
|
-
const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
|
|
553
|
-
const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
|
|
554
|
-
const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
|
|
555
|
-
const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
|
|
556
|
-
|
|
557
|
-
const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
|
|
558
|
-
placement: 'bottom-start',
|
|
559
|
-
middleware: [flip(), shift()],
|
|
560
|
-
whileElementsMounted: autoUpdate,
|
|
561
|
-
})
|
|
562
|
-
|
|
563
57
|
const dataUrl = computed(() =>
|
|
564
58
|
`${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
|
|
565
59
|
)
|
|
566
60
|
|
|
61
|
+
// Profile is shared with sibling components (e.g. DataStructure) via
|
|
62
|
+
// `provideTabularProfile` in the parent. Falls back to a local fetch
|
|
63
|
+
// when no parent provides it (standalone usage).
|
|
64
|
+
// Fetched before the rows: `dataQuery` reads the column types to build the
|
|
65
|
+
// global search, so the profile has to be resolved by the time it is evaluated.
|
|
66
|
+
const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
|
|
67
|
+
|
|
68
|
+
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
|
|
69
|
+
|
|
70
|
+
// Column metadata (display type, label/icon, null ratio, boolean counts) — pure
|
|
71
|
+
// derivations of the profile, extracted to keep this component focused on data
|
|
72
|
+
// fetching, pagination and filter/column state.
|
|
73
|
+
const {
|
|
74
|
+
columnTypesMap,
|
|
75
|
+
getColumnType,
|
|
76
|
+
getColumnProfile,
|
|
77
|
+
getColumnDisplay,
|
|
78
|
+
getNullPercent,
|
|
79
|
+
getBooleanCounts,
|
|
80
|
+
} = useColumnMetadata(profileData, allColumns, t)
|
|
81
|
+
|
|
567
82
|
// Sort & filter state
|
|
568
|
-
const sort = ref<SortConfig | null>(null)
|
|
569
|
-
const filters = ref<Record<string, ColumnFilters>>({})
|
|
83
|
+
const sort = ref<SortConfig | null>(props.initialSort ? { ...props.initialSort } : null)
|
|
84
|
+
const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
|
|
570
85
|
|
|
571
86
|
const PAGE_SIZE = 50
|
|
572
87
|
|
|
@@ -597,16 +112,18 @@ const dataQuery = computed(() => {
|
|
|
597
112
|
else if (filter.null === 'exclude') {
|
|
598
113
|
q[`${col}__isnotnull`] = ''
|
|
599
114
|
}
|
|
115
|
+
if (filter.date) {
|
|
116
|
+
Object.assign(q, buildDateFilterParams(col, filter.date))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (props.globalSearch && profileData.value?.profile) {
|
|
120
|
+
const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
|
|
121
|
+
q.or = '(' + conditions.join(',') + ')'
|
|
600
122
|
}
|
|
601
123
|
return q
|
|
602
124
|
})
|
|
603
125
|
|
|
604
|
-
const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
|
|
605
|
-
|
|
606
|
-
// Profile is shared with sibling components (e.g. DataStructure) via
|
|
607
|
-
// `provideTabularProfile` in the parent. Falls back to a local fetch
|
|
608
|
-
// when no parent provides it (standalone usage).
|
|
609
|
-
const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
|
|
126
|
+
const { data: tableData, error, status: dataStatus } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
|
|
610
127
|
|
|
611
128
|
// The component renders nothing useful until the profile is available
|
|
612
129
|
// (allColumns is derived from it). Surface a clear loading / error state
|
|
@@ -614,6 +131,9 @@ const { data: profileData, error: profileError, status: profileStatus } = await
|
|
|
614
131
|
const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
|
|
615
132
|
const previewError = computed(() => error.value || profileError.value)
|
|
616
133
|
const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
|
|
134
|
+
// A search / filter / sort change refetches while the previous rows stay on
|
|
135
|
+
// screen: without a signal, the table looks unchanged for several seconds.
|
|
136
|
+
const isRefreshing = computed(() => dataStatus.value === 'pending' && !previewLoading.value)
|
|
617
137
|
|
|
618
138
|
// Infinite scroll state
|
|
619
139
|
const allRows = ref<TabularRow[]>([])
|
|
@@ -621,7 +141,6 @@ const currentPage = ref(1)
|
|
|
621
141
|
const hasMore = ref(false)
|
|
622
142
|
const loadingMore = ref(false)
|
|
623
143
|
const generation = ref(0)
|
|
624
|
-
const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
|
|
625
144
|
|
|
626
145
|
watch(() => tableData.value, (data) => {
|
|
627
146
|
generation.value++
|
|
@@ -653,8 +172,6 @@ async function loadNextPage() {
|
|
|
653
172
|
|
|
654
173
|
const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
|
|
655
174
|
|
|
656
|
-
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
|
|
657
|
-
|
|
658
175
|
const visibleColumns = ref(new Set(allColumns.value))
|
|
659
176
|
|
|
660
177
|
watch(allColumns, (cols) => {
|
|
@@ -674,7 +191,7 @@ const hiddenCount = computed(() =>
|
|
|
674
191
|
function toggleColumn(col: string) {
|
|
675
192
|
const next = new Set(visibleColumns.value)
|
|
676
193
|
if (next.has(col)) {
|
|
677
|
-
|
|
194
|
+
next.delete(col)
|
|
678
195
|
}
|
|
679
196
|
else {
|
|
680
197
|
next.add(col)
|
|
@@ -686,121 +203,30 @@ function showAllColumns() {
|
|
|
686
203
|
visibleColumns.value = new Set(allColumns.value)
|
|
687
204
|
}
|
|
688
205
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
// Strategy:
|
|
692
|
-
// - On first render (when both data and profile are loaded), we measure the
|
|
693
|
-
// natural offsetWidth of each <th>, then distribute any leftover container
|
|
694
|
-
// space proportionally so the table fills the available horizontal area.
|
|
695
|
-
// - We immediately lock all columns to those widths via `columnWidths`, so
|
|
696
|
-
// pagination, filtering, sorting or infinite-scroll never trigger a layout
|
|
697
|
-
// shift (the table doesn't re-auto-size based on newly visible content).
|
|
698
|
-
// - Manual resize via the drag handle keeps working unchanged: it always sees
|
|
699
|
-
// columnWidths populated, so it goes straight into the resize loop.
|
|
700
|
-
const columnWidths = ref<Record<string, number>>({})
|
|
701
|
-
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
702
|
-
|
|
703
|
-
function measureAndDistributeColumnWidths() {
|
|
704
|
-
const container = scrollContainerRef.value
|
|
705
|
-
if (!container) return
|
|
706
|
-
const ths = container.querySelectorAll('th')
|
|
707
|
-
if (!ths || ths.length === 0) return
|
|
708
|
-
|
|
709
|
-
const naturalWidths: Record<string, number> = {}
|
|
710
|
-
let naturalSum = 0
|
|
711
|
-
ths.forEach((th, i) => {
|
|
712
|
-
const col = displayedColumns.value[i]
|
|
713
|
-
if (!col) return
|
|
714
|
-
const w = th.offsetWidth
|
|
715
|
-
naturalWidths[col] = w
|
|
716
|
-
naturalSum += w
|
|
717
|
-
})
|
|
718
|
-
if (naturalSum === 0) return
|
|
719
|
-
|
|
720
|
-
// Inflate to fill container if columns don't naturally cover it.
|
|
721
|
-
const containerWidth = container.clientWidth
|
|
722
|
-
const ratio = naturalSum < containerWidth ? containerWidth / naturalSum : 1
|
|
723
|
-
|
|
724
|
-
const widths: Record<string, number> = {}
|
|
725
|
-
for (const [col, w] of Object.entries(naturalWidths)) {
|
|
726
|
-
widths[col] = Math.floor(w * ratio)
|
|
727
|
-
}
|
|
728
|
-
columnWidths.value = widths
|
|
206
|
+
function hideAllColumns() {
|
|
207
|
+
visibleColumns.value = new Set()
|
|
729
208
|
}
|
|
730
209
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
// (pagination, filters) keep the locked widths and avoid layout shifts.
|
|
734
|
-
watch(
|
|
735
|
-
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
736
|
-
async ([dataReady, colCount]) => {
|
|
737
|
-
if (!dataReady || colCount === 0) return
|
|
738
|
-
if (Object.keys(columnWidths.value).length > 0) return
|
|
739
|
-
await nextTick()
|
|
740
|
-
measureAndDistributeColumnWidths()
|
|
741
|
-
},
|
|
742
|
-
{ immediate: true },
|
|
743
|
-
)
|
|
744
|
-
|
|
745
|
-
function startResize(col: string, e: MouseEvent) {
|
|
746
|
-
// Fallback in case the user grabs the handle before the initial measure ran.
|
|
747
|
-
if (!Object.keys(columnWidths.value).length) measureAndDistributeColumnWidths()
|
|
748
|
-
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
749
|
-
// Disable smooth scroll during resize
|
|
750
|
-
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
751
|
-
document.addEventListener('mousemove', onResize)
|
|
752
|
-
document.addEventListener('mouseup', stopResize)
|
|
210
|
+
function selectOnlyColumn(col: string) {
|
|
211
|
+
visibleColumns.value = new Set([col])
|
|
753
212
|
}
|
|
754
213
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
214
|
+
const { formatCellDate } = useFormatTabular()
|
|
215
|
+
|
|
216
|
+
function describeDateFilter(filter: DateFilter): string {
|
|
217
|
+
switch (filter.operator) {
|
|
218
|
+
case 'is': return `= ${formatCellDate(filter.start)}`
|
|
219
|
+
case 'before': return `${t('avant le')} ${formatCellDate(filter.start)}`
|
|
220
|
+
case 'after': return `${t('après le')} ${formatCellDate(filter.start)}`
|
|
221
|
+
// An open-ended range is what `dateFilterBounds` sends, so it is what the chip
|
|
222
|
+
// says: repeating the separator as a missing end reads as three dashes in a row
|
|
223
|
+
case 'between': return filter.end
|
|
224
|
+
? `${formatCellDate(filter.start)} – ${formatCellDate(filter.end)}`
|
|
225
|
+
: `${t('à partir du')} ${formatCellDate(filter.start)}`
|
|
767
226
|
}
|
|
768
|
-
const delta = e.clientX - resizing.value.startX
|
|
769
|
-
const newWidth = Math.max(60, resizing.value.startWidth + delta)
|
|
770
|
-
columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
function stopResize() {
|
|
774
|
-
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
|
|
775
|
-
resizing.value = null
|
|
776
|
-
document.removeEventListener('mousemove', onResize)
|
|
777
|
-
document.removeEventListener('mouseup', stopResize)
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
onUnmounted(() => {
|
|
781
|
-
document.removeEventListener('mousemove', onResize)
|
|
782
|
-
document.removeEventListener('mouseup', stopResize)
|
|
783
|
-
})
|
|
784
|
-
|
|
785
|
-
// Cell popover
|
|
786
|
-
const activeCell = ref<CellInfo | null>(null)
|
|
787
|
-
|
|
788
|
-
function onCellClick(col: string, value: unknown, event: MouseEvent) {
|
|
789
|
-
const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
|
|
790
|
-
if (!el) return
|
|
791
|
-
if (activeCell.value && activeCell.value.element === el) {
|
|
792
|
-
activeCell.value = null
|
|
793
|
-
return
|
|
794
|
-
}
|
|
795
|
-
activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
|
|
796
227
|
}
|
|
797
228
|
|
|
798
229
|
// Active filters
|
|
799
|
-
interface ActiveFilter {
|
|
800
|
-
column: string
|
|
801
|
-
label: string
|
|
802
|
-
}
|
|
803
|
-
|
|
804
230
|
const activeFilters = computed<ActiveFilter[]>(() => {
|
|
805
231
|
const result: ActiveFilter[] = []
|
|
806
232
|
for (const [col, filter] of Object.entries(filters.value)) {
|
|
@@ -809,7 +235,12 @@ const activeFilters = computed<ActiveFilter[]>(() => {
|
|
|
809
235
|
parts.push(`= ${filter.in.join(', ')}`)
|
|
810
236
|
}
|
|
811
237
|
if (filter.exact != null) {
|
|
812
|
-
|
|
238
|
+
if (getColumnType(col) === 'boolean') {
|
|
239
|
+
parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
parts.push(`= ${filter.exact}`)
|
|
243
|
+
}
|
|
813
244
|
}
|
|
814
245
|
if (filter.contains) {
|
|
815
246
|
parts.push(`${t('contient')} "${filter.contains}"`)
|
|
@@ -829,6 +260,9 @@ const activeFilters = computed<ActiveFilter[]>(() => {
|
|
|
829
260
|
else if (filter.max != null) {
|
|
830
261
|
parts.push(`≤ ${filter.max}`)
|
|
831
262
|
}
|
|
263
|
+
if (filter.date) {
|
|
264
|
+
parts.push(describeDateFilter(filter.date))
|
|
265
|
+
}
|
|
832
266
|
if (parts.length) {
|
|
833
267
|
result.push({ column: col, label: parts.join(', ') })
|
|
834
268
|
}
|
|
@@ -849,125 +283,38 @@ function hasFilterForColumn(col: string): boolean {
|
|
|
849
283
|
return _hasFilterForColumn(filters.value, col)
|
|
850
284
|
}
|
|
851
285
|
|
|
852
|
-
//
|
|
286
|
+
// Whether the mobile filter sheet is open (button lives in the toolbar).
|
|
853
287
|
const mobileFilterOpen = ref(false)
|
|
854
|
-
const mobileExpandedRows = ref(new Set<number>())
|
|
855
|
-
const mobileFilterExpandedCol = ref<string | null>(null)
|
|
856
|
-
|
|
857
|
-
function mobileVisibleFields(index: number): string[] {
|
|
858
|
-
if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
|
|
859
|
-
return displayedColumns.value
|
|
860
|
-
}
|
|
861
|
-
return displayedColumns.value.slice(0, 4)
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
function toggleMobileExpand(index: number) {
|
|
865
|
-
const next = new Set(mobileExpandedRows.value)
|
|
866
|
-
if (next.has(index)) next.delete(index)
|
|
867
|
-
else next.add(index)
|
|
868
|
-
mobileExpandedRows.value = next
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
function toggleMobileFilterColumn(col: string) {
|
|
872
|
-
mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Column type helpers
|
|
876
|
-
function resolveColumnType(col: string): ColumnType {
|
|
877
|
-
const profile = profileData.value?.profile
|
|
878
|
-
if (!profile) return 'text'
|
|
879
|
-
const colInfo = profile.columns[col]
|
|
880
|
-
if (!colInfo) return 'text'
|
|
881
|
-
if (['int', 'float'].includes(colInfo.python_type)) return 'number'
|
|
882
|
-
if (colInfo.format === 'year') return 'date'
|
|
883
|
-
if (['date', 'datetime'].includes(colInfo.python_type)) return 'date'
|
|
884
|
-
if (colInfo.python_type === 'bool') return 'boolean'
|
|
885
|
-
if (profile.categorical.includes(col)) return 'categorical'
|
|
886
|
-
return 'text'
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
const columnTypesMap = computed(() => {
|
|
890
|
-
const map: Record<string, ColumnType> = {}
|
|
891
|
-
for (const col of allColumns.value) {
|
|
892
|
-
map[col] = resolveColumnType(col)
|
|
893
|
-
}
|
|
894
|
-
return map
|
|
895
|
-
})
|
|
896
|
-
|
|
897
|
-
function getColumnType(col: string): ColumnType {
|
|
898
|
-
return columnTypesMap.value[col] ?? 'text'
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
function getColumnProfile(col: string) {
|
|
902
|
-
return profileData.value?.profile?.profile?.[col] ?? null
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
function getTopsEntries(col: string) {
|
|
906
|
-
return getColumnProfile(col)?.tops ?? []
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
function getNullPercent(col: string) {
|
|
910
|
-
const colProfile = getColumnProfile(col)
|
|
911
|
-
const total = profileData.value?.profile?.total_lines
|
|
912
|
-
if (!colProfile || !total) return '0%'
|
|
913
|
-
return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
|
|
914
|
-
}
|
|
915
288
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
return map
|
|
289
|
+
// Provide the shared state so child components (active filters, columns menu, rows
|
|
290
|
+
// info, table…) can inject it instead of receiving a wall of props.
|
|
291
|
+
provideTabularContext({
|
|
292
|
+
tableData,
|
|
293
|
+
totalLines,
|
|
294
|
+
allRows,
|
|
295
|
+
hasMore,
|
|
296
|
+
loadNextPage,
|
|
297
|
+
isRefreshing,
|
|
298
|
+
sort,
|
|
299
|
+
filters,
|
|
300
|
+
activeFilters,
|
|
301
|
+
removeFilter,
|
|
302
|
+
clearAllFilters,
|
|
303
|
+
hasFilterForColumn,
|
|
304
|
+
allColumns,
|
|
305
|
+
visibleColumns,
|
|
306
|
+
displayedColumns,
|
|
307
|
+
hiddenCount,
|
|
308
|
+
toggleColumn,
|
|
309
|
+
showAllColumns,
|
|
310
|
+
hideAllColumns,
|
|
311
|
+
selectOnlyColumn,
|
|
312
|
+
mobileFilterOpen,
|
|
313
|
+
columnTypesMap,
|
|
314
|
+
getColumnType,
|
|
315
|
+
getColumnProfile,
|
|
316
|
+
getColumnDisplay,
|
|
317
|
+
getNullPercent,
|
|
318
|
+
getBooleanCounts,
|
|
947
319
|
})
|
|
948
|
-
|
|
949
|
-
function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
|
|
950
|
-
const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
|
|
951
|
-
return { backgroundColor: colors.bg, color: colors.text }
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
|
|
955
|
-
const styles: Record<string, BadgeStyle> = {}
|
|
956
|
-
for (const top of getTopsEntries(col)) {
|
|
957
|
-
styles[top.value] = getCategoryBadgeStyle(col, top.value)
|
|
958
|
-
}
|
|
959
|
-
return styles
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
|
|
963
|
-
const profile = getColumnProfile(col)
|
|
964
|
-
if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
|
|
965
|
-
let trueCount = 0
|
|
966
|
-
let falseCount = 0
|
|
967
|
-
for (const top of profile.tops) {
|
|
968
|
-
if (isTruthy(top.value ?? '')) trueCount += top.count
|
|
969
|
-
else if (isFalsy(top.value ?? '')) falseCount += top.count
|
|
970
|
-
}
|
|
971
|
-
return { trueCount, falseCount }
|
|
972
|
-
}
|
|
973
320
|
</script>
|