@datagouv/components-next 1.0.2-dev.18 → 1.0.2-dev.181
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-CaxMyNif.js} +9 -9
- package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
- package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
- package/dist/ImagePreview.client-CPmCyisP.js +36 -0
- package/dist/JsonPreview.client-CKOU-LBs.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
- package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-IGYJWP5m.js} +41 -42
- package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-csUebU3A.js} +835 -856
- package/dist/Pmtiles.client-CLq7UHBg.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-DeoHZXW1.js +60 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
- package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
- package/dist/XmlPreview.client-C1toAbQ0.js +34 -0
- package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +193 -149
- package/dist/components.css +2 -2
- package/dist/{index-BFQX7Ccz.js → index-CaUlhl3k.js} +21842 -15810
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-ByYGrf8H.js} +185 -187
- package/dist/{main-vqhkVhVS.js → main-DD4ieUAo.js} +41500 -73223
- package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-B1KPK4L4.js} +16 -16
- package/package.json +47 -38
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +3 -2
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +320 -0
- package/src/components/Chart/ChartViewerWrapper.vue +182 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +9 -0
- package/src/components/DataserviceQuality.vue +82 -0
- package/src/components/DataserviceQualityInline.vue +31 -0
- package/src/components/DataserviceQualityTooltipContent.vue +79 -0
- package/src/components/DatasetCard.vue +9 -4
- package/src/components/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- package/src/components/Form/Listbox.vue +101 -0
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/ObjectCardHeader.vue +11 -4
- package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
- package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
- package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
- package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
- package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
- package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
- package/src/components/OpenApiViewer/openapi.ts +150 -0
- package/src/components/OrganizationNameWithCertificate.vue +3 -2
- package/src/components/Pagination.vue +8 -5
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
- package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
- package/src/components/ResourceAccordion/Preview.vue +11 -11
- package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
- package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
- package/src/components/ResourceListItem.vue +154 -0
- package/src/components/ReuseCard.vue +14 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +2 -2
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +219 -117
- package/src/components/Search/SearchInput.vue +5 -4
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +50 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
- 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 +481 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +81 -0
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +15 -1
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +47 -21
- package/src/composables/useSearchFilter.ts +120 -0
- package/src/composables/useStableQueryParams.ts +38 -6
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/config.ts +21 -3
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +20 -42
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +99 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/dates.ts +3 -2
- package/src/functions/metrics.ts +6 -4
- package/src/functions/resources.ts +136 -1
- package/src/functions/schemas.ts +27 -0
- package/src/functions/tabular.ts +214 -0
- package/src/functions/tabularApi.ts +96 -11
- package/src/main.ts +117 -14
- package/src/types/dataservices.ts +17 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/pages.ts +0 -5
- package/src/types/posts.ts +2 -2
- package/src/types/reports.ts +5 -1
- package/src/types/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +63 -1
- package/src/types/site.ts +5 -3
- package/src/types/ui.ts +2 -0
- package/src/types/users.ts +2 -1
- package/src/types/visualizations.ts +93 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
- package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
- package/dist/Swagger.client-B4y0hXME.js +0 -4
- package/dist/XmlPreview.client-LETEfNBH.js +0 -64
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
- /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
- /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
|
@@ -0,0 +1,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>
|