@datagouv/components-next 1.3.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/dist/{Control-b-43UT2B.js → Control-B9WQxlpJ.js} +10 -10
- package/dist/{Datafair.client-BrjISlaZ.js → Datafair.client-D_6yzJUG.js} +9 -9
- package/dist/{Event-py_AAwJY.js → Event-iXDrp-fy.js} +15 -15
- package/dist/{Image-BHQYZWzH.js → Image-mNMBOofs.js} +22 -22
- package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
- package/dist/{JsonPreview.client-DivFAnbN.js → JsonPreview.client-DSl1kiwC.js} +2 -2
- package/dist/{Map-CtP7leHl.js → Map-BfqKMw0S.js} +165 -165
- package/dist/{MapContainer.client-Cq8tNLZZ.js → MapContainer.client-BSfpk_rT.js} +17 -17
- package/dist/{OSM-gdDSIsDA.js → OSM-CcrUhItr.js} +1 -1
- package/dist/{PdfPreview.client-BNBgLsK2.js → PdfPreview.client-BANFeM6K.js} +438 -427
- package/dist/{Pmtiles.client-CQi5Rw8W.js → Pmtiles.client-DH9ZrsZR.js} +6177 -5967
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
- package/dist/{ScaleLine-hG-FhNqy.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-D6rFonpT.js → Tile-CSpn0Ca8.js} +47 -47
- package/dist/{TileImage-cfrEgz5y.js → TileImage-CpbaEFwd.js} +44 -44
- package/dist/{View-vp7vDb9q.js → View-BYBpt-AU.js} +58 -58
- package/dist/{XmlPreview.client-XCsNvnsZ.js → XmlPreview.client-KCB5mwVf.js} +3 -3
- package/dist/{common-C8efyyuc.js → common-BoeTUnDZ.js} +58 -58
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +199 -172
- package/dist/components.css +1 -1
- package/dist/{index-CXUamc8Z.js → index-DHWzX5vK.js} +4668 -4466
- package/dist/{leaflet-src-BRKv9Ksv.js → leaflet-src-pVQfonBI.js} +1 -1
- package/dist/{main-DKu5SOAl.js → main-BDcYkhOc.js} +41684 -33432
- package/dist/{proj-CsjVeojN.js → proj-DxGbobp7.js} +110 -110
- package/dist/{tilecoord-CPcQJnrF.js → tilecoord-DvX7WWiA.js} +94 -94
- package/dist/{vue3-xml-viewer.common-BIaEIFYG.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
- package/package.json +28 -27
- 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 +27 -18
- package/src/components/ResourceAccordion/Preview.vue +8 -4
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +29 -9
- package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -209
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +136 -183
- 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 +12 -8
- 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 +19 -3
- 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 +122 -913
- package/src/components/TabularExplorer/TabularFilterContent.vue +33 -25
- package/src/components/TabularExplorer/TabularFilterPopover.vue +7 -7
- 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 +32 -20
- 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 +47 -39
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +1 -0
- 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 +83 -0
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +119 -4
- 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/reuses.ts +12 -1
- package/src/types/search.ts +2 -2
- package/src/types/visualizations.ts +5 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.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,661 +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>
|
|
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
|
-
(px-4 on ResourceExplorerViewer's TabPanel, .container on
|
|
63
|
-
ExploreResourceView), so they line up with the resource title /
|
|
64
|
-
download button above. Only the table itself goes edge-to-edge
|
|
65
|
-
(via -mx-4 below). -->
|
|
66
|
-
<div>
|
|
67
|
-
<div class="flex items-center py-3 gap-2">
|
|
68
|
-
<!-- Mobile: filter & sort button -->
|
|
69
|
-
<BrandedButton
|
|
70
|
-
class="md:hidden"
|
|
71
|
-
color="tertiary"
|
|
72
|
-
size="2xs"
|
|
73
|
-
:icon="RiFilterLine"
|
|
74
|
-
keep-margins-even-without-borders
|
|
75
|
-
@click="mobileFilterOpen = true"
|
|
76
|
-
>
|
|
77
|
-
{{ t('Filtres & tri') }}
|
|
78
|
-
</BrandedButton>
|
|
79
|
-
|
|
80
|
-
<div class="flex-1" />
|
|
81
|
-
|
|
82
|
-
<!-- Right: Stats -->
|
|
83
|
-
<div class="flex items-center gap-4">
|
|
84
|
-
<!-- Columns (clickable, opens column popover) -->
|
|
85
|
-
<Popover
|
|
86
|
-
ref="columnAnchor"
|
|
87
|
-
v-slot="{ open }"
|
|
88
|
-
class="relative"
|
|
89
|
-
>
|
|
90
|
-
<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">
|
|
91
|
-
<RiLayoutColumnLine
|
|
92
|
-
class="size-3"
|
|
93
|
-
aria-hidden="true"
|
|
94
|
-
/>
|
|
95
|
-
<span class="font-bold hidden md:inline">{{ t('Colonnes') }}</span>
|
|
96
|
-
<span class="font-mono tabular-nums">{{ visibleColumns.size }}/{{ allColumns.length }}</span>
|
|
97
|
-
<RiArrowDownSLine
|
|
98
|
-
class="size-3 opacity-50"
|
|
99
|
-
aria-hidden="true"
|
|
100
|
-
/>
|
|
101
|
-
</PopoverButton>
|
|
102
|
-
|
|
103
|
-
<ClientOnly>
|
|
104
|
-
<Teleport to="#tooltips">
|
|
105
|
-
<PopoverPanel
|
|
106
|
-
v-show="open"
|
|
107
|
-
ref="columnPanel"
|
|
108
|
-
static
|
|
109
|
-
class="bg-white border border-gray-default rounded shadow-lg w-72 absolute z-[800]"
|
|
110
|
-
:style="columnFloatingStyles"
|
|
111
|
-
>
|
|
112
|
-
<div class="px-3 py-2 border-b border-gray-default space-y-1.5">
|
|
113
|
-
<span class="block text-xs font-medium text-gray-title">
|
|
114
|
-
{{ visibleColumns.size }} {{ t('sur') }} {{ allColumns.length }} {{ t('colonnes visibles') }}
|
|
115
|
-
</span>
|
|
116
|
-
<div class="flex items-center gap-1">
|
|
117
|
-
<BrandedButton
|
|
118
|
-
v-if="hiddenCount"
|
|
119
|
-
color="tertiary"
|
|
120
|
-
size="2xs"
|
|
121
|
-
@click="showAllColumns"
|
|
122
|
-
>
|
|
123
|
-
{{ t('Tout afficher') }}
|
|
124
|
-
</BrandedButton>
|
|
125
|
-
<BrandedButton
|
|
126
|
-
v-if="visibleColumns.size > 0"
|
|
127
|
-
color="tertiary"
|
|
128
|
-
size="2xs"
|
|
129
|
-
@click="hideAllColumns"
|
|
130
|
-
>
|
|
131
|
-
{{ t('Tout masquer') }}
|
|
132
|
-
</BrandedButton>
|
|
133
|
-
</div>
|
|
134
|
-
</div>
|
|
135
|
-
<div class="max-h-64 overflow-auto p-1">
|
|
136
|
-
<div
|
|
137
|
-
v-for="col in allColumns"
|
|
138
|
-
:key="col"
|
|
139
|
-
class="group/col flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 text-xs"
|
|
140
|
-
>
|
|
141
|
-
<label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
|
|
142
|
-
<input
|
|
143
|
-
type="checkbox"
|
|
144
|
-
:checked="visibleColumns.has(col)"
|
|
145
|
-
class="size-3.5 accent-new-primary"
|
|
146
|
-
@change="toggleColumn(col)"
|
|
147
|
-
>
|
|
148
|
-
<span class="truncate">{{ col }}</span>
|
|
149
|
-
</label>
|
|
150
|
-
<button
|
|
151
|
-
class="shrink-0 px-1 text-new-primary hover:underline hidden group-hover/col:block group-focus-within/col:block"
|
|
152
|
-
@click="selectOnlyColumn(col)"
|
|
153
|
-
>
|
|
154
|
-
{{ t('Uniquement') }}
|
|
155
|
-
</button>
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
</PopoverPanel>
|
|
159
|
-
</Teleport>
|
|
160
|
-
</ClientOnly>
|
|
161
|
-
</Popover>
|
|
162
|
-
|
|
163
|
-
<!-- Rows -->
|
|
164
|
-
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
165
|
-
<RiLayoutRowLine
|
|
166
|
-
class="size-3 text-mention-grey"
|
|
167
|
-
aria-hidden="true"
|
|
168
|
-
/>
|
|
169
|
-
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
170
|
-
<span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
171
|
-
</span>
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
|
|
175
|
-
<!-- Active filters & sort -->
|
|
176
|
-
<div
|
|
177
|
-
v-if="activeFilters.length > 0 || sort"
|
|
178
|
-
class="bg-gray-some border border-gray-default rounded-xl px-3 py-2.5 mb-3 space-y-2.5"
|
|
179
|
-
>
|
|
180
|
-
<!-- Header -->
|
|
181
|
-
<div class="flex items-center justify-between">
|
|
182
|
-
<div class="flex items-center gap-2">
|
|
183
|
-
<RiFilterLine
|
|
184
|
-
class="size-3.5"
|
|
185
|
-
aria-hidden="true"
|
|
186
|
-
/>
|
|
187
|
-
<span class="text-xs text-gray-plain">{{ activeFilters.length > 0 ? t('Filtres actifs') : t('Tri actif') }}</span>
|
|
188
|
-
<span
|
|
189
|
-
v-if="activeFilters.length > 0"
|
|
190
|
-
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"
|
|
191
|
-
>
|
|
192
|
-
{{ activeFilters.length }}
|
|
193
|
-
</span>
|
|
194
|
-
</div>
|
|
195
|
-
<BrandedButton
|
|
196
|
-
color="tertiary"
|
|
197
|
-
size="2xs"
|
|
198
|
-
:icon="RiCloseLine"
|
|
199
|
-
@click="clearAllFilters(); sort = null"
|
|
200
|
-
>
|
|
201
|
-
{{ t('Tout effacer') }}
|
|
202
|
-
</BrandedButton>
|
|
203
|
-
</div>
|
|
204
|
-
<!-- Chips -->
|
|
205
|
-
<div class="flex flex-wrap gap-1.5">
|
|
206
|
-
<!-- Sort chip -->
|
|
207
|
-
<span
|
|
208
|
-
v-if="sort"
|
|
209
|
-
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
210
|
-
>
|
|
211
|
-
<component
|
|
212
|
-
:is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
|
|
213
|
-
class="size-3 text-new-primary"
|
|
214
|
-
aria-hidden="true"
|
|
215
|
-
/>
|
|
216
|
-
<span class="text-gray-plain">{{ t('Tri') }}</span>
|
|
217
|
-
<span class="text-gray-title">{{ sort.column }}</span>
|
|
218
|
-
<span class="text-gray-plain">{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
|
|
219
|
-
<button
|
|
220
|
-
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
221
|
-
@click="sort = null"
|
|
222
|
-
>
|
|
223
|
-
<RiCloseLine
|
|
224
|
-
class="size-3"
|
|
225
|
-
aria-hidden="true"
|
|
226
|
-
/>
|
|
227
|
-
<span class="sr-only">{{ t('Supprimer le tri') }}</span>
|
|
228
|
-
</button>
|
|
229
|
-
</span>
|
|
230
|
-
<!-- Filter chips -->
|
|
231
|
-
<span
|
|
232
|
-
v-for="af in activeFilters"
|
|
233
|
-
:key="af.column"
|
|
234
|
-
class="inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
|
|
235
|
-
>
|
|
236
|
-
<component
|
|
237
|
-
:is="getColumnDisplay(af.column).icon"
|
|
238
|
-
class="size-3 text-gray-title"
|
|
239
|
-
aria-hidden="true"
|
|
240
|
-
/>
|
|
241
|
-
<span class="text-gray-title">{{ af.column }}</span>
|
|
242
|
-
<span class="text-gray-plain">{{ af.label }}</span>
|
|
243
|
-
<button
|
|
244
|
-
class="rounded p-0.5 text-gray-low hover:text-gray-plain hover:bg-gray-100"
|
|
245
|
-
@click="removeFilter(af.column)"
|
|
246
|
-
>
|
|
247
|
-
<RiCloseLine
|
|
248
|
-
class="size-3"
|
|
249
|
-
aria-hidden="true"
|
|
250
|
-
/>
|
|
251
|
-
<span class="sr-only">{{ t('Supprimer ce filtre') }}</span>
|
|
252
|
-
</button>
|
|
253
|
-
</span>
|
|
254
|
-
</div>
|
|
255
|
-
</div>
|
|
256
|
-
</div>
|
|
257
|
-
<!-- /toolbar & active filters -->
|
|
258
|
-
|
|
259
|
-
<!-- No column selected -->
|
|
260
|
-
<div
|
|
261
|
-
v-if="displayedColumns.length === 0"
|
|
262
|
-
class="flex flex-col items-center py-12"
|
|
263
|
-
>
|
|
264
|
-
<img
|
|
265
|
-
:src="noColumnsSrc"
|
|
266
|
-
class="h-32"
|
|
267
|
-
alt=""
|
|
268
|
-
>
|
|
269
|
-
<p class="font-bold text-gray-title my-3">
|
|
270
|
-
{{ t('Aucune colonne sélectionnée') }}
|
|
271
|
-
</p>
|
|
272
|
-
<BrandedButton
|
|
273
|
-
color="secondary"
|
|
274
|
-
size="xs"
|
|
275
|
-
@click="showAllColumns"
|
|
276
|
-
>
|
|
277
|
-
{{ t('Afficher toutes les colonnes') }}
|
|
278
|
-
</BrandedButton>
|
|
279
|
-
</div>
|
|
280
|
-
|
|
281
|
-
<!-- Desktop: scrollable table -->
|
|
282
|
-
<!-- The table goes edge-to-edge while the toolbar / active-filter rows
|
|
283
|
-
above keep the host's horizontal padding:
|
|
284
|
-
- default: `-mx-4` cancels the host's 1rem padding (px-4 on
|
|
285
|
-
ResourceExplorerViewer's TabPanel), so the table reaches the
|
|
286
|
-
host box edge.
|
|
287
|
-
- `fullBleed`: the table breaks out of the (max-width) container to
|
|
288
|
-
span almost the whole viewport (explore page), keeping a 2rem
|
|
289
|
-
gutter on each side so it doesn't touch the screen edges. Centered
|
|
290
|
-
on the viewport via the classic `left-1/2` + `-translate-x-1/2`
|
|
291
|
-
trick (works because the container is itself centered). -->
|
|
292
|
-
<div
|
|
293
|
-
v-if="displayedColumns.length > 0"
|
|
294
|
-
ref="scrollContainer"
|
|
295
|
-
class="hidden md:block overflow-auto max-h-[70vh]"
|
|
296
|
-
:class="fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4'"
|
|
297
|
-
>
|
|
298
|
-
<table class="text-sm border-collapse">
|
|
299
|
-
<thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
300
|
-
<tr class="border-b border-gray-default">
|
|
301
|
-
<th
|
|
302
|
-
v-for="col in displayedColumns"
|
|
303
|
-
:key="col"
|
|
304
|
-
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"
|
|
305
|
-
:style="columnWidths[col] ? { width: columnWidths[col] + 'px', minWidth: columnWidths[col] + 'px', maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
306
|
-
>
|
|
307
|
-
<div class="flex items-center gap-0.5 min-w-0">
|
|
308
|
-
<span
|
|
309
|
-
class="font-extrabold text-sm truncate"
|
|
310
|
-
:title="col"
|
|
311
|
-
>{{ col }}</span>
|
|
312
|
-
<RiArrowUpLine
|
|
313
|
-
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
314
|
-
class="size-3.5 shrink-0 text-new-primary"
|
|
315
|
-
aria-hidden="true"
|
|
316
|
-
/>
|
|
317
|
-
<RiArrowDownLine
|
|
318
|
-
v-else-if="sort?.column === col && sort.direction === 'desc'"
|
|
319
|
-
class="size-3.5 shrink-0 text-new-primary"
|
|
320
|
-
aria-hidden="true"
|
|
321
|
-
/>
|
|
322
|
-
<TabularFilterPopover
|
|
323
|
-
v-model:sort="sort"
|
|
324
|
-
v-model:filters="filters"
|
|
325
|
-
class="mt-1"
|
|
326
|
-
:column="col"
|
|
327
|
-
:column-type="getColumnType(col)"
|
|
328
|
-
:column-profile="getColumnProfile(col)"
|
|
329
|
-
:null-percent="getNullPercent(col)"
|
|
330
|
-
:total-lines="totalLines"
|
|
331
|
-
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
332
|
-
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
333
|
-
/>
|
|
334
|
-
</div>
|
|
335
|
-
<!-- Column type (semantic format when available) -->
|
|
336
|
-
<span class="font-mono text-xs text-gray-plain -mt-0.5 inline-flex items-center gap-1 max-w-full">
|
|
337
|
-
<component
|
|
338
|
-
:is="getColumnDisplay(col).icon"
|
|
339
|
-
class="size-3 shrink-0"
|
|
340
|
-
aria-hidden="true"
|
|
341
|
-
/>
|
|
342
|
-
<span class="mt-px truncate">{{ getColumnDisplay(col).label }}</span>
|
|
343
|
-
</span>
|
|
344
|
-
<!-- Resize handle: wide hit zone, thin visible bar -->
|
|
345
|
-
<div
|
|
346
|
-
class="absolute top-0 bottom-0 w-3 z-20 cursor-col-resize group/resize"
|
|
347
|
-
:class="col === displayedColumns[displayedColumns.length - 1] ? 'right-3' : '-right-1.5'"
|
|
348
|
-
@mousedown.prevent="startResize(col, $event)"
|
|
349
|
-
>
|
|
350
|
-
<div
|
|
351
|
-
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"
|
|
352
|
-
:class="{ '!opacity-100': resizing?.column === col }"
|
|
353
|
-
/>
|
|
354
|
-
</div>
|
|
355
|
-
</th>
|
|
356
|
-
</tr>
|
|
357
|
-
</thead>
|
|
358
|
-
<tbody>
|
|
359
|
-
<tr v-if="allRows.length === 0">
|
|
360
|
-
<td
|
|
361
|
-
:colspan="displayedColumns.length"
|
|
362
|
-
class="py-16 text-center"
|
|
363
|
-
>
|
|
364
|
-
<div class="flex flex-col items-center gap-2">
|
|
365
|
-
<RiSearchLine
|
|
366
|
-
class="size-8 text-gray-low"
|
|
367
|
-
aria-hidden="true"
|
|
368
|
-
/>
|
|
369
|
-
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
370
|
-
</div>
|
|
371
|
-
</td>
|
|
372
|
-
</tr>
|
|
373
|
-
<tr
|
|
374
|
-
v-for="(row, i) in allRows"
|
|
375
|
-
:key="row.__id ?? i"
|
|
376
|
-
class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
|
|
377
|
-
>
|
|
378
|
-
<td
|
|
379
|
-
v-for="col in displayedColumns"
|
|
380
|
-
:key="col"
|
|
381
|
-
data-cell
|
|
382
|
-
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"
|
|
383
|
-
:class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' }"
|
|
384
|
-
:style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
385
|
-
@click="onCellClick(col, row[col], $event)"
|
|
386
|
-
>
|
|
387
|
-
<TabularCell
|
|
388
|
-
:value="row[col]"
|
|
389
|
-
:column-type="getColumnType(col)"
|
|
390
|
-
:category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
|
|
391
|
-
/>
|
|
392
|
-
</td>
|
|
393
|
-
</tr>
|
|
394
|
-
</tbody>
|
|
395
|
-
</table>
|
|
396
|
-
<InfiniteLoader
|
|
397
|
-
v-if="hasMore"
|
|
398
|
-
:root="scrollContainerRef"
|
|
399
|
-
@intersect="loadNextPage"
|
|
400
|
-
/>
|
|
401
|
-
</div>
|
|
402
|
-
|
|
403
|
-
<!-- Cell popover -->
|
|
404
|
-
<TabularCellPopover
|
|
405
|
-
v-model:cell="activeCell"
|
|
406
|
-
v-model:filters="filters"
|
|
407
|
-
/>
|
|
408
|
-
|
|
409
|
-
<!-- Mobile: card layout -->
|
|
410
|
-
<div
|
|
411
|
-
v-if="displayedColumns.length > 0"
|
|
412
|
-
class="md:hidden space-y-2 px-1"
|
|
413
|
-
>
|
|
414
|
-
<div
|
|
415
|
-
v-if="allRows.length === 0"
|
|
416
|
-
class="py-16 text-center"
|
|
417
|
-
>
|
|
418
|
-
<div class="flex flex-col items-center gap-2">
|
|
419
|
-
<RiSearchLine
|
|
420
|
-
class="size-8 text-gray-low"
|
|
421
|
-
aria-hidden="true"
|
|
422
|
-
/>
|
|
423
|
-
<span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
|
|
424
|
-
</div>
|
|
425
|
-
</div>
|
|
426
|
-
<div
|
|
427
|
-
v-for="(row, i) in allRows"
|
|
428
|
-
:key="row.__id ?? i"
|
|
429
|
-
class="border border-gray-default rounded-lg p-3 space-y-2"
|
|
430
|
-
:class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
|
|
431
|
-
>
|
|
432
|
-
<div
|
|
433
|
-
v-for="col in mobileVisibleFields(i)"
|
|
434
|
-
:key="col"
|
|
435
|
-
class="flex flex-col gap-0.5 min-w-0"
|
|
436
|
-
>
|
|
437
|
-
<div class="flex items-center gap-1 min-w-0">
|
|
438
|
-
<component
|
|
439
|
-
:is="getColumnDisplay(col).icon"
|
|
440
|
-
class="size-3 text-gray-low shrink-0"
|
|
441
|
-
aria-hidden="true"
|
|
442
|
-
/>
|
|
443
|
-
<span
|
|
444
|
-
class="text-xs text-gray-plain truncate"
|
|
445
|
-
:title="col"
|
|
446
|
-
>{{ col }}</span>
|
|
447
|
-
</div>
|
|
448
|
-
<div
|
|
449
|
-
data-cell
|
|
450
|
-
class="min-w-0 pl-4 cursor-pointer"
|
|
451
|
-
@click="onCellClick(col, row[col], $event)"
|
|
452
|
-
>
|
|
453
|
-
<TabularCell
|
|
454
|
-
:value="row[col]"
|
|
455
|
-
:column-type="getColumnType(col)"
|
|
456
|
-
:category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
|
|
457
|
-
compact
|
|
458
|
-
/>
|
|
459
|
-
</div>
|
|
460
|
-
</div>
|
|
461
|
-
<button
|
|
462
|
-
v-if="displayedColumns.length > 4"
|
|
463
|
-
class="text-xs text-gray-title hover:underline pt-1 flex items-center gap-1"
|
|
464
|
-
@click="toggleMobileExpand(i)"
|
|
465
|
-
>
|
|
466
|
-
<RiArrowDownSLine
|
|
467
|
-
class="size-3 transition-transform"
|
|
468
|
-
:class="{ 'rotate-180': mobileExpandedRows.has(i) }"
|
|
469
|
-
aria-hidden="true"
|
|
470
|
-
/>
|
|
471
|
-
{{ mobileExpandedRows.has(i) ? t('Moins') : `+${displayedColumns.length - 4} ${t('champs')}` }}
|
|
472
|
-
</button>
|
|
473
|
-
</div>
|
|
474
|
-
</div>
|
|
475
|
-
|
|
476
|
-
<!-- Mobile: filter bottom sheet -->
|
|
477
|
-
<TransitionRoot
|
|
478
|
-
:show="mobileFilterOpen"
|
|
479
|
-
as="template"
|
|
480
|
-
>
|
|
481
|
-
<Dialog
|
|
482
|
-
class="relative z-[900]"
|
|
483
|
-
@close="mobileFilterOpen = false"
|
|
484
|
-
>
|
|
485
|
-
<!-- Backdrop -->
|
|
486
|
-
<TransitionChild
|
|
487
|
-
as="template"
|
|
488
|
-
enter="ease-out duration-300"
|
|
489
|
-
enter-from="opacity-0"
|
|
490
|
-
enter-to="opacity-100"
|
|
491
|
-
leave="ease-in duration-200"
|
|
492
|
-
leave-from="opacity-100"
|
|
493
|
-
leave-to="opacity-0"
|
|
494
|
-
>
|
|
495
|
-
<div class="fixed inset-0 bg-black/30" />
|
|
496
|
-
</TransitionChild>
|
|
497
|
-
<!-- Panel -->
|
|
498
|
-
<TransitionChild
|
|
499
|
-
as="template"
|
|
500
|
-
enter="ease-out duration-300"
|
|
501
|
-
enter-from="translate-y-full"
|
|
502
|
-
enter-to="translate-y-0"
|
|
503
|
-
leave="ease-in duration-200"
|
|
504
|
-
leave-from="translate-y-0"
|
|
505
|
-
leave-to="translate-y-full"
|
|
506
|
-
>
|
|
507
|
-
<div class="fixed inset-x-0 bottom-0 max-h-[80vh] flex flex-col bg-white rounded-t-2xl shadow-xl">
|
|
508
|
-
<!-- Header -->
|
|
509
|
-
<DialogPanel class="flex flex-col max-h-[80vh]">
|
|
510
|
-
<div class="px-4 pt-4 pb-2 border-b border-gray-default">
|
|
511
|
-
<DialogTitle class="text-sm font-bold">
|
|
512
|
-
{{ t('Filtres & tri par colonne') }}
|
|
513
|
-
</DialogTitle>
|
|
514
|
-
<p class="text-xs text-gray-low">
|
|
515
|
-
{{ allColumns.length }} {{ t('colonnes') }} · {{ activeFilters.length }} {{ t('filtre') }}{{ activeFilters.length !== 1 ? 's' : '' }}
|
|
516
|
-
</p>
|
|
517
|
-
</div>
|
|
518
|
-
<!-- Column list -->
|
|
519
|
-
<div class="flex-1 overflow-y-auto">
|
|
520
|
-
<div
|
|
521
|
-
v-for="col in allColumns"
|
|
522
|
-
:key="col"
|
|
523
|
-
class="border-b border-gray-default last:border-b-0"
|
|
524
|
-
>
|
|
525
|
-
<button
|
|
526
|
-
class="flex items-center gap-2 w-full px-3 py-2.5 text-left"
|
|
527
|
-
:class="hasFilterForColumn(col) ? 'bg-new-primary/5' : ''"
|
|
528
|
-
@click="toggleMobileFilterColumn(col)"
|
|
529
|
-
>
|
|
530
|
-
<component
|
|
531
|
-
:is="getColumnDisplay(col).icon"
|
|
532
|
-
class="size-3.5 text-gray-low shrink-0"
|
|
533
|
-
aria-hidden="true"
|
|
534
|
-
/>
|
|
535
|
-
<span class="flex-1 text-sm text-gray-title truncate">{{ col }}</span>
|
|
536
|
-
<RiArrowUpLine
|
|
537
|
-
v-if="sort?.column === col && sort.direction === 'asc'"
|
|
538
|
-
class="size-3 text-new-primary shrink-0"
|
|
539
|
-
aria-hidden="true"
|
|
540
|
-
/>
|
|
541
|
-
<RiArrowDownLine
|
|
542
|
-
v-if="sort?.column === col && sort.direction === 'desc'"
|
|
543
|
-
class="size-3 text-new-primary shrink-0"
|
|
544
|
-
aria-hidden="true"
|
|
545
|
-
/>
|
|
546
|
-
<span
|
|
547
|
-
v-if="hasFilterForColumn(col)"
|
|
548
|
-
class="size-2 rounded-full bg-new-primary shrink-0"
|
|
549
|
-
/>
|
|
550
|
-
<RiArrowDownSLine
|
|
551
|
-
class="size-3.5 text-gray-low shrink-0 transition-transform"
|
|
552
|
-
:class="{ 'rotate-180': mobileFilterExpandedCol === col }"
|
|
553
|
-
aria-hidden="true"
|
|
554
|
-
/>
|
|
555
|
-
</button>
|
|
556
|
-
<div
|
|
557
|
-
v-if="mobileFilterExpandedCol === col"
|
|
558
|
-
class="pb-1"
|
|
559
|
-
>
|
|
560
|
-
<TabularFilterContent
|
|
561
|
-
v-model:sort="sort"
|
|
562
|
-
v-model:filters="filters"
|
|
563
|
-
:column="col"
|
|
564
|
-
:column-type="getColumnType(col)"
|
|
565
|
-
:column-profile="getColumnProfile(col)"
|
|
566
|
-
:null-percent="getNullPercent(col)"
|
|
567
|
-
:total-lines="totalLines"
|
|
568
|
-
:category-badge-styles="getColumnType(col) === 'categorical' ? getCategoryBadgeStylesForColumn(col) : undefined"
|
|
569
|
-
:boolean-counts="getColumnType(col) === 'boolean' ? getBooleanCounts(col) : undefined"
|
|
570
|
-
/>
|
|
571
|
-
</div>
|
|
572
|
-
</div>
|
|
573
|
-
</div>
|
|
574
|
-
<!-- Reset all -->
|
|
575
|
-
<div
|
|
576
|
-
v-if="activeFilters.length > 0 || sort"
|
|
577
|
-
class="border-t border-gray-default px-4 py-3"
|
|
578
|
-
>
|
|
579
|
-
<BrandedButton
|
|
580
|
-
color="secondary"
|
|
581
|
-
size="xs"
|
|
582
|
-
class="w-full"
|
|
583
|
-
@click="clearAllFilters(); sort = null; mobileFilterOpen = false"
|
|
584
|
-
>
|
|
585
|
-
{{ t('Tout réinitialiser') }}
|
|
586
|
-
</BrandedButton>
|
|
587
|
-
</div>
|
|
588
|
-
</DialogPanel>
|
|
589
|
-
</div>
|
|
590
|
-
</TransitionChild>
|
|
591
|
-
</Dialog>
|
|
592
|
-
</TransitionRoot>
|
|
16
|
+
<slot />
|
|
593
17
|
</template>
|
|
594
18
|
</div>
|
|
595
19
|
</template>
|
|
596
20
|
|
|
597
21
|
<script setup lang="ts">
|
|
598
|
-
import { computed,
|
|
22
|
+
import { computed, ref, watch } from 'vue'
|
|
599
23
|
import { ofetch } from 'ofetch'
|
|
600
|
-
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
601
|
-
import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
602
|
-
import {
|
|
603
|
-
RiLayoutColumnLine,
|
|
604
|
-
RiLayoutRowLine,
|
|
605
|
-
RiArrowDownSLine,
|
|
606
|
-
RiArrowUpLine,
|
|
607
|
-
RiArrowDownLine,
|
|
608
|
-
RiFilterLine,
|
|
609
|
-
RiCloseLine,
|
|
610
|
-
RiSearchLine,
|
|
611
|
-
} from '@remixicon/vue'
|
|
612
24
|
import { useFetch } from '../../functions/api'
|
|
613
25
|
import { useComponentsConfig } from '../../config'
|
|
614
26
|
import { useTranslation } from '../../composables/useTranslation'
|
|
615
27
|
import { injectTabularProfile } from '../../composables/useTabularProfile'
|
|
616
|
-
import {
|
|
617
|
-
import
|
|
618
|
-
import
|
|
619
|
-
import
|
|
620
|
-
import
|
|
621
|
-
import
|
|
622
|
-
import TabularCellPopover from './TabularCellPopover.vue'
|
|
623
|
-
import type { CellInfo } from './TabularCellPopover.vue'
|
|
624
|
-
import TabularFilterContent from './TabularFilterContent.vue'
|
|
625
|
-
import TabularFilterPopover from './TabularFilterPopover.vue'
|
|
626
|
-
import type { TabularDataResponse, TabularRow, ColumnType, SortConfig, ColumnFilters, BadgeStyle } from './types'
|
|
627
|
-
import noColumnsSrc from '../../../assets/illustrations/_table.svg?url'
|
|
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'
|
|
628
34
|
|
|
629
35
|
const props = defineProps<{
|
|
630
36
|
resourceId: string
|
|
631
|
-
// When
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
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
|
|
635
52
|
}>()
|
|
636
53
|
|
|
637
54
|
const { t } = useTranslation()
|
|
638
55
|
const config = useComponentsConfig()
|
|
639
56
|
|
|
640
|
-
// Column selector popover positioning
|
|
641
|
-
const columnAnchorComponent = useTemplateRef<InstanceType<typeof Popover>>('columnAnchor')
|
|
642
|
-
const columnPanelComponent = useTemplateRef<InstanceType<typeof PopoverPanel>>('columnPanel')
|
|
643
|
-
const columnAnchorEl = computed(() => columnAnchorComponent.value?.$el as HTMLElement | undefined)
|
|
644
|
-
const columnPanelEl = computed(() => columnPanelComponent.value?.$el as HTMLElement | undefined)
|
|
645
|
-
|
|
646
|
-
const { floatingStyles: columnFloatingStyles } = useFloating(columnAnchorEl, columnPanelEl, {
|
|
647
|
-
placement: 'bottom-start',
|
|
648
|
-
middleware: [flip(), shift()],
|
|
649
|
-
whileElementsMounted: autoUpdate,
|
|
650
|
-
})
|
|
651
|
-
|
|
652
57
|
const dataUrl = computed(() =>
|
|
653
58
|
`${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
|
|
654
59
|
)
|
|
655
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
|
+
|
|
656
82
|
// Sort & filter state
|
|
657
|
-
const sort = ref<SortConfig | null>(null)
|
|
658
|
-
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 })
|
|
659
85
|
|
|
660
86
|
const PAGE_SIZE = 50
|
|
661
87
|
|
|
@@ -686,16 +112,18 @@ const dataQuery = computed(() => {
|
|
|
686
112
|
else if (filter.null === 'exclude') {
|
|
687
113
|
q[`${col}__isnotnull`] = ''
|
|
688
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(',') + ')'
|
|
689
122
|
}
|
|
690
123
|
return q
|
|
691
124
|
})
|
|
692
125
|
|
|
693
|
-
const { data: tableData, error } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
|
|
694
|
-
|
|
695
|
-
// Profile is shared with sibling components (e.g. DataStructure) via
|
|
696
|
-
// `provideTabularProfile` in the parent. Falls back to a local fetch
|
|
697
|
-
// when no parent provides it (standalone usage).
|
|
698
|
-
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 })
|
|
699
127
|
|
|
700
128
|
// The component renders nothing useful until the profile is available
|
|
701
129
|
// (allColumns is derived from it). Surface a clear loading / error state
|
|
@@ -703,6 +131,9 @@ const { data: profileData, error: profileError, status: profileStatus } = await
|
|
|
703
131
|
const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
|
|
704
132
|
const previewError = computed(() => error.value || profileError.value)
|
|
705
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)
|
|
706
137
|
|
|
707
138
|
// Infinite scroll state
|
|
708
139
|
const allRows = ref<TabularRow[]>([])
|
|
@@ -710,7 +141,6 @@ const currentPage = ref(1)
|
|
|
710
141
|
const hasMore = ref(false)
|
|
711
142
|
const loadingMore = ref(false)
|
|
712
143
|
const generation = ref(0)
|
|
713
|
-
const scrollContainerRef = useTemplateRef<HTMLElement>('scrollContainer')
|
|
714
144
|
|
|
715
145
|
watch(() => tableData.value, (data) => {
|
|
716
146
|
generation.value++
|
|
@@ -742,8 +172,6 @@ async function loadNextPage() {
|
|
|
742
172
|
|
|
743
173
|
const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
|
|
744
174
|
|
|
745
|
-
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
|
|
746
|
-
|
|
747
175
|
const visibleColumns = ref(new Set(allColumns.value))
|
|
748
176
|
|
|
749
177
|
watch(allColumns, (cols) => {
|
|
@@ -783,150 +211,22 @@ function selectOnlyColumn(col: string) {
|
|
|
783
211
|
visibleColumns.value = new Set([col])
|
|
784
212
|
}
|
|
785
213
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
// - Manual resize writes directly to `columnWidths` during the drag, then stores
|
|
799
|
-
// the final width back into `naturalWidths` so it survives later redistribution.
|
|
800
|
-
const columnWidths = ref<Record<string, number>>({})
|
|
801
|
-
const naturalWidths = ref<Record<string, number>>({})
|
|
802
|
-
const resizing = ref<{ column: string, startX: number, startWidth: number } | null>(null)
|
|
803
|
-
|
|
804
|
-
// Measure content-driven widths once, while the <th> are still auto-sized.
|
|
805
|
-
function measureNaturalWidths() {
|
|
806
|
-
const container = scrollContainerRef.value
|
|
807
|
-
if (!container) return
|
|
808
|
-
const ths = container.querySelectorAll('th')
|
|
809
|
-
if (!ths || ths.length === 0) return
|
|
810
|
-
|
|
811
|
-
const widths: Record<string, number> = {}
|
|
812
|
-
ths.forEach((th, i) => {
|
|
813
|
-
const col = displayedColumns.value[i]
|
|
814
|
-
if (col) widths[col] = th.offsetWidth
|
|
815
|
-
})
|
|
816
|
-
naturalWidths.value = widths
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
// Distribute the natural widths of the currently displayed columns to fill the
|
|
820
|
-
// container (inflating proportionally when they don't naturally cover it).
|
|
821
|
-
function redistributeColumnWidths() {
|
|
822
|
-
const container = scrollContainerRef.value
|
|
823
|
-
if (!container) return
|
|
824
|
-
const cols = displayedColumns.value
|
|
825
|
-
let naturalSum = 0
|
|
826
|
-
for (const col of cols) naturalSum += naturalWidths.value[col] ?? 0
|
|
827
|
-
if (naturalSum === 0) return
|
|
828
|
-
|
|
829
|
-
const ratio = naturalSum < container.clientWidth ? container.clientWidth / naturalSum : 1
|
|
830
|
-
const widths: Record<string, number> = {}
|
|
831
|
-
for (const col of cols) widths[col] = Math.floor((naturalWidths.value[col] ?? 0) * ratio)
|
|
832
|
-
columnWidths.value = widths
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// Measure once both data and profile are ready (so the <thead> is rendered with
|
|
836
|
-
// content-driven natural widths), then distribute.
|
|
837
|
-
watch(
|
|
838
|
-
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
839
|
-
async ([dataReady, colCount]) => {
|
|
840
|
-
if (!dataReady || colCount === 0) return
|
|
841
|
-
if (Object.keys(naturalWidths.value).length > 0) return
|
|
842
|
-
await nextTick()
|
|
843
|
-
measureNaturalWidths()
|
|
844
|
-
redistributeColumnWidths()
|
|
845
|
-
},
|
|
846
|
-
{ immediate: true },
|
|
847
|
-
)
|
|
848
|
-
|
|
849
|
-
// Re-stretch the remaining columns whenever the displayed set changes (columns
|
|
850
|
-
// toggled / hidden / isolated from the menu). No DOM measurement needed — we
|
|
851
|
-
// redistribute the already-measured natural widths over the new container space.
|
|
852
|
-
watch(displayedColumns, async () => {
|
|
853
|
-
if (Object.keys(naturalWidths.value).length === 0) return
|
|
854
|
-
// Wait for the table to (re)mount before reading the container: the desktop
|
|
855
|
-
// table is `v-if`-ed out when no column is displayed, so going from 0 → N
|
|
856
|
-
// columns means `scrollContainer` only exists after the next render.
|
|
857
|
-
await nextTick()
|
|
858
|
-
redistributeColumnWidths()
|
|
859
|
-
})
|
|
860
|
-
|
|
861
|
-
function startResize(col: string, e: MouseEvent) {
|
|
862
|
-
// Fallback in case the user grabs the handle before the initial measure ran.
|
|
863
|
-
if (!Object.keys(naturalWidths.value).length) {
|
|
864
|
-
measureNaturalWidths()
|
|
865
|
-
redistributeColumnWidths()
|
|
866
|
-
}
|
|
867
|
-
resizing.value = { column: col, startX: e.clientX, startWidth: columnWidths.value[col] ?? 100 }
|
|
868
|
-
// Disable smooth scroll during resize
|
|
869
|
-
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = 'auto'
|
|
870
|
-
document.addEventListener('mousemove', onResize)
|
|
871
|
-
document.addEventListener('mouseup', stopResize)
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
function onResize(e: MouseEvent) {
|
|
875
|
-
if (!resizing.value) return
|
|
876
|
-
const container = scrollContainerRef.value
|
|
877
|
-
// Auto-scroll when mouse approaches the right edge, and adjust startX to compensate
|
|
878
|
-
if (container) {
|
|
879
|
-
const rect = container.getBoundingClientRect()
|
|
880
|
-
const distFromRight = rect.right - e.clientX
|
|
881
|
-
if (distFromRight < 50) {
|
|
882
|
-
const oldScroll = container.scrollLeft
|
|
883
|
-
container.scrollLeft += Math.max(1, (50 - distFromRight) * 0.5)
|
|
884
|
-
resizing.value.startX -= container.scrollLeft - oldScroll
|
|
885
|
-
}
|
|
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)}`
|
|
886
226
|
}
|
|
887
|
-
const delta = e.clientX - resizing.value.startX
|
|
888
|
-
const newWidth = Math.max(60, resizing.value.startWidth + delta)
|
|
889
|
-
columnWidths.value = { ...columnWidths.value, [resizing.value.column]: newWidth }
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
function stopResize() {
|
|
893
|
-
if (scrollContainerRef.value) scrollContainerRef.value.style.scrollBehavior = ''
|
|
894
|
-
// Persist the manual width as the column's new base so it survives later
|
|
895
|
-
// redistribution when other columns are toggled.
|
|
896
|
-
if (resizing.value) {
|
|
897
|
-
const col = resizing.value.column
|
|
898
|
-
const width = columnWidths.value[col]
|
|
899
|
-
if (width != null) naturalWidths.value = { ...naturalWidths.value, [col]: width }
|
|
900
|
-
}
|
|
901
|
-
resizing.value = null
|
|
902
|
-
document.removeEventListener('mousemove', onResize)
|
|
903
|
-
document.removeEventListener('mouseup', stopResize)
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
onUnmounted(() => {
|
|
907
|
-
document.removeEventListener('mousemove', onResize)
|
|
908
|
-
document.removeEventListener('mouseup', stopResize)
|
|
909
|
-
})
|
|
910
|
-
|
|
911
|
-
// Cell popover
|
|
912
|
-
const activeCell = ref<CellInfo | null>(null)
|
|
913
|
-
|
|
914
|
-
function onCellClick(col: string, value: unknown, event: MouseEvent) {
|
|
915
|
-
const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
|
|
916
|
-
if (!el) return
|
|
917
|
-
if (activeCell.value && activeCell.value.element === el) {
|
|
918
|
-
activeCell.value = null
|
|
919
|
-
return
|
|
920
|
-
}
|
|
921
|
-
activeCell.value = { column: col, columnType: columnTypesMap.value[col] ?? 'text', value, element: el }
|
|
922
227
|
}
|
|
923
228
|
|
|
924
229
|
// Active filters
|
|
925
|
-
interface ActiveFilter {
|
|
926
|
-
column: string
|
|
927
|
-
label: string
|
|
928
|
-
}
|
|
929
|
-
|
|
930
230
|
const activeFilters = computed<ActiveFilter[]>(() => {
|
|
931
231
|
const result: ActiveFilter[] = []
|
|
932
232
|
for (const [col, filter] of Object.entries(filters.value)) {
|
|
@@ -935,7 +235,12 @@ const activeFilters = computed<ActiveFilter[]>(() => {
|
|
|
935
235
|
parts.push(`= ${filter.in.join(', ')}`)
|
|
936
236
|
}
|
|
937
237
|
if (filter.exact != null) {
|
|
938
|
-
|
|
238
|
+
if (getColumnType(col) === 'boolean') {
|
|
239
|
+
parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
parts.push(`= ${filter.exact}`)
|
|
243
|
+
}
|
|
939
244
|
}
|
|
940
245
|
if (filter.contains) {
|
|
941
246
|
parts.push(`${t('contient')} "${filter.contains}"`)
|
|
@@ -955,6 +260,9 @@ const activeFilters = computed<ActiveFilter[]>(() => {
|
|
|
955
260
|
else if (filter.max != null) {
|
|
956
261
|
parts.push(`≤ ${filter.max}`)
|
|
957
262
|
}
|
|
263
|
+
if (filter.date) {
|
|
264
|
+
parts.push(describeDateFilter(filter.date))
|
|
265
|
+
}
|
|
958
266
|
if (parts.length) {
|
|
959
267
|
result.push({ column: col, label: parts.join(', ') })
|
|
960
268
|
}
|
|
@@ -975,137 +283,38 @@ function hasFilterForColumn(col: string): boolean {
|
|
|
975
283
|
return _hasFilterForColumn(filters.value, col)
|
|
976
284
|
}
|
|
977
285
|
|
|
978
|
-
//
|
|
286
|
+
// Whether the mobile filter sheet is open (button lives in the toolbar).
|
|
979
287
|
const mobileFilterOpen = ref(false)
|
|
980
|
-
const mobileExpandedRows = ref(new Set<number>())
|
|
981
|
-
const mobileFilterExpandedCol = ref<string | null>(null)
|
|
982
|
-
|
|
983
|
-
function mobileVisibleFields(index: number): string[] {
|
|
984
|
-
if (displayedColumns.value.length <= 4 || mobileExpandedRows.value.has(index)) {
|
|
985
|
-
return displayedColumns.value
|
|
986
|
-
}
|
|
987
|
-
return displayedColumns.value.slice(0, 4)
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
function toggleMobileExpand(index: number) {
|
|
991
|
-
const next = new Set(mobileExpandedRows.value)
|
|
992
|
-
if (next.has(index)) next.delete(index)
|
|
993
|
-
else next.add(index)
|
|
994
|
-
mobileExpandedRows.value = next
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
function toggleMobileFilterColumn(col: string) {
|
|
998
|
-
mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
|
|
999
|
-
}
|
|
1000
288
|
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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,
|
|
1021
319
|
})
|
|
1022
|
-
|
|
1023
|
-
function getColumnType(col: string): ColumnType {
|
|
1024
|
-
return columnTypesMap.value[col] ?? 'text'
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
function getColumnProfile(col: string) {
|
|
1028
|
-
return profileData.value?.profile?.profile?.[col] ?? null
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
function getTopsEntries(col: string) {
|
|
1032
|
-
return getColumnProfile(col)?.tops ?? []
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
function getNullPercent(col: string) {
|
|
1036
|
-
const colProfile = getColumnProfile(col)
|
|
1037
|
-
const total = profileData.value?.profile?.total_lines
|
|
1038
|
-
if (!colProfile || !total) return '0%'
|
|
1039
|
-
return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
const typeConfig = buildTypeConfig(t)
|
|
1043
|
-
const formatConfig = buildFormatConfig(t)
|
|
1044
|
-
|
|
1045
|
-
function getTypeConfig(col: string) {
|
|
1046
|
-
return typeConfig[getColumnType(col)]
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
// Header display: prefer the rich semantic format detected by csv-detective
|
|
1050
|
-
// (code INSEE, UAI, département, SIREN, email…) over the generic display type.
|
|
1051
|
-
// Tolerant: generic formats fall back to the type config, and unknown semantic
|
|
1052
|
-
// formats are humanized rather than dropped.
|
|
1053
|
-
function getColumnDisplay(col: string) {
|
|
1054
|
-
const format = profileData.value?.profile?.columns?.[col]?.format
|
|
1055
|
-
const fallback = getTypeConfig(col)
|
|
1056
|
-
if (!format || GENERIC_FORMATS.has(format)) return fallback
|
|
1057
|
-
return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
const BADGE_PALETTE = [
|
|
1061
|
-
{ bg: '#E6EEFE', text: '#3558A2' }, // blue-cumulus
|
|
1062
|
-
{ bg: '#E8E3DB', text: '#6A6156' }, // beige-gris-galet
|
|
1063
|
-
{ bg: '#FEECC2', text: '#695240' }, // yellow-tournesol
|
|
1064
|
-
{ bg: '#C7F6FC', text: '#006A6F' }, // green-archipel
|
|
1065
|
-
{ bg: '#E9EDFE', text: '#2323FF' }, // blue-ecume
|
|
1066
|
-
{ bg: '#FFE0C7', text: '#885B40' }, // orange-terre-battue
|
|
1067
|
-
{ bg: '#F2E9DB', text: '#6B4C35' }, // brown-cafe-creme
|
|
1068
|
-
{ bg: '#BAFAEE', text: '#297254' }, // green-menthe
|
|
1069
|
-
{ bg: '#FEE0EB', text: '#8D5368' }, // pink-macaron
|
|
1070
|
-
{ bg: '#FCE164', text: '#695240' }, // yellow-moutarde
|
|
1071
|
-
] as const
|
|
1072
|
-
|
|
1073
|
-
const BADGE_FALLBACK = { bg: '#F0E0CF', text: '#745B47' } // brown-opera
|
|
1074
|
-
|
|
1075
|
-
const badgeColorMap = computed(() => {
|
|
1076
|
-
const map = new Map<string, { bg: string, text: string }>()
|
|
1077
|
-
const profile = profileData.value?.profile
|
|
1078
|
-
if (!profile) return map
|
|
1079
|
-
for (const col of profile.categorical) {
|
|
1080
|
-
getTopsEntries(col).forEach((top, i) => {
|
|
1081
|
-
map.set(`${col}::${top.value}`, BADGE_PALETTE[i % BADGE_PALETTE.length]!)
|
|
1082
|
-
})
|
|
1083
|
-
}
|
|
1084
|
-
return map
|
|
1085
|
-
})
|
|
1086
|
-
|
|
1087
|
-
function getCategoryBadgeStyle(col: string, value: string): BadgeStyle {
|
|
1088
|
-
const colors = badgeColorMap.value.get(`${col}::${value}`) ?? BADGE_FALLBACK
|
|
1089
|
-
return { backgroundColor: colors.bg, color: colors.text }
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
function getCategoryBadgeStylesForColumn(col: string): Record<string, BadgeStyle> {
|
|
1093
|
-
const styles: Record<string, BadgeStyle> = {}
|
|
1094
|
-
for (const top of getTopsEntries(col)) {
|
|
1095
|
-
styles[top.value] = getCategoryBadgeStyle(col, top.value)
|
|
1096
|
-
}
|
|
1097
|
-
return styles
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
|
|
1101
|
-
const profile = getColumnProfile(col)
|
|
1102
|
-
if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
|
|
1103
|
-
let trueCount = 0
|
|
1104
|
-
let falseCount = 0
|
|
1105
|
-
for (const top of profile.tops) {
|
|
1106
|
-
if (isTruthy(top.value ?? '')) trueCount += top.count
|
|
1107
|
-
else if (isFalsy(top.value ?? '')) falseCount += top.count
|
|
1108
|
-
}
|
|
1109
|
-
return { trueCount, falseCount }
|
|
1110
|
-
}
|
|
1111
320
|
</script>
|