@datagouv/components-next 1.0.2-dev.163 → 1.0.2-dev.165
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/{Datafair.client-DXPU1V24.js → Datafair.client-B-OG5WtK.js} +1 -1
- package/dist/ImagePreview.client-D0c-ifUi.js +36 -0
- package/dist/{JsonPreview.client-D1SnGfWG.js → JsonPreview.client-Dwh9sW9M.js} +2 -2
- package/dist/{MapContainer.client-DlsaEVGx.js → MapContainer.client-C2AZ7sOB.js} +2 -2
- package/dist/{PdfPreview.client-C9OrLR0z.js → PdfPreview.client-8NzNfW8b.js} +2 -2
- package/dist/{Pmtiles.client-CyylWfTy.js → Pmtiles.client-BFH4rTB7.js} +5842 -5660
- package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-CtXZOHXI.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-BOgE441d.js} +7 -10
- package/dist/{XmlPreview.client-DoX7RurH.js → XmlPreview.client-DMOUO508.js} +3 -3
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +179 -171
- package/dist/components.css +1 -1
- package/dist/{index-Br1sa7wQ.js → index-C9obT9DJ.js} +4392 -4293
- package/dist/{leaflet-src-DWG7tmpQ.js → leaflet-src-B4AZU0vr.js} +1 -1
- package/dist/{main-BU6kP4dE.js → main-DNoYWfit.js} +16159 -15631
- package/dist/{vue3-xml-viewer.common-Cpui-Yi9.js → vue3-xml-viewer.common-Cp7Cu5ME.js} +1 -1
- package/package.json +4 -5
- package/src/components/Chart/ChartViewer.vue +103 -10
- package/src/components/Chart/ChartViewerWrapper.vue +8 -0
- package/src/components/DataserviceCard.vue +6 -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/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +8 -3
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +15 -1
- package/src/components/ReuseCard.vue +2 -2
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +2 -2
- package/src/components/TabularExplorer/TabularCell.vue +5 -4
- package/src/components/TabularExplorer/TabularCellPopover.vue +1 -1
- package/src/components/TabularExplorer/TabularExplorer.vue +168 -49
- package/src/components/TabularExplorer/TabularFilterContent.vue +27 -10
- package/src/components/TabularExplorer/TabularFilterPopover.vue +1 -0
- package/src/components/TabularExplorer/types.ts +3 -3
- package/src/composables/useHasTabularData.ts +2 -1
- package/src/composables/useResourceCapabilities.ts +27 -14
- package/src/config.ts +1 -0
- package/src/functions/charts.ts +23 -3
- package/src/functions/resources.ts +56 -0
- package/src/functions/tabular.ts +44 -1
- package/src/main.ts +15 -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 +1 -1
- /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
|
@@ -122,6 +122,11 @@
|
|
|
122
122
|
v-if="ogcWms"
|
|
123
123
|
:resource="resource"
|
|
124
124
|
/>
|
|
125
|
+
<PreviewUnavailable v-if="!hasPmtiles && !ogcWms && hasPmtilesError">
|
|
126
|
+
{{ t("La carte n'a pas pu être générée automatiquement pour ce fichier.") }}
|
|
127
|
+
<br>
|
|
128
|
+
<span class="text-gray-medium text-xs">{{ pmtilesError }}</span>
|
|
129
|
+
</PreviewUnavailable>
|
|
125
130
|
</div>
|
|
126
131
|
<div v-if="tab.key === 'data'">
|
|
127
132
|
<JsonPreview
|
|
@@ -136,6 +141,10 @@
|
|
|
136
141
|
v-else-if="resource.format && resource.format.toLowerCase() === 'xml'"
|
|
137
142
|
:resource="resource"
|
|
138
143
|
/>
|
|
144
|
+
<ImagePreview
|
|
145
|
+
v-else-if="isImagePreviewFormat(resource.format)"
|
|
146
|
+
:resource="resource"
|
|
147
|
+
/>
|
|
139
148
|
<DatafairPreview
|
|
140
149
|
v-else-if="hasDatafairPreview"
|
|
141
150
|
:resource="resource"
|
|
@@ -229,7 +238,7 @@ import Metadata from '../ResourceAccordion/Metadata.vue'
|
|
|
229
238
|
import SchemaBadge from '../ResourceAccordion/SchemaBadge.vue'
|
|
230
239
|
import ResourceSelector from './ResourceSelector.vue'
|
|
231
240
|
import { filesize, summarize } from '../../functions/helpers'
|
|
232
|
-
import { getResourceFormatIcon, getResourceExternalUrl, getResourceFilesize } from '../../functions/resources'
|
|
241
|
+
import { getResourceFormatIcon, getResourceExternalUrl, getResourceFilesize, isImagePreviewFormat } from '../../functions/resources'
|
|
233
242
|
import { trackEvent } from '../../functions/matomo'
|
|
234
243
|
import { useComponentsConfig } from '../../config'
|
|
235
244
|
import { useFormatDate } from '../../functions/dates'
|
|
@@ -248,6 +257,9 @@ const PdfPreview = defineAsyncComponent(() =>
|
|
|
248
257
|
const XmlPreview = defineAsyncComponent(() =>
|
|
249
258
|
import('../ResourceAccordion/XmlPreview.client.vue'),
|
|
250
259
|
)
|
|
260
|
+
const ImagePreview = defineAsyncComponent(() =>
|
|
261
|
+
import('../ResourceAccordion/ImagePreview.client.vue'),
|
|
262
|
+
)
|
|
251
263
|
const DatafairPreview = defineAsyncComponent(() =>
|
|
252
264
|
import('../ResourceAccordion/Datafair.client.vue'),
|
|
253
265
|
)
|
|
@@ -275,6 +287,8 @@ const { formatRelativeIfRecentDate } = useFormatDate()
|
|
|
275
287
|
const {
|
|
276
288
|
hasTabularData,
|
|
277
289
|
hasPmtiles,
|
|
290
|
+
hasPmtilesError,
|
|
291
|
+
pmtilesError,
|
|
278
292
|
hasDatafairPreview,
|
|
279
293
|
hasOpenAPIPreview,
|
|
280
294
|
ogcService,
|
|
@@ -68,7 +68,7 @@ import { RiLockLine, RiSubtractLine } from '@remixicon/vue'
|
|
|
68
68
|
import { computed } from 'vue'
|
|
69
69
|
import type { RouteLocationRaw } from 'vue-router'
|
|
70
70
|
import { useFormatDate } from '../functions/dates'
|
|
71
|
-
import type { Reuse } from '../types/reuses'
|
|
71
|
+
import type { Reuse, ReuseV2 } from '../types/reuses'
|
|
72
72
|
import type { TitleTag } from '../types/ui'
|
|
73
73
|
import { useTranslation } from '../composables/useTranslation'
|
|
74
74
|
import AppLink from './AppLink.vue'
|
|
@@ -77,7 +77,7 @@ import ReuseDetails from './ReuseDetails.vue'
|
|
|
77
77
|
import Placeholder from './Placeholder.vue'
|
|
78
78
|
|
|
79
79
|
const props = withDefaults(defineProps<{
|
|
80
|
-
reuse: Reuse
|
|
80
|
+
reuse: Reuse | ReuseV2
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* The reuseUrl is a route location object to allow Vue Router to navigate to the details of a reuse.
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
import { RiEyeLine, RiStarLine, RiSubtractLine } from '@remixicon/vue'
|
|
33
33
|
import { useReuseType } from '../composables/useReuseType'
|
|
34
34
|
import { summarize } from '../functions/helpers'
|
|
35
|
-
import type { Reuse } from '../types/reuses'
|
|
35
|
+
import type { Reuse, ReuseV2 } from '../types/reuses'
|
|
36
36
|
import { useTranslation } from '../composables/useTranslation'
|
|
37
37
|
|
|
38
38
|
const props = defineProps<{
|
|
39
|
-
reuse: Reuse
|
|
39
|
+
reuse: Reuse | ReuseV2
|
|
40
40
|
}>()
|
|
41
41
|
|
|
42
42
|
const { t } = useTranslation()
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
<script setup lang="ts">
|
|
61
61
|
import { RiArchiveLine, RiLineChartLine, RiLockLine } from '@remixicon/vue'
|
|
62
62
|
import type { RouteLocationRaw } from 'vue-router'
|
|
63
|
-
import type { Reuse } from '../types/reuses'
|
|
63
|
+
import type { Reuse, ReuseV2 } from '../types/reuses'
|
|
64
64
|
import { useTranslation } from '../composables/useTranslation'
|
|
65
65
|
import ReuseDetails from './ReuseDetails.vue'
|
|
66
66
|
import Placeholder from './Placeholder.vue'
|
|
@@ -71,7 +71,7 @@ import ObjectCardShortDescription from './ObjectCardShortDescription.vue'
|
|
|
71
71
|
import ObjectCardBadge from './ObjectCardBadge.vue'
|
|
72
72
|
|
|
73
73
|
defineProps<{
|
|
74
|
-
reuse: Reuse
|
|
74
|
+
reuse: Reuse | ReuseV2
|
|
75
75
|
reuseUrl?: RouteLocationRaw
|
|
76
76
|
organizationUrl?: RouteLocationRaw
|
|
77
77
|
}>()
|
|
@@ -266,7 +266,7 @@
|
|
|
266
266
|
name="reuse"
|
|
267
267
|
:reuse="result"
|
|
268
268
|
>
|
|
269
|
-
<ReuseHorizontalCard :reuse="(result as
|
|
269
|
+
<ReuseHorizontalCard :reuse="(result as ReuseV2)" />
|
|
270
270
|
</slot>
|
|
271
271
|
</template>
|
|
272
272
|
<template v-else-if="currentTypeConfig?.class === 'organizations'">
|
|
@@ -368,7 +368,7 @@ import type { AsyncDataRequestStatus } from '../../functions/api.types'
|
|
|
368
368
|
import type { Dataset } from '../../types/datasets'
|
|
369
369
|
import type { Dataservice } from '../../types/dataservices'
|
|
370
370
|
import type { Organization } from '../../types/organizations'
|
|
371
|
-
import type {
|
|
371
|
+
import type { ReuseV2 } from '../../types/reuses'
|
|
372
372
|
import type { TopicV2 } from '../../types/topics'
|
|
373
373
|
import type { GlobalSearchConfig, SearchResponseByClass, SearchType, SortOption, FacetItem } from '../../types/search'
|
|
374
374
|
import { getDefaultGlobalSearchConfig } from '../../types/search'
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
:style="categoryBadgeStyle ? { backgroundColor: categoryBadgeStyle.backgroundColor, color: categoryBadgeStyle.color } : undefined"
|
|
22
22
|
>{{ value }}</span>
|
|
23
23
|
<span
|
|
24
|
-
v-else-if="columnType === 'number'"
|
|
25
|
-
:class="compact ? 'font-mono tabular-nums text-xs
|
|
26
|
-
>{{ formatNumber(value) }}</span>
|
|
24
|
+
v-else-if="columnType === 'number' || columnType === 'year'"
|
|
25
|
+
:class="compact ? 'font-mono tabular-nums text-xs' : ''"
|
|
26
|
+
>{{ columnType === 'year' || noNumberFormat ? value : formatNumber(value) }}</span>
|
|
27
27
|
<span
|
|
28
28
|
v-else-if="columnType === 'date'"
|
|
29
|
-
:class="compact ? 'font-mono tabular-nums text-xs
|
|
29
|
+
:class="compact ? 'font-mono tabular-nums text-xs' : ''"
|
|
30
30
|
>{{ formatCellDate(value) }}</span>
|
|
31
31
|
<span
|
|
32
32
|
v-else
|
|
@@ -44,6 +44,7 @@ defineProps<{
|
|
|
44
44
|
columnType: ColumnType
|
|
45
45
|
categoryBadgeStyle?: BadgeStyle
|
|
46
46
|
compact?: boolean
|
|
47
|
+
noNumberFormat?: boolean
|
|
47
48
|
}>()
|
|
48
49
|
|
|
49
50
|
const { t } = useTranslation()
|
|
@@ -128,7 +128,7 @@ function filterByValue() {
|
|
|
128
128
|
const val = String(cell.value.value ?? '')
|
|
129
129
|
const col = cell.value.column
|
|
130
130
|
const existing = filters.value[col] ?? {}
|
|
131
|
-
if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'date') {
|
|
131
|
+
if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'date' || cell.value.columnType === 'year') {
|
|
132
132
|
const current = existing.in ?? []
|
|
133
133
|
if (!current.includes(val)) {
|
|
134
134
|
filters.value = { ...filters.value, [col]: { ...existing, in: [...current, val] } }
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div>
|
|
3
|
+
<!-- Outside the branches below: this slot holds the query controls (search
|
|
4
|
+
input…), so it has to stay reachable when the query itself failed. -->
|
|
5
|
+
<slot name="toolbar-top" />
|
|
6
|
+
|
|
3
7
|
<div
|
|
4
8
|
v-if="previewError"
|
|
5
9
|
class="max-w-3xl mx-auto"
|
|
@@ -162,12 +166,24 @@
|
|
|
162
166
|
|
|
163
167
|
<!-- Rows -->
|
|
164
168
|
<span class="flex items-center gap-1.5 text-xs text-gray-plain">
|
|
169
|
+
<RiLoader5Line
|
|
170
|
+
v-if="isRefreshing"
|
|
171
|
+
class="size-3 text-new-primary animate-spin"
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
/>
|
|
165
174
|
<RiLayoutRowLine
|
|
175
|
+
v-else
|
|
166
176
|
class="size-3 text-mention-grey"
|
|
167
177
|
aria-hidden="true"
|
|
168
178
|
/>
|
|
169
179
|
<span class="font-bold hidden md:inline">{{ t('Lignes') }}</span>
|
|
170
|
-
|
|
180
|
+
<!-- The count stays in place while refreshing: the spinner already
|
|
181
|
+
says it is stale, and swapping it for a word of another width
|
|
182
|
+
would shift the whole toolbar on every search. -->
|
|
183
|
+
<span
|
|
184
|
+
data-testid="row-count"
|
|
185
|
+
class="font-mono tabular-nums"
|
|
186
|
+
>{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
|
|
171
187
|
</span>
|
|
172
188
|
</div>
|
|
173
189
|
</div>
|
|
@@ -231,6 +247,7 @@
|
|
|
231
247
|
<span
|
|
232
248
|
v-for="af in activeFilters"
|
|
233
249
|
:key="af.column"
|
|
250
|
+
:data-testid="`active-filter-${af.column}`"
|
|
234
251
|
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
252
|
>
|
|
236
253
|
<component
|
|
@@ -292,8 +309,12 @@
|
|
|
292
309
|
<div
|
|
293
310
|
v-if="displayedColumns.length > 0"
|
|
294
311
|
ref="scrollContainer"
|
|
295
|
-
class="hidden md:block overflow-auto max-h-[70vh]"
|
|
296
|
-
:class="
|
|
312
|
+
class="hidden md:block overflow-auto max-h-[70vh] transition-opacity"
|
|
313
|
+
:class="[
|
|
314
|
+
fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4',
|
|
315
|
+
{ 'opacity-40': isRefreshing },
|
|
316
|
+
]"
|
|
317
|
+
:aria-busy="isRefreshing"
|
|
297
318
|
>
|
|
298
319
|
<table class="text-sm border-collapse">
|
|
299
320
|
<thead class="sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]">
|
|
@@ -380,14 +401,20 @@
|
|
|
380
401
|
:key="col"
|
|
381
402
|
data-cell
|
|
382
403
|
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' }"
|
|
404
|
+
:class="{ 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' }"
|
|
384
405
|
:style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
|
|
385
406
|
@click="onCellClick(col, row[col], $event)"
|
|
386
407
|
>
|
|
408
|
+
<AppLink
|
|
409
|
+
v-if="isLinkColumn(col)"
|
|
410
|
+
:to="getRowHref(row)"
|
|
411
|
+
class="link"
|
|
412
|
+
>
|
|
413
|
+
<TabularCell v-bind="cellProps(col, row)" />
|
|
414
|
+
</AppLink>
|
|
387
415
|
<TabularCell
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
:category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
|
|
416
|
+
v-else
|
|
417
|
+
v-bind="cellProps(col, row)"
|
|
391
418
|
/>
|
|
392
419
|
</td>
|
|
393
420
|
</tr>
|
|
@@ -409,7 +436,9 @@
|
|
|
409
436
|
<!-- Mobile: card layout -->
|
|
410
437
|
<div
|
|
411
438
|
v-if="displayedColumns.length > 0"
|
|
412
|
-
class="md:hidden space-y-2 px-1"
|
|
439
|
+
class="md:hidden space-y-2 px-1 transition-opacity"
|
|
440
|
+
:class="{ 'opacity-40': isRefreshing }"
|
|
441
|
+
:aria-busy="isRefreshing"
|
|
413
442
|
>
|
|
414
443
|
<div
|
|
415
444
|
v-if="allRows.length === 0"
|
|
@@ -445,15 +474,26 @@
|
|
|
445
474
|
:title="col"
|
|
446
475
|
>{{ col }}</span>
|
|
447
476
|
</div>
|
|
477
|
+
<!-- Numbers and dates take their colour from here rather than from
|
|
478
|
+
the cell, so a link wrapping one (see `rowHref`) keeps its own. -->
|
|
448
479
|
<div
|
|
449
480
|
data-cell
|
|
450
|
-
class="min-w-0 pl-4 cursor-pointer"
|
|
481
|
+
class="min-w-0 pl-4 cursor-pointer text-gray-title"
|
|
451
482
|
@click="onCellClick(col, row[col], $event)"
|
|
452
483
|
>
|
|
484
|
+
<AppLink
|
|
485
|
+
v-if="isLinkColumn(col)"
|
|
486
|
+
:to="getRowHref(row)"
|
|
487
|
+
class="link"
|
|
488
|
+
>
|
|
489
|
+
<TabularCell
|
|
490
|
+
v-bind="cellProps(col, row)"
|
|
491
|
+
compact
|
|
492
|
+
/>
|
|
493
|
+
</AppLink>
|
|
453
494
|
<TabularCell
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
:category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
|
|
495
|
+
v-else
|
|
496
|
+
v-bind="cellProps(col, row)"
|
|
457
497
|
compact
|
|
458
498
|
/>
|
|
459
499
|
</div>
|
|
@@ -597,6 +637,7 @@
|
|
|
597
637
|
<script setup lang="ts">
|
|
598
638
|
import { computed, nextTick, onUnmounted, ref, watch, useTemplateRef } from 'vue'
|
|
599
639
|
import { ofetch } from 'ofetch'
|
|
640
|
+
import { useElementSize } from '@vueuse/core'
|
|
600
641
|
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
|
|
601
642
|
import { Dialog, DialogPanel, DialogTitle, Popover, PopoverButton, PopoverPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
|
602
643
|
import {
|
|
@@ -608,12 +649,14 @@ import {
|
|
|
608
649
|
RiFilterLine,
|
|
609
650
|
RiCloseLine,
|
|
610
651
|
RiSearchLine,
|
|
652
|
+
RiLoader5Line,
|
|
611
653
|
} from '@remixicon/vue'
|
|
612
654
|
import { useFetch } from '../../functions/api'
|
|
613
655
|
import { useComponentsConfig } from '../../config'
|
|
614
656
|
import { useTranslation } from '../../composables/useTranslation'
|
|
615
657
|
import { injectTabularProfile } from '../../composables/useTabularProfile'
|
|
616
|
-
import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy, resolveColumnType } from '../../functions/tabular'
|
|
658
|
+
import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy, resolveColumnType, buildGlobalSearchConditions } from '../../functions/tabular'
|
|
659
|
+
import AppLink from '../AppLink.vue'
|
|
617
660
|
import ClientOnly from '../ClientOnly.vue'
|
|
618
661
|
import SimpleBanner from '../SimpleBanner.vue'
|
|
619
662
|
import BrandedButton from '../BrandedButton.vue'
|
|
@@ -632,8 +675,42 @@ const props = defineProps<{
|
|
|
632
675
|
// the host's padding. Used on the standalone explore page where the table
|
|
633
676
|
// should span the whole screen while the toolbar stays inside the container.
|
|
634
677
|
fullBleed?: boolean
|
|
678
|
+
// When set, searches across multiple columns using the Tabular API's or(...)
|
|
679
|
+
// parameter. Text and categorical columns get a __contains filter; number
|
|
680
|
+
// columns get a __exact filter (since __contains is not supported for numbers
|
|
681
|
+
// by the API). Year, date and boolean columns are excluded.
|
|
682
|
+
// Note: combined via AND with any existing column-specific `contains` filters,
|
|
683
|
+
// so it acts as an additional narrowing constraint, not a replacement.
|
|
684
|
+
globalSearch?: string
|
|
685
|
+
// Initial filters applied on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
|
|
686
|
+
// Used when navigating from the detail page badge with query params.
|
|
687
|
+
initialFilters?: Record<string, ColumnFilters>
|
|
688
|
+
// When set, renders <a> tags inside the specified column cells for native
|
|
689
|
+
// browser UX (hover URL, ctrl+click, middle-click).
|
|
690
|
+
rowHref?: { columns: string[], href: (row: TabularRow) => string }
|
|
691
|
+
// Columns listed here render their values raw (no number formatting).
|
|
692
|
+
// Useful for identifier columns.
|
|
693
|
+
noFormatColumns?: string[]
|
|
635
694
|
}>()
|
|
636
695
|
|
|
696
|
+
function getRowHref(row: TabularRow): string | undefined {
|
|
697
|
+
return props.rowHref?.href(row)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function isLinkColumn(col: string): boolean {
|
|
701
|
+
return props.rowHref?.columns.includes(col) ?? false
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function cellProps(col: string, row: TabularRow) {
|
|
705
|
+
const columnType = getColumnType(col)
|
|
706
|
+
return {
|
|
707
|
+
value: row[col],
|
|
708
|
+
columnType,
|
|
709
|
+
categoryBadgeStyle: columnType === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined,
|
|
710
|
+
noNumberFormat: props.noFormatColumns?.includes(col),
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
637
714
|
const { t } = useTranslation()
|
|
638
715
|
const config = useComponentsConfig()
|
|
639
716
|
|
|
@@ -653,9 +730,39 @@ const dataUrl = computed(() =>
|
|
|
653
730
|
`${config.tabularApiUrl}/api/resources/${props.resourceId}/data/`,
|
|
654
731
|
)
|
|
655
732
|
|
|
733
|
+
// Profile is shared with sibling components (e.g. DataStructure) via
|
|
734
|
+
// `provideTabularProfile` in the parent. Falls back to a local fetch
|
|
735
|
+
// when no parent provides it (standalone usage).
|
|
736
|
+
// Fetched before the rows: `dataQuery` reads the column types to build the
|
|
737
|
+
// global search, so the profile has to be resolved by the time it is evaluated.
|
|
738
|
+
const { data: profileData, error: profileError, status: profileStatus } = await injectTabularProfile(() => props.resourceId)
|
|
739
|
+
|
|
740
|
+
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
|
|
741
|
+
|
|
742
|
+
// Column type helpers
|
|
743
|
+
function getColumnTypeFromName(col: string): ColumnType {
|
|
744
|
+
const profile = profileData.value?.profile
|
|
745
|
+
if (!profile) return 'text'
|
|
746
|
+
const colInfo = profile.columns[col]
|
|
747
|
+
if (!colInfo) return 'text'
|
|
748
|
+
return resolveColumnType(colInfo, profile.categorical.includes(col))
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const columnTypesMap = computed(() => {
|
|
752
|
+
const map: Record<string, ColumnType> = {}
|
|
753
|
+
for (const col of allColumns.value) {
|
|
754
|
+
map[col] = getColumnTypeFromName(col)
|
|
755
|
+
}
|
|
756
|
+
return map
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
function getColumnType(col: string): ColumnType {
|
|
760
|
+
return columnTypesMap.value[col] ?? 'text'
|
|
761
|
+
}
|
|
762
|
+
|
|
656
763
|
// Sort & filter state
|
|
657
764
|
const sort = ref<SortConfig | null>(null)
|
|
658
|
-
const filters = ref<Record<string, ColumnFilters>>({})
|
|
765
|
+
const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
|
|
659
766
|
|
|
660
767
|
const PAGE_SIZE = 50
|
|
661
768
|
|
|
@@ -687,15 +794,14 @@ const dataQuery = computed(() => {
|
|
|
687
794
|
q[`${col}__isnotnull`] = ''
|
|
688
795
|
}
|
|
689
796
|
}
|
|
797
|
+
if (props.globalSearch && profileData.value?.profile) {
|
|
798
|
+
const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
|
|
799
|
+
q.or = '(' + conditions.join(',') + ')'
|
|
800
|
+
}
|
|
690
801
|
return q
|
|
691
802
|
})
|
|
692
803
|
|
|
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)
|
|
804
|
+
const { data: tableData, error, status: dataStatus } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
|
|
699
805
|
|
|
700
806
|
// The component renders nothing useful until the profile is available
|
|
701
807
|
// (allColumns is derived from it). Surface a clear loading / error state
|
|
@@ -703,6 +809,9 @@ const { data: profileData, error: profileError, status: profileStatus } = await
|
|
|
703
809
|
const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
|
|
704
810
|
const previewError = computed(() => error.value || profileError.value)
|
|
705
811
|
const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
|
|
812
|
+
// A search / filter / sort change refetches while the previous rows stay on
|
|
813
|
+
// screen: without a signal, the table looks unchanged for several seconds.
|
|
814
|
+
const isRefreshing = computed(() => dataStatus.value === 'pending' && !previewLoading.value)
|
|
706
815
|
|
|
707
816
|
// Infinite scroll state
|
|
708
817
|
const allRows = ref<TabularRow[]>([])
|
|
@@ -742,8 +851,6 @@ async function loadNextPage() {
|
|
|
742
851
|
|
|
743
852
|
const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
|
|
744
853
|
|
|
745
|
-
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
|
|
746
|
-
|
|
747
854
|
const visibleColumns = ref(new Set(allColumns.value))
|
|
748
855
|
|
|
749
856
|
watch(allColumns, (cols) => {
|
|
@@ -832,18 +939,43 @@ function redistributeColumnWidths() {
|
|
|
832
939
|
columnWidths.value = widths
|
|
833
940
|
}
|
|
834
941
|
|
|
835
|
-
// Measure
|
|
836
|
-
//
|
|
942
|
+
// Measure the natural widths once (the <th> must still be auto-sized) and
|
|
943
|
+
// distribute them. No-op once measured or while no column is displayed.
|
|
944
|
+
function ensureMeasured() {
|
|
945
|
+
if (Object.keys(naturalWidths.value).length > 0) return
|
|
946
|
+
if (displayedColumns.value.length === 0) return
|
|
947
|
+
measureNaturalWidths()
|
|
948
|
+
redistributeColumnWidths()
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Drive measurement and re-stretch off the container's reactive width
|
|
952
|
+
// (`useElementSize` wraps a ResizeObserver and disconnects it on unmount). The
|
|
953
|
+
// width goes 0 -> N once the table is laid out in the DOM, which is also when
|
|
954
|
+
// the <thead> carries its content-driven natural widths: the first non-zero
|
|
955
|
+
// width measures the stable base, later changes (window resize) only
|
|
956
|
+
// redistribute it.
|
|
957
|
+
const { width: containerWidth } = useElementSize(scrollContainerRef)
|
|
958
|
+
|
|
959
|
+
watch(containerWidth, (width) => {
|
|
960
|
+
if (width === 0) return
|
|
961
|
+
if (Object.keys(naturalWidths.value).length === 0) {
|
|
962
|
+
ensureMeasured()
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
redistributeColumnWidths()
|
|
966
|
+
}
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
// Fallback: re-run once columns appear if they weren't available when the
|
|
970
|
+
// container was first sized (standalone usage where the profile resolves late).
|
|
837
971
|
watch(
|
|
838
972
|
() => [tableData.value !== null, displayedColumns.value.length] as const,
|
|
839
973
|
async ([dataReady, colCount]) => {
|
|
840
974
|
if (!dataReady || colCount === 0) return
|
|
841
975
|
if (Object.keys(naturalWidths.value).length > 0) return
|
|
842
976
|
await nextTick()
|
|
843
|
-
|
|
844
|
-
redistributeColumnWidths()
|
|
977
|
+
ensureMeasured()
|
|
845
978
|
},
|
|
846
|
-
{ immediate: true },
|
|
847
979
|
)
|
|
848
980
|
|
|
849
981
|
// Re-stretch the remaining columns whenever the displayed set changes (columns
|
|
@@ -912,6 +1044,9 @@ onUnmounted(() => {
|
|
|
912
1044
|
const activeCell = ref<CellInfo | null>(null)
|
|
913
1045
|
|
|
914
1046
|
function onCellClick(col: string, value: unknown, event: MouseEvent) {
|
|
1047
|
+
// A link inside a cell (see `rowHref`) owns its clicks: navigate, don't open
|
|
1048
|
+
// the popover on top of the page we're leaving.
|
|
1049
|
+
if ((event.target as HTMLElement).closest('a')) return
|
|
915
1050
|
const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
|
|
916
1051
|
if (!el) return
|
|
917
1052
|
if (activeCell.value && activeCell.value.element === el) {
|
|
@@ -935,7 +1070,12 @@ const activeFilters = computed<ActiveFilter[]>(() => {
|
|
|
935
1070
|
parts.push(`= ${filter.in.join(', ')}`)
|
|
936
1071
|
}
|
|
937
1072
|
if (filter.exact != null) {
|
|
938
|
-
|
|
1073
|
+
if (getColumnType(col) === 'boolean') {
|
|
1074
|
+
parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
parts.push(`= ${filter.exact}`)
|
|
1078
|
+
}
|
|
939
1079
|
}
|
|
940
1080
|
if (filter.contains) {
|
|
941
1081
|
parts.push(`${t('contient')} "${filter.contains}"`)
|
|
@@ -998,27 +1138,6 @@ function toggleMobileFilterColumn(col: string) {
|
|
|
998
1138
|
mobileFilterExpandedCol.value = mobileFilterExpandedCol.value === col ? null : col
|
|
999
1139
|
}
|
|
1000
1140
|
|
|
1001
|
-
// Column type helpers
|
|
1002
|
-
function getColumnTypeFromName(col: string): ColumnType {
|
|
1003
|
-
const profile = profileData.value?.profile
|
|
1004
|
-
if (!profile) return 'text'
|
|
1005
|
-
const colInfo = profile.columns[col]
|
|
1006
|
-
if (!colInfo) return 'text'
|
|
1007
|
-
return resolveColumnType(colInfo, profile.categorical.includes(col))
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
const columnTypesMap = computed(() => {
|
|
1011
|
-
const map: Record<string, ColumnType> = {}
|
|
1012
|
-
for (const col of allColumns.value) {
|
|
1013
|
-
map[col] = getColumnTypeFromName(col)
|
|
1014
|
-
}
|
|
1015
|
-
return map
|
|
1016
|
-
})
|
|
1017
|
-
|
|
1018
|
-
function getColumnType(col: string): ColumnType {
|
|
1019
|
-
return columnTypesMap.value[col] ?? 'text'
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
1141
|
function getColumnProfile(col: string) {
|
|
1023
1142
|
return profileData.value?.profile?.profile?.[col] ?? null
|
|
1024
1143
|
}
|
|
@@ -61,18 +61,22 @@
|
|
|
61
61
|
</div>
|
|
62
62
|
</div>
|
|
63
63
|
|
|
64
|
-
<!-- Search (contains) -->
|
|
65
|
-
<div
|
|
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
|
+
>
|
|
66
69
|
<div class="relative">
|
|
67
|
-
<
|
|
70
|
+
<component
|
|
71
|
+
:is="searchField.icon"
|
|
68
72
|
class="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-gray-medium"
|
|
69
73
|
aria-hidden="true"
|
|
70
74
|
/>
|
|
71
75
|
<input
|
|
72
76
|
v-model="search"
|
|
73
|
-
type="
|
|
77
|
+
:type="searchField.type"
|
|
74
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"
|
|
75
|
-
:placeholder="
|
|
79
|
+
:placeholder="searchField.placeholder"
|
|
76
80
|
>
|
|
77
81
|
</div>
|
|
78
82
|
</div>
|
|
@@ -154,14 +158,14 @@
|
|
|
154
158
|
|
|
155
159
|
<!-- Number range -->
|
|
156
160
|
<form
|
|
157
|
-
v-if="columnType === 'number' && columnProfile"
|
|
161
|
+
v-if="(columnType === 'number' || columnType === 'year') && columnProfile"
|
|
158
162
|
class="px-3 py-2 border-b border-black/10 space-y-2"
|
|
159
163
|
@submit.prevent="applyRange"
|
|
160
164
|
>
|
|
161
165
|
<div class="flex items-center gap-2 text-xs text-gray-plain">
|
|
162
|
-
<span class="tabular-nums">{{ formatNumber(profileMin) }}</span>
|
|
166
|
+
<span class="tabular-nums">{{ columnType === 'year' ? profileMin : formatNumber(profileMin) }}</span>
|
|
163
167
|
<span class="text-gray-medium">—</span>
|
|
164
|
-
<span class="tabular-nums">{{ formatNumber(profileMax) }}</span>
|
|
168
|
+
<span class="tabular-nums">{{ columnType === 'year' ? profileMax : formatNumber(profileMax) }}</span>
|
|
165
169
|
</div>
|
|
166
170
|
<div class="flex items-center gap-2">
|
|
167
171
|
<input
|
|
@@ -213,6 +217,7 @@ import {
|
|
|
213
217
|
RiArrowUpLine,
|
|
214
218
|
RiArrowDownLine,
|
|
215
219
|
RiSearchLine,
|
|
220
|
+
RiCalendarLine,
|
|
216
221
|
RiCheckLine,
|
|
217
222
|
} from '@remixicon/vue'
|
|
218
223
|
import { useTranslation } from '../../composables/useTranslation'
|
|
@@ -239,13 +244,25 @@ const { formatNumber } = useFormatTabular()
|
|
|
239
244
|
|
|
240
245
|
const search = ref('')
|
|
241
246
|
|
|
247
|
+
// Numbers, years and dates are matched exactly (the API has no `contains` for
|
|
248
|
+
// them), so the field offers the matching native picker instead of a text search.
|
|
249
|
+
const searchField = computed(() => {
|
|
250
|
+
switch (props.columnType) {
|
|
251
|
+
case 'date': return { icon: RiCalendarLine, type: 'date', placeholder: '' }
|
|
252
|
+
case 'number':
|
|
253
|
+
case 'year': return { icon: RiSearchLine, type: 'number', placeholder: t('Rechercher...') }
|
|
254
|
+
default: return { icon: RiSearchLine, type: 'text', placeholder: t('Rechercher...') }
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
242
258
|
watchDebounced(search, (q) => {
|
|
243
259
|
const existing = filters.value[props.column] ?? {}
|
|
260
|
+
const operator = props.columnType === 'number' || props.columnType === 'year' || props.columnType === 'date' ? 'exact' : 'contains'
|
|
244
261
|
if (q) {
|
|
245
|
-
filters.value = { ...filters.value, [props.column]: { ...existing,
|
|
262
|
+
filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: q } }
|
|
246
263
|
}
|
|
247
264
|
else {
|
|
248
|
-
const {
|
|
265
|
+
const { [operator]: _, ...rest } = existing
|
|
249
266
|
filters.value = { ...filters.value, [props.column]: rest }
|
|
250
267
|
}
|
|
251
268
|
}, { debounce: 300 })
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
static
|
|
24
24
|
class="bg-white border border-black/10 rounded-lg shadow-md w-64 absolute z-[800]"
|
|
25
25
|
:style="floatingStyles"
|
|
26
|
+
:data-testid="`column-filter-${column}`"
|
|
26
27
|
>
|
|
27
28
|
<!-- Title -->
|
|
28
29
|
<div class="flex items-center justify-between px-3 py-2 border-b border-black/10">
|
|
@@ -51,8 +51,8 @@ export type TabularColumnProfile = {
|
|
|
51
51
|
tops: TabularTopValue[]
|
|
52
52
|
nb_distinct: number
|
|
53
53
|
nb_missing_values: number
|
|
54
|
-
min?: number
|
|
55
|
-
max?: number
|
|
54
|
+
min?: number | string
|
|
55
|
+
max?: number | string
|
|
56
56
|
std?: number
|
|
57
57
|
mean?: number
|
|
58
58
|
}
|
|
@@ -62,7 +62,7 @@ export type TabularTopValue = {
|
|
|
62
62
|
count: number
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean'
|
|
65
|
+
export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
|
|
66
66
|
|
|
67
67
|
export type ColumnFilters = {
|
|
68
68
|
in?: string[]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useComponentsConfig } from '../config'
|
|
2
|
+
import { hasTabularParsingError } from '../functions/resources'
|
|
2
3
|
import type { Resource } from '../types/resources'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -20,7 +21,7 @@ export const useHasTabularData = () => {
|
|
|
20
21
|
return (
|
|
21
22
|
config.tabularApiUrl
|
|
22
23
|
&& resource.extras['analysis:parsing:parsing_table']
|
|
23
|
-
&& !resource
|
|
24
|
+
&& !hasTabularParsingError(resource)
|
|
24
25
|
&& !sourceUnreachable
|
|
25
26
|
&& (config.tabularAllowRemote || resource.filetype === 'file')
|
|
26
27
|
)
|