@datagouv/components-next 1.0.2-dev.19 → 1.0.2-dev.190

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.
Files changed (156) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-VsEj9A00.js} +9 -9
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-cv32--L0.js +36 -0
  8. package/dist/JsonPreview.client-JmyzznjY.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-BxGjbFJV.js} +41 -42
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-CkrjnmJP.js} +835 -856
  13. package/dist/Pmtiles.client-EAekMgpr.js +26333 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-B0OeAb39.js +60 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-WcOMRUAD.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +194 -149
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-DLsJxwiN.js} +21842 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-D0FPslrZ.js} +185 -187
  26. package/dist/{main-vqhkVhVS.js → main-HzoTSLAM.js} +41511 -73246
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-iq0K0tIG.js} +16 -16
  32. package/package.json +47 -38
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +3 -2
  35. package/src/components/AppLink.vue +3 -0
  36. package/src/components/BannerAction.vue +6 -2
  37. package/src/components/Chart/ChartViewer.vue +320 -0
  38. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  39. package/src/components/CopyButton.vue +6 -2
  40. package/src/components/DataserviceCard.vue +9 -0
  41. package/src/components/DataserviceQuality.vue +82 -0
  42. package/src/components/DataserviceQualityInline.vue +31 -0
  43. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  44. package/src/components/DatasetCard.vue +9 -4
  45. package/src/components/DatasetQuality.vue +11 -11
  46. package/src/components/DatasetQualityInline.vue +2 -2
  47. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  48. package/src/components/Form/Listbox.vue +101 -0
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/InfiniteLoader.vue +53 -0
  51. package/src/components/ObjectCardHeader.vue +11 -4
  52. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  53. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  54. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  55. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  56. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  57. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  58. package/src/components/OpenApiViewer/openapi.ts +150 -0
  59. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  60. package/src/components/Pagination.vue +23 -58
  61. package/src/components/RadioInput.vue +7 -2
  62. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  63. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  64. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  65. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  66. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  67. package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
  68. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  69. package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
  70. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  71. package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
  72. package/src/components/ResourceAccordion/Preview.vue +11 -11
  73. package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
  74. package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
  75. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  76. package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
  77. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
  78. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
  79. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
  80. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  81. package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
  82. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
  83. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  84. package/src/components/ResourceListItem.vue +154 -0
  85. package/src/components/ReuseCard.vue +14 -6
  86. package/src/components/ReuseDetails.vue +2 -2
  87. package/src/components/ReuseHorizontalCard.vue +2 -2
  88. package/src/components/SchemaCard.vue +10 -1
  89. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  90. package/src/components/Search/GlobalSearch.vue +220 -117
  91. package/src/components/Search/SearchInput.vue +5 -4
  92. package/src/components/Search/Sidemenu.vue +4 -3
  93. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  94. package/src/components/TabularExplorer/TabularCell.vue +50 -0
  95. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  96. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  97. package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
  98. package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
  99. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  100. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  101. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  102. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  103. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  104. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  105. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  106. package/src/components/TabularExplorer/types.ts +81 -0
  107. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  108. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  109. package/src/composables/useActiveDescendant.ts +5 -0
  110. package/src/composables/useDatasetResources.ts +164 -0
  111. package/src/composables/useHasTabularData.ts +15 -1
  112. package/src/composables/useMetrics.ts +1 -1
  113. package/src/composables/useResizable.ts +46 -0
  114. package/src/composables/useResourceCapabilities.ts +48 -40
  115. package/src/composables/useSearchFilter.ts +120 -0
  116. package/src/composables/useStableQueryParams.ts +38 -6
  117. package/src/composables/useTabularProfile.ts +70 -0
  118. package/src/composables/useTranslation.ts +24 -9
  119. package/src/config.ts +21 -3
  120. package/src/functions/activities.ts +3 -3
  121. package/src/functions/api.ts +20 -42
  122. package/src/functions/api.types.ts +1 -0
  123. package/src/functions/charts.ts +99 -0
  124. package/src/functions/datasets.ts +0 -17
  125. package/src/functions/dates.ts +3 -2
  126. package/src/functions/helpers.ts +15 -0
  127. package/src/functions/metrics.ts +63 -60
  128. package/src/functions/paginate.ts +46 -0
  129. package/src/functions/resourceCapabilities.ts +34 -0
  130. package/src/functions/resources.ts +136 -1
  131. package/src/functions/schemas.ts +27 -0
  132. package/src/functions/tabular.ts +214 -0
  133. package/src/functions/tabularApi.ts +96 -11
  134. package/src/main.ts +117 -14
  135. package/src/types/dataservices.ts +17 -0
  136. package/src/types/organizations.ts +10 -0
  137. package/src/types/pages.ts +0 -5
  138. package/src/types/posts.ts +2 -2
  139. package/src/types/reports.ts +5 -1
  140. package/src/types/resources.ts +0 -1
  141. package/src/types/reuses.ts +12 -1
  142. package/src/types/search.ts +63 -1
  143. package/src/types/site.ts +5 -3
  144. package/src/types/ui.ts +2 -0
  145. package/src/types/users.ts +2 -1
  146. package/src/types/visualizations.ts +93 -0
  147. package/assets/swagger-themes/newspaper.css +0 -1670
  148. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  149. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  150. package/dist/Swagger.client-B4y0hXME.js +0 -4
  151. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  152. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  153. package/src/functions/pagination.ts +0 -9
  154. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  155. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  156. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -0,0 +1,299 @@
1
+ <template>
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. -->
15
+ <template v-else-if="tableData && profileData">
16
+ <slot />
17
+ </template>
18
+ </div>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import { computed, ref, watch } from 'vue'
23
+ import { ofetch } from 'ofetch'
24
+ import { useFetch } from '../../functions/api'
25
+ import { useComponentsConfig } from '../../config'
26
+ import { useTranslation } from '../../composables/useTranslation'
27
+ import { injectTabularProfile } from '../../composables/useTabularProfile'
28
+ import { hasFilterForColumn as _hasFilterForColumn, buildGlobalSearchConditions } from '../../functions/tabular'
29
+ import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
30
+ import TabularSkeleton from './TabularSkeleton.vue'
31
+ import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters } from './types'
32
+ import { provideTabularContext, type ActiveFilter } from './useTabularContext'
33
+ import { useColumnMetadata } from './useColumnMetadata'
34
+
35
+ const props = defineProps<{
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
52
+ }>()
53
+
54
+ const { t } = useTranslation()
55
+ const config = useComponentsConfig()
56
+
57
+ const dataUrl = computed(() =>
58
+ `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
59
+ )
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
+
82
+ // Sort & filter state
83
+ const sort = ref<SortConfig | null>(props.initialSort ? { ...props.initialSort } : null)
84
+ const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
85
+
86
+ const PAGE_SIZE = 50
87
+
88
+ const dataQuery = computed(() => {
89
+ const q: Record<string, string | number> = { page: 1, page_size: PAGE_SIZE }
90
+ if (sort.value) {
91
+ q[`${sort.value.column}__sort`] = sort.value.direction
92
+ }
93
+ for (const [col, filter] of Object.entries(filters.value)) {
94
+ if (filter.in?.length) {
95
+ q[`${col}__in`] = filter.in.join(',')
96
+ }
97
+ if (filter.exact != null) {
98
+ q[`${col}__exact`] = filter.exact
99
+ }
100
+ if (Number.isFinite(filter.min)) {
101
+ q[`${col}__greater`] = filter.min!
102
+ }
103
+ if (Number.isFinite(filter.max)) {
104
+ q[`${col}__less`] = filter.max!
105
+ }
106
+ if (filter.contains) {
107
+ q[`${col}__contains`] = filter.contains
108
+ }
109
+ if (filter.null === 'only') {
110
+ q[`${col}__isnull`] = ''
111
+ }
112
+ else if (filter.null === 'exclude') {
113
+ q[`${col}__isnotnull`] = ''
114
+ }
115
+ }
116
+ if (props.globalSearch && profileData.value?.profile) {
117
+ const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
118
+ q.or = '(' + conditions.join(',') + ')'
119
+ }
120
+ return q
121
+ })
122
+
123
+ const { data: tableData, error, status: dataStatus } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
124
+
125
+ // The component renders nothing useful until the profile is available
126
+ // (allColumns is derived from it). Surface a clear loading / error state
127
+ // so we don't end up with an empty table + a spinner running forever.
128
+ const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
129
+ const previewError = computed(() => error.value || profileError.value)
130
+ const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
131
+ // A search / filter / sort change refetches while the previous rows stay on
132
+ // screen: without a signal, the table looks unchanged for several seconds.
133
+ const isRefreshing = computed(() => dataStatus.value === 'pending' && !previewLoading.value)
134
+
135
+ // Infinite scroll state
136
+ const allRows = ref<TabularRow[]>([])
137
+ const currentPage = ref(1)
138
+ const hasMore = ref(false)
139
+ const loadingMore = ref(false)
140
+ const generation = ref(0)
141
+
142
+ watch(() => tableData.value, (data) => {
143
+ generation.value++
144
+ if (data) {
145
+ allRows.value = [...data.data]
146
+ currentPage.value = 1
147
+ hasMore.value = data.data.length < data.meta.total
148
+ }
149
+ }, { immediate: true })
150
+
151
+ async function loadNextPage() {
152
+ if (loadingMore.value || !hasMore.value || !tableData.value) return
153
+ loadingMore.value = true
154
+ const gen = generation.value
155
+ try {
156
+ const nextPage = currentPage.value + 1
157
+ const query = { ...dataQuery.value, page: nextPage }
158
+ const data = await ofetch<TabularDataResponse>(dataUrl.value, { params: query })
159
+ // Discard stale response if filters/sort changed during the fetch
160
+ if (gen !== generation.value) return
161
+ allRows.value = [...allRows.value, ...data.data]
162
+ currentPage.value = nextPage
163
+ hasMore.value = allRows.value.length < tableData.value.meta.total
164
+ }
165
+ finally {
166
+ loadingMore.value = false
167
+ }
168
+ }
169
+
170
+ const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
171
+
172
+ const visibleColumns = ref(new Set(allColumns.value))
173
+
174
+ watch(allColumns, (cols) => {
175
+ if (cols.length > 0 && visibleColumns.value.size === 0) {
176
+ visibleColumns.value = new Set(cols)
177
+ }
178
+ })
179
+
180
+ const displayedColumns = computed(() =>
181
+ allColumns.value.filter(col => visibleColumns.value.has(col)),
182
+ )
183
+
184
+ const hiddenCount = computed(() =>
185
+ allColumns.value.length - visibleColumns.value.size,
186
+ )
187
+
188
+ function toggleColumn(col: string) {
189
+ const next = new Set(visibleColumns.value)
190
+ if (next.has(col)) {
191
+ next.delete(col)
192
+ }
193
+ else {
194
+ next.add(col)
195
+ }
196
+ visibleColumns.value = next
197
+ }
198
+
199
+ function showAllColumns() {
200
+ visibleColumns.value = new Set(allColumns.value)
201
+ }
202
+
203
+ function hideAllColumns() {
204
+ visibleColumns.value = new Set()
205
+ }
206
+
207
+ function selectOnlyColumn(col: string) {
208
+ visibleColumns.value = new Set([col])
209
+ }
210
+
211
+ // Active filters
212
+ const activeFilters = computed<ActiveFilter[]>(() => {
213
+ const result: ActiveFilter[] = []
214
+ for (const [col, filter] of Object.entries(filters.value)) {
215
+ const parts: string[] = []
216
+ if (filter.in?.length) {
217
+ parts.push(`= ${filter.in.join(', ')}`)
218
+ }
219
+ if (filter.exact != null) {
220
+ if (getColumnType(col) === 'boolean') {
221
+ parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
222
+ }
223
+ else {
224
+ parts.push(`= ${filter.exact}`)
225
+ }
226
+ }
227
+ if (filter.contains) {
228
+ parts.push(`${t('contient')} "${filter.contains}"`)
229
+ }
230
+ if (filter.null === 'only') {
231
+ parts.push(t('null uniquement'))
232
+ }
233
+ else if (filter.null === 'exclude') {
234
+ parts.push(t('sans null'))
235
+ }
236
+ if (filter.min != null && filter.max != null) {
237
+ parts.push(`${filter.min} – ${filter.max}`)
238
+ }
239
+ else if (filter.min != null) {
240
+ parts.push(`≥ ${filter.min}`)
241
+ }
242
+ else if (filter.max != null) {
243
+ parts.push(`≤ ${filter.max}`)
244
+ }
245
+ if (parts.length) {
246
+ result.push({ column: col, label: parts.join(', ') })
247
+ }
248
+ }
249
+ return result
250
+ })
251
+
252
+ function removeFilter(column: string) {
253
+ const { [column]: _, ...rest } = filters.value
254
+ filters.value = rest
255
+ }
256
+
257
+ function clearAllFilters() {
258
+ filters.value = {}
259
+ }
260
+
261
+ function hasFilterForColumn(col: string): boolean {
262
+ return _hasFilterForColumn(filters.value, col)
263
+ }
264
+
265
+ // Whether the mobile filter sheet is open (button lives in the toolbar).
266
+ const mobileFilterOpen = ref(false)
267
+
268
+ // Provide the shared state so child components (active filters, columns menu, rows
269
+ // info, table…) can inject it instead of receiving a wall of props.
270
+ provideTabularContext({
271
+ tableData,
272
+ totalLines,
273
+ allRows,
274
+ hasMore,
275
+ loadNextPage,
276
+ isRefreshing,
277
+ sort,
278
+ filters,
279
+ activeFilters,
280
+ removeFilter,
281
+ clearAllFilters,
282
+ hasFilterForColumn,
283
+ allColumns,
284
+ visibleColumns,
285
+ displayedColumns,
286
+ hiddenCount,
287
+ toggleColumn,
288
+ showAllColumns,
289
+ hideAllColumns,
290
+ selectOnlyColumn,
291
+ mobileFilterOpen,
292
+ columnTypesMap,
293
+ getColumnType,
294
+ getColumnProfile,
295
+ getColumnDisplay,
296
+ getNullPercent,
297
+ getBooleanCounts,
298
+ })
299
+ </script>
@@ -0,0 +1,362 @@
1
+ <template>
2
+ <div>
3
+ <!-- Sort -->
4
+ <div class="flex items-center gap-1 px-2 py-1.5 border-b border-black/10 text-xs">
5
+ <span class="text-gray-plain">{{ t('Trier') }}</span>
6
+ <BrandedButton
7
+ :color="sort?.column === column && sort?.direction === 'asc' ? 'primary' : 'tertiary'"
8
+ size="2xs"
9
+ :icon="RiArrowUpLine"
10
+ keep-margins-even-without-borders
11
+ @click="toggleSort('asc')"
12
+ >
13
+ {{ t('Croissant') }}
14
+ </BrandedButton>
15
+ <BrandedButton
16
+ :color="sort?.column === column && sort?.direction === 'desc' ? 'primary' : 'tertiary'"
17
+ size="2xs"
18
+ :icon="RiArrowDownLine"
19
+ keep-margins-even-without-borders
20
+ @click="toggleSort('desc')"
21
+ >
22
+ {{ t('Décroissant') }}
23
+ </BrandedButton>
24
+ </div>
25
+
26
+ <!-- Null filter with progress bar -->
27
+ <div
28
+ v-if="columnProfile && columnProfile.nb_missing_values > 0"
29
+ class="px-3 py-2 border-b border-black/10 space-y-1.5"
30
+ >
31
+ <div class="flex items-center gap-2">
32
+ <span class="text-xs text-gray-plain whitespace-nowrap">
33
+ <span class="font-mono tabular-nums">{{ columnProfile.nb_missing_values }}</span>
34
+ null
35
+ <span class="text-gray-low">({{ nullPercent }})</span>
36
+ </span>
37
+ <ProgressBar
38
+ :value="columnProfile.nb_missing_values"
39
+ :max="totalLines"
40
+ bar-class="bg-gray-low"
41
+ class="flex-1 !h-1.5 !min-w-0 !border-0 !bg-gray-default"
42
+ />
43
+ </div>
44
+ <div class="flex items-center justify-end gap-0.5">
45
+ <BrandedButton
46
+ :color="nullFilter === 'only' ? 'primary' : 'tertiary'"
47
+ size="2xs"
48
+ keep-margins-even-without-borders
49
+ @click="toggleNullFilter('only')"
50
+ >
51
+ {{ t('seul.') }}
52
+ </BrandedButton>
53
+ <BrandedButton
54
+ :color="nullFilter === 'exclude' ? 'primary' : 'tertiary'"
55
+ size="2xs"
56
+ keep-margins-even-without-borders
57
+ @click="toggleNullFilter('exclude')"
58
+ >
59
+ {{ t('exclure') }}
60
+ </BrandedButton>
61
+ </div>
62
+ </div>
63
+
64
+ <!-- Search (contains) or date filter -->
65
+ <div
66
+ v-if="columnType !== 'boolean'"
67
+ class="px-3 py-2 border-b border-black/10"
68
+ >
69
+ <div class="relative">
70
+ <component
71
+ :is="searchField.icon"
72
+ class="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-gray-medium"
73
+ aria-hidden="true"
74
+ />
75
+ <input
76
+ v-model="search"
77
+ :type="searchField.type"
78
+ class="w-full h-8 text-sm border border-transparent rounded-lg py-1 pl-8 pr-3 bg-[#f3f3f5] focus:outline-none focus:border-new-primary"
79
+ :placeholder="searchField.placeholder"
80
+ >
81
+ </div>
82
+ </div>
83
+
84
+ <!-- Categorical values -->
85
+ <div
86
+ v-if="columnType === 'categorical' && columnProfile?.tops && filteredTops.length"
87
+ class="max-h-56 overflow-auto p-1"
88
+ >
89
+ <button
90
+ v-for="top in filteredTops"
91
+ :key="top.value"
92
+ class="flex w-full items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 cursor-pointer text-xs select-none"
93
+ :class="isValueSelected(top.value) && 'bg-gray-50'"
94
+ @click="toggleValue(top.value)"
95
+ >
96
+ <span
97
+ class="flex size-4 shrink-0 items-center justify-center rounded border"
98
+ :class="isValueSelected(top.value) ? 'border-new-primary bg-new-primary text-white' : 'border-gray-low'"
99
+ >
100
+ <RiCheckLine
101
+ v-if="isValueSelected(top.value)"
102
+ class="size-3"
103
+ aria-hidden="true"
104
+ />
105
+ </span>
106
+ <span class="flex-1 truncate text-left text-xs">
107
+ {{ top.value ?? 'null' }}
108
+ </span>
109
+ <span class="font-mono text-xs text-gray-low tabular-nums shrink-0">{{ top.count }}</span>
110
+ </button>
111
+ </div>
112
+
113
+ <!-- Boolean filter -->
114
+ <div
115
+ v-if="columnType === 'boolean'"
116
+ class="px-3 py-3 space-y-1.5"
117
+ >
118
+ <button
119
+ class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-colors"
120
+ :class="booleanFilter === true ? 'bg-new-primary text-white' : 'bg-gray-some text-gray-title hover:bg-gray-default'"
121
+ @click="toggleBooleanFilter(true)"
122
+ >
123
+ <span
124
+ class="size-2.5 rounded-full shrink-0"
125
+ :class="booleanFilter === true ? 'bg-new-success-light' : 'bg-new-success'"
126
+ />
127
+ <span class="flex-1 text-left">{{ t('Vrai') }}</span>
128
+ <span
129
+ v-if="booleanCounts"
130
+ class="font-mono tabular-nums text-xs"
131
+ :class="booleanFilter === true ? 'text-white/70' : 'text-gray-low'"
132
+ >{{ booleanCounts.trueCount }}</span>
133
+ </button>
134
+ <button
135
+ class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-colors"
136
+ :class="booleanFilter === false ? 'bg-new-primary text-white' : 'bg-gray-some text-gray-title hover:bg-gray-default'"
137
+ @click="toggleBooleanFilter(false)"
138
+ >
139
+ <span
140
+ class="size-2.5 rounded-full shrink-0"
141
+ :class="booleanFilter === false ? 'bg-new-warning-light' : 'bg-new-error'"
142
+ />
143
+ <span class="flex-1 text-left">{{ t('Faux') }}</span>
144
+ <span
145
+ v-if="booleanCounts"
146
+ class="font-mono tabular-nums text-xs"
147
+ :class="booleanFilter === false ? 'text-white/70' : 'text-gray-low'"
148
+ >{{ booleanCounts.falseCount }}</span>
149
+ </button>
150
+ </div>
151
+
152
+ <!-- Number range -->
153
+ <form
154
+ v-if="(columnType === 'number' || columnType === 'year') && columnProfile"
155
+ class="px-3 py-2 border-b border-black/10 space-y-2"
156
+ @submit.prevent="applyRange"
157
+ >
158
+ <div class="flex items-center gap-2 text-xs text-gray-plain">
159
+ <span class="tabular-nums">{{ columnType === 'year' ? profileMin : formatNumber(profileMin) }}</span>
160
+ <span class="text-gray-medium">—</span>
161
+ <span class="tabular-nums">{{ columnType === 'year' ? profileMax : formatNumber(profileMax) }}</span>
162
+ </div>
163
+ <div class="flex items-center gap-2">
164
+ <input
165
+ v-model.number="rangeMin"
166
+ type="number"
167
+ step="any"
168
+ class="w-full h-7 text-xs border border-black/10 rounded px-2 bg-[#f3f3f5] focus:outline-none focus:border-new-primary tabular-nums"
169
+ :placeholder="String(profileMin)"
170
+ :min="profileMin"
171
+ :max="profileMax"
172
+ >
173
+ <span class="text-gray-medium text-xs">–</span>
174
+ <input
175
+ v-model.number="rangeMax"
176
+ type="number"
177
+ step="any"
178
+ class="w-full h-7 text-xs border border-black/10 rounded px-2 bg-[#f3f3f5] focus:outline-none focus:border-new-primary tabular-nums"
179
+ :placeholder="String(profileMax)"
180
+ :min="profileMin"
181
+ :max="profileMax"
182
+ >
183
+ </div>
184
+ <div class="flex items-center gap-2">
185
+ <BrandedButton
186
+ color="primary"
187
+ size="2xs"
188
+ type="submit"
189
+ >
190
+ {{ t('Appliquer') }}
191
+ </BrandedButton>
192
+ <BrandedButton
193
+ color="tertiary"
194
+ size="2xs"
195
+ type="button"
196
+ keep-margins-even-without-borders
197
+ @click="clearRange"
198
+ >
199
+ {{ t('Effacer') }}
200
+ </BrandedButton>
201
+ </div>
202
+ </form>
203
+ </div>
204
+ </template>
205
+
206
+ <script setup lang="ts">
207
+ import { computed, ref } from 'vue'
208
+ import { watchDebounced } from '@vueuse/core'
209
+ import {
210
+ RiArrowUpLine,
211
+ RiArrowDownLine,
212
+ RiSearchLine,
213
+ RiCalendarLine,
214
+ RiCheckLine,
215
+ } from '@remixicon/vue'
216
+ import { useTranslation } from '../../composables/useTranslation'
217
+ import { useFormatTabular } from '../../functions/tabular'
218
+ import BrandedButton from '../BrandedButton.vue'
219
+ import ProgressBar from '../ProgressBar.vue'
220
+ import type { TabularColumnProfile, ColumnType, ColumnFilters, SortConfig, SortDirection } from './types'
221
+
222
+ const props = defineProps<{
223
+ column: string
224
+ columnType: ColumnType
225
+ columnProfile: TabularColumnProfile | null
226
+ nullPercent: string
227
+ totalLines: number
228
+ booleanCounts?: { trueCount: number, falseCount: number }
229
+ }>()
230
+
231
+ const sort = defineModel<SortConfig | null>('sort')
232
+ const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })
233
+
234
+ const { t } = useTranslation()
235
+ const { formatNumber } = useFormatTabular()
236
+
237
+ const search = ref('')
238
+
239
+ // Numbers, years and dates are matched exactly (the API has no `contains` for
240
+ // them), so the field offers the matching native picker instead of a text search.
241
+ const searchField = computed(() => {
242
+ switch (props.columnType) {
243
+ case 'date': return { icon: RiCalendarLine, type: 'date', placeholder: '' }
244
+ case 'number':
245
+ case 'year': return { icon: RiSearchLine, type: 'number', placeholder: t('Rechercher...') }
246
+ default: return { icon: RiSearchLine, type: 'text', placeholder: t('Rechercher...') }
247
+ }
248
+ })
249
+
250
+ watchDebounced(search, (q) => {
251
+ const existing = filters.value[props.column] ?? {}
252
+ const operator = props.columnType === 'number' || props.columnType === 'year' || props.columnType === 'date' ? 'exact' : 'contains'
253
+ if (q) {
254
+ filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: q } }
255
+ }
256
+ else {
257
+ const { [operator]: _, ...rest } = existing
258
+ filters.value = { ...filters.value, [props.column]: rest }
259
+ }
260
+ }, { debounce: 300 })
261
+
262
+ // Null filter helpers
263
+ const nullFilter = computed(() => filters.value[props.column]?.null ?? null)
264
+
265
+ function toggleNullFilter(mode: 'only' | 'exclude') {
266
+ const existing = filters.value[props.column] ?? {}
267
+ if (existing.null === mode) {
268
+ const { null: _, ...rest } = existing
269
+ filters.value = { ...filters.value, [props.column]: rest }
270
+ }
271
+ else {
272
+ filters.value = { ...filters.value, [props.column]: { ...existing, null: mode } }
273
+ }
274
+ }
275
+
276
+ // Categorical filter helpers
277
+ const selectedValues = computed(() => filters.value[props.column]?.in ?? [])
278
+
279
+ const filteredTops = computed(() => {
280
+ if (!props.columnProfile?.tops) return []
281
+ if (!search.value) return props.columnProfile.tops
282
+ const q = search.value.toLowerCase()
283
+ return props.columnProfile.tops.filter(top =>
284
+ (top.value ?? '').toLowerCase().includes(q),
285
+ )
286
+ })
287
+
288
+ function isValueSelected(value: string) {
289
+ return selectedValues.value.includes(value)
290
+ }
291
+
292
+ function toggleValue(value: string) {
293
+ const current = selectedValues.value
294
+ const next = current.includes(value)
295
+ ? current.filter(v => v !== value)
296
+ : [...current, value]
297
+ const existing = filters.value[props.column] ?? {}
298
+ filters.value = { ...filters.value, [props.column]: { ...existing, in: next } }
299
+ }
300
+
301
+ // Number range filter helpers
302
+ const profileMin = computed(() => props.columnProfile?.min ?? 0)
303
+ const profileMax = computed(() => props.columnProfile?.max ?? 100)
304
+ const rangeMin = ref<number | undefined>(undefined)
305
+ const rangeMax = ref<number | undefined>(undefined)
306
+
307
+ function applyRange() {
308
+ const existing = filters.value[props.column] ?? {}
309
+ const next = { ...existing }
310
+ if (Number.isFinite(rangeMin.value)) {
311
+ next.min = rangeMin.value
312
+ }
313
+ else {
314
+ delete next.min
315
+ }
316
+ if (Number.isFinite(rangeMax.value)) {
317
+ next.max = rangeMax.value
318
+ }
319
+ else {
320
+ delete next.max
321
+ }
322
+ filters.value = { ...filters.value, [props.column]: next }
323
+ }
324
+
325
+ function clearRange() {
326
+ rangeMin.value = undefined
327
+ rangeMax.value = undefined
328
+ const existing = filters.value[props.column]
329
+ if (existing) {
330
+ const { min: _min, max: _max, ...rest } = existing
331
+ filters.value = { ...filters.value, [props.column]: rest }
332
+ }
333
+ }
334
+
335
+ // Boolean filter helpers
336
+ const booleanFilter = computed<boolean | null>(() => {
337
+ const f = filters.value[props.column]
338
+ if (!f || f.exact == null) return null
339
+ return f.exact === 'true'
340
+ })
341
+
342
+ function toggleBooleanFilter(value: boolean) {
343
+ const existing = filters.value[props.column] ?? {}
344
+ const strValue = String(value)
345
+ if (existing.exact === strValue) {
346
+ const { exact: _, ...rest } = existing
347
+ filters.value = { ...filters.value, [props.column]: rest }
348
+ }
349
+ else {
350
+ filters.value = { ...filters.value, [props.column]: { ...existing, exact: strValue } }
351
+ }
352
+ }
353
+
354
+ function toggleSort(direction: SortDirection) {
355
+ if (sort.value?.column === props.column && sort.value.direction === direction) {
356
+ sort.value = null
357
+ }
358
+ else {
359
+ sort.value = { column: props.column, direction }
360
+ }
361
+ }
362
+ </script>