@datagouv/components-next 1.0.2-dev.163 → 1.0.2-dev.164

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 (53) hide show
  1. package/dist/{Datafair.client-DXPU1V24.js → Datafair.client-B-OG5WtK.js} +1 -1
  2. package/dist/ImagePreview.client-D0c-ifUi.js +36 -0
  3. package/dist/{JsonPreview.client-D1SnGfWG.js → JsonPreview.client-Dwh9sW9M.js} +2 -2
  4. package/dist/{MapContainer.client-DlsaEVGx.js → MapContainer.client-C2AZ7sOB.js} +2 -2
  5. package/dist/{PdfPreview.client-C9OrLR0z.js → PdfPreview.client-8NzNfW8b.js} +2 -2
  6. package/dist/{Pmtiles.client-CyylWfTy.js → Pmtiles.client-BFH4rTB7.js} +5842 -5660
  7. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-CtXZOHXI.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-BOgE441d.js} +7 -10
  8. package/dist/{XmlPreview.client-DoX7RurH.js → XmlPreview.client-DMOUO508.js} +3 -3
  9. package/dist/components-next.css +1 -1
  10. package/dist/components-next.js +179 -171
  11. package/dist/components.css +1 -1
  12. package/dist/{index-Br1sa7wQ.js → index-C9obT9DJ.js} +4392 -4293
  13. package/dist/{leaflet-src-DWG7tmpQ.js → leaflet-src-B4AZU0vr.js} +1 -1
  14. package/dist/{main-BU6kP4dE.js → main-DNoYWfit.js} +16159 -15631
  15. package/dist/{vue3-xml-viewer.common-Cpui-Yi9.js → vue3-xml-viewer.common-Cp7Cu5ME.js} +1 -1
  16. package/package.json +4 -5
  17. package/src/components/Chart/ChartViewer.vue +103 -10
  18. package/src/components/Chart/ChartViewerWrapper.vue +8 -0
  19. package/src/components/DataserviceCard.vue +6 -0
  20. package/src/components/DataserviceQuality.vue +82 -0
  21. package/src/components/DataserviceQualityInline.vue +31 -0
  22. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  23. package/src/components/DatasetQuality.vue +11 -11
  24. package/src/components/DatasetQualityInline.vue +2 -2
  25. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  26. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  27. package/src/components/ResourceAccordion/ResourceAccordion.vue +8 -3
  28. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +15 -1
  29. package/src/components/ReuseCard.vue +2 -2
  30. package/src/components/ReuseDetails.vue +2 -2
  31. package/src/components/ReuseHorizontalCard.vue +2 -2
  32. package/src/components/Search/GlobalSearch.vue +2 -2
  33. package/src/components/TabularExplorer/TabularCell.vue +5 -4
  34. package/src/components/TabularExplorer/TabularCellPopover.vue +1 -1
  35. package/src/components/TabularExplorer/TabularExplorer.vue +168 -49
  36. package/src/components/TabularExplorer/TabularFilterContent.vue +27 -10
  37. package/src/components/TabularExplorer/TabularFilterPopover.vue +1 -0
  38. package/src/components/TabularExplorer/types.ts +3 -3
  39. package/src/composables/useHasTabularData.ts +2 -1
  40. package/src/composables/useResourceCapabilities.ts +27 -14
  41. package/src/config.ts +1 -0
  42. package/src/functions/charts.ts +23 -3
  43. package/src/functions/resources.ts +56 -0
  44. package/src/functions/tabular.ts +44 -1
  45. package/src/main.ts +15 -6
  46. package/src/types/dataservices.ts +15 -0
  47. package/src/types/organizations.ts +10 -0
  48. package/src/types/reuses.ts +12 -1
  49. package/src/types/search.ts +2 -2
  50. package/src/types/visualizations.ts +1 -1
  51. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  52. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  53. /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 Reuse)" />
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 { Reuse } from '../../types/reuses'
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 text-gray-title' : ''"
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 text-gray-title' : ''"
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
- <span class="font-mono tabular-nums">{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span>
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="fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4'"
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
- :value="row[col]"
389
- :column-type="getColumnType(col)"
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
- :value="row[col]"
455
- :column-type="getColumnType(col)"
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 once both data and profile are ready (so the <thead> is rendered with
836
- // content-driven natural widths), then distribute.
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
- measureNaturalWidths()
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
- parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
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 class="px-3 py-2 border-b border-black/10">
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
- <RiSearchLine
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="text"
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="t('Rechercher...')"
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, contains: q } }
262
+ filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: q } }
246
263
  }
247
264
  else {
248
- const { contains: _, ...rest } = existing
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.extras['analysis:parsing:error']
24
+ && !hasTabularParsingError(resource)
24
25
  && !sourceUnreachable
25
26
  && (config.tabularAllowRemote || resource.filetype === 'file')
26
27
  )