@datagouv/components-next 1.0.2-dev.230 → 1.0.2-dev.232

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 (39) hide show
  1. package/dist/{ImagePreview.client-Db0MdI5N.js → ImagePreview.client-CSRz7eUF.js} +2 -2
  2. package/dist/{JsonPreview.client-DRCq40Q-.js → JsonPreview.client-wT2hfYLv.js} +2 -2
  3. package/dist/{Map-D17BKcyK.js → Map-QeL96qbQ.js} +1 -1
  4. package/dist/{MapContainer.client-DdPY_Iri.js → MapContainer.client-UC7wQOBj.js} +10 -10
  5. package/dist/{PdfPreview.client-DtKCBgGZ.js → PdfPreview.client-DifxM8Th.js} +2 -2
  6. package/dist/{Pmtiles.client-DSZ_4FXc.js → Pmtiles.client-OyXiX_7g.js} +2104 -2905
  7. package/dist/{PreviewWrapper-DegSQbMM.js → PreviewWrapper-D7yfQyyH.js} +1 -1
  8. package/dist/{XmlPreview.client-D9R_YF5s.js → XmlPreview.client-BkPGZLn6.js} +2 -2
  9. package/dist/components-next.css +1 -1
  10. package/dist/components-next.js +10149 -9989
  11. package/dist/components.css +1 -1
  12. package/dist/{france-Bu5x7PSp.js → france-BNatwStf.js} +1 -1
  13. package/dist/purify.es-CeeOHdER.js +805 -0
  14. package/dist/{resources-BOKIyTJV.js → resources-kuI_FosB.js} +18 -21
  15. package/dist/src-C0XyM3eg.js +68979 -0
  16. package/dist/{vue-D62CcCib.js → vue-D5eMxsCr.js} +78 -74
  17. package/package.json +2 -2
  18. package/src/components/ResourceAccordion/EditButton.vue +1 -4
  19. package/src/components/ResourceAccordion/MapContainer.client.vue +18 -7
  20. package/src/components/ResourceAccordion/ResourceAccordion.vue +1 -6
  21. package/src/components/ResourceExplorer/ResourceExplorer.vue +8 -2
  22. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +140 -90
  23. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +17 -2
  24. package/src/components/ResourceExplorer/ResourceSelector.vue +3 -0
  25. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +3 -5
  26. package/src/components/ResourceIconBadge.vue +28 -0
  27. package/src/components/ResourceListItem.vue +83 -27
  28. package/src/components/TabularExplorer/TabularActiveFilters.vue +94 -54
  29. package/src/components/TabularExplorer/TabularExplorer.vue +58 -18
  30. package/src/components/TabularExplorer/TabularSearchInput.vue +40 -0
  31. package/src/components/TabularExplorer/TabularSkeleton.vue +14 -6
  32. package/src/components/TabularExplorer/TabularTable.vue +12 -5
  33. package/src/components/TabularExplorer/TabularToolbar.vue +7 -4
  34. package/src/components/TabularExplorer/useTabularContext.ts +10 -0
  35. package/src/composables/useResourceCapabilities.ts +5 -0
  36. package/src/functions/resources.ts +0 -4
  37. package/src/main.ts +2 -0
  38. package/src/types/resources.ts +0 -6
  39. package/dist/src-DbFybTVs.js +0 -66090
@@ -4,41 +4,82 @@
4
4
  v-bind="$attrs"
5
5
  :to
6
6
  :replace
7
- :class="selected ? '[&&]:!bg-gray-200' : '[&&]:hover:!bg-gray-100'"
8
- class="grid h-7 w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 rounded px-1 py-1 text-left !bg-none !no-underline"
7
+ :class="[
8
+ selected ? '[&&]:!bg-gray-200' : '[&&]:hover:!bg-gray-100',
9
+ // Expanded, each row is its own grid: fixed tracks keep the extra columns
10
+ // aligned from one row to the next, where `auto` tracks would follow each
11
+ // row's content.
12
+ expanded ? 'grid-cols-[auto_minmax(0,1fr)_13rem_5rem_6rem_4rem_6.5rem]' : 'grid-cols-[auto_minmax(0,1fr)_auto_1.25rem]',
13
+ ]"
14
+ class="grid h-8 w-full items-center gap-1 rounded px-1 py-1 text-left !bg-none !no-underline"
9
15
  @pointerenter="openOnHover"
10
16
  @pointerleave="closeTooltip"
11
- @focus="show = true"
17
+ @focus="show = !expanded"
12
18
  @blur="closeTooltip"
13
19
  >
14
- <span
15
- :class="[iconColor, '[&_svg]:fill-current']"
16
- class="flex size-5 shrink-0 items-center justify-center rounded-[1px]"
17
- >
18
- <component
19
- :is="iconComponent"
20
- class="size-4"
21
- aria-hidden="true"
22
- />
23
- </span>
20
+ <ResourceIconBadge :resource />
24
21
  <div class="flex min-w-0 items-baseline gap-0.5 whitespace-nowrap leading-4">
25
22
  <span
26
- class="truncate text-[13px]"
23
+ class="truncate text-[14px]"
27
24
  :class="selected ? 'font-extrabold text-gray-title' : 'font-medium text-gray-medium'"
28
25
  >{{ resource.title || t('Fichier sans nom') }}</span>
29
- <template v-if="humanFilesize">
30
- <span class="shrink-0 text-[13px] text-gray-medium">·</span>
31
- <span class="shrink-0 text-[12px] text-gray-medium">{{ humanFilesize }}</span>
26
+ <template v-if="humanFilesize && !expanded">
27
+ <span class="shrink-0 text-[14px] text-gray-medium">·</span>
28
+ <span class="shrink-0 text-[13px] text-gray-medium">{{ humanFilesize }}</span>
32
29
  </template>
33
30
  </div>
31
+ <template v-if="expanded">
32
+ <span class="truncate text-[13px] text-gray-medium">
33
+ <TranslationT keypath="Mis à jour le {date}">
34
+ <template #date>
35
+ <FormattedDate :date="resource.last_modified" />
36
+ </template>
37
+ </TranslationT>
38
+ </span>
39
+ <span class="text-right text-[13px] tabular-nums text-gray-medium">{{ humanFilesize }}</span>
40
+ </template>
34
41
  <!-- Capped and truncated: an `auto` grid track floors at its content width, so an
35
42
  unusually long format (`www:link-1.0-http--samples`) would otherwise squeeze
36
43
  the title track to nothing and overflow the fixed-height row. -->
37
44
  <span
38
45
  v-if="resource.format"
39
- class="max-w-24 truncate rounded bg-gray-lower px-1.5 py-0.5 text-[12px] uppercase leading-4 text-gray-medium"
46
+ class="max-w-24 justify-self-start truncate rounded bg-gray-lower px-1.5 py-0.5 text-[13px] uppercase leading-4 text-gray-medium"
40
47
  :title="resource.format"
41
48
  >{{ resource.format }}</span>
49
+ <!-- Holds the format track so the next columns stay in place. -->
50
+ <span v-else />
51
+ <span
52
+ v-if="expanded"
53
+ class="inline-flex items-center justify-end gap-1 text-[13px] tabular-nums text-gray-medium"
54
+ >
55
+ <RiDownloadLine
56
+ class="size-3"
57
+ aria-hidden="true"
58
+ />
59
+ {{ summarize(resource.metrics.views) }}
60
+ </span>
61
+ <!-- Compact, the eye is labelled in the hover card; expanded, the row has room
62
+ for the label and no hover card. -->
63
+ <span
64
+ v-if="expanded && hasPreview"
65
+ class="flex w-fit items-center gap-0.5 justify-self-end rounded bg-gray-lower px-1.5 py-0.5 text-[13px] leading-4 text-gray-medium"
66
+ >
67
+ <RiEyeLine
68
+ class="size-3.5"
69
+ aria-hidden="true"
70
+ />
71
+ {{ t('Explorable') }}
72
+ </span>
73
+ <span
74
+ v-else
75
+ class="flex items-center justify-center"
76
+ >
77
+ <RiEyeLine
78
+ v-if="hasPreview"
79
+ class="size-4 text-gray-medium"
80
+ aria-hidden="true"
81
+ />
82
+ </span>
42
83
  </AppLink>
43
84
 
44
85
  <!-- Hover card: the row truncates the title, so surface the full name plus the
@@ -84,6 +125,16 @@
84
125
  {{ summarize(resource.metrics.views) }}
85
126
  </span>
86
127
  </div>
128
+ <span
129
+ v-if="hasPreview"
130
+ class="mt-1.5 flex w-fit items-center gap-0.5 rounded bg-gray-lower px-1.5 py-0.5 text-[12px] leading-4 text-gray-medium"
131
+ >
132
+ <RiEyeLine
133
+ class="size-3"
134
+ aria-hidden="true"
135
+ />
136
+ {{ t('Explorable') }}
137
+ </span>
87
138
  </div>
88
139
  </Teleport>
89
140
  </ClientOnly>
@@ -94,15 +145,17 @@ import { computed, ref, useTemplateRef } from 'vue'
94
145
  import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
95
146
  import { useEventListener } from '@vueuse/core'
96
147
  import type { RouteLocationRaw } from 'vue-router'
97
- import { RiDownloadLine } from '@remixicon/vue'
148
+ import { RiDownloadLine, RiEyeLine } from '@remixicon/vue'
98
149
  import AppLink from './AppLink.vue'
99
150
  import ClientOnly from './ClientOnly.vue'
100
- import File from './Icons/File.vue'
101
151
  import FormattedDate from './FormattedDate.vue'
152
+ import ResourceIconBadge from './ResourceIconBadge.vue'
102
153
  import TranslationT from './TranslationT.vue'
103
- import { getResourceFormatIcon, getResourceIconColor, getResourceFilesize } from '../functions/resources'
154
+ import { getResourceFilesize } from '../functions/resources'
104
155
  import { filesize, summarize } from '../functions/helpers'
156
+ import { useResourceCapabilities } from '../composables/useResourceCapabilities'
105
157
  import { useTranslation } from '../composables/useTranslation'
158
+ import type { Dataset, DatasetV2 } from '../types/datasets'
106
159
  import type { Resource } from '../types/resources'
107
160
 
108
161
  // The hover card below is a second root node, so Vue drops fallthrough attributes
@@ -115,20 +168,22 @@ defineOptions({ inheritAttrs: false })
115
168
  // truth for the selection.
116
169
  const props = withDefaults(defineProps<{
117
170
  resource: Resource
171
+ // Some previews (Data Fair, OpenAPI) depend on the dataset's organization.
172
+ dataset: Dataset | DatasetV2
118
173
  to: RouteLocationRaw
119
174
  selected?: boolean
120
175
  replace?: boolean
176
+ // Full-width list: the row also shows the update date, size and downloads.
177
+ expanded?: boolean
121
178
  }>(), {
122
179
  selected: false,
123
180
  replace: false,
181
+ expanded: false,
124
182
  })
125
183
 
126
184
  const { t } = useTranslation()
127
185
 
128
- // Render the icon directly (not via ResourceIcon which forces a gray color) so the
129
- // colored badge can tint it through currentColor + [&_svg]:fill-current.
130
- const iconComponent = computed(() => (props.resource.format ? getResourceFormatIcon(props.resource.format) : null) ?? File)
131
- const iconColor = computed(() => getResourceIconColor(props.resource.format))
186
+ const { hasPreview } = useResourceCapabilities(() => props.resource, () => props.dataset)
132
187
 
133
188
  const humanFilesize = computed(() => {
134
189
  const size = getResourceFilesize(props.resource)
@@ -151,9 +206,10 @@ const { floatingStyles } = useFloating(rowEl, card, {
151
206
 
152
207
  // A tap fires a pointer enter too, so the card would flash on every touch selection
153
208
  // in the mobile resource picker. Only a real pointer opens it — keyboard focus still
154
- // does, through @focus.
209
+ // does, through @focus. The expanded row already shows everything the card holds,
210
+ // so it never opens there.
155
211
  function openOnHover(event: PointerEvent) {
156
- if (event.pointerType === 'mouse') show.value = true
212
+ if (event.pointerType === 'mouse' && !props.expanded) show.value = true
157
213
  }
158
214
 
159
215
  function closeTooltip() {
@@ -1,79 +1,119 @@
1
1
  <template>
2
2
  <div
3
- v-if="activeFilters.length > 0 || sort"
4
- class="flex flex-wrap items-center gap-1.5"
3
+ v-if="activeFilters.length > 0 || sort || globalSearch"
4
+ class="flex items-center justify-between gap-2"
5
5
  >
6
- <!-- Sort chip -->
7
- <span
8
- v-if="sort"
9
- class="inline-flex h-6 items-center gap-1 rounded border border-new-primary/20 bg-new-primary/10 pl-1.5 pr-0.5 text-[12px] leading-4 text-new-primary"
10
- >
11
- <component
12
- :is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
13
- class="size-3.5"
14
- aria-hidden="true"
15
- />
16
- <span class="font-medium">{{ sort.column }}</span>
17
- <span>{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
18
- <button
19
- :aria-label="t('Supprimer le tri')"
20
- class="flex size-5 items-center justify-center rounded hover:bg-new-primary/15"
21
- @click="sort = null"
6
+ <div class="flex min-w-0 flex-wrap items-center gap-1.5">
7
+ <!-- Sort chip -->
8
+ <span
9
+ v-if="sort"
10
+ class="inline-flex h-6 items-center gap-1 rounded border border-new-primary/20 bg-new-primary/10 pl-1.5 pr-0.5 text-[12px] leading-4 text-new-primary"
22
11
  >
23
- <RiCloseLine
12
+ <component
13
+ :is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
24
14
  class="size-3.5"
25
15
  aria-hidden="true"
26
16
  />
27
- </button>
28
- </span>
17
+ <span class="font-medium">{{ sort.column }}</span>
18
+ <span>{{ sort.direction === 'asc' ? t('croissant') : t('décroissant') }}</span>
19
+ <button
20
+ :aria-label="t('Supprimer le tri')"
21
+ class="flex size-5 items-center justify-center rounded hover:bg-new-primary/15"
22
+ @click="sort = null"
23
+ >
24
+ <RiCloseLine
25
+ class="size-3.5"
26
+ aria-hidden="true"
27
+ />
28
+ </button>
29
+ </span>
29
30
 
30
- <!-- Filter chips -->
31
- <span
32
- v-for="af in activeFilters"
33
- :key="af.column"
34
- :data-testid="`active-filter-${af.column}`"
35
- class="inline-flex h-6 items-center gap-1 rounded border border-new-primary/20 bg-new-primary/10 pl-1.5 pr-0.5 text-[12px] leading-4 text-new-primary"
36
- >
37
- <component
38
- :is="getColumnDisplay(af.column).icon"
39
- class="size-3.5"
40
- aria-hidden="true"
41
- />
42
- <span class="font-medium">{{ af.column }}</span>
43
- <span>{{ af.label }}</span>
44
- <button
45
- :aria-label="t('Supprimer ce filtre')"
46
- class="flex size-5 items-center justify-center rounded hover:bg-new-primary/15"
47
- @click="removeFilter(af.column)"
31
+ <!-- Global search chip -->
32
+ <span
33
+ v-if="globalSearch"
34
+ class="inline-flex h-6 items-center gap-1 rounded border border-new-primary/20 bg-new-primary/10 pl-1.5 pr-0.5 text-[12px] leading-4 text-new-primary"
48
35
  >
49
- <RiCloseLine
36
+ <RiSearchLine
50
37
  class="size-3.5"
51
38
  aria-hidden="true"
52
39
  />
53
- </button>
54
- </span>
40
+ <span class="font-medium">{{ t('Toutes les colonnes') }}</span>
41
+ <span>{{ t('contient') }} "{{ globalSearch }}"</span>
42
+ <button
43
+ :aria-label="t('Supprimer la recherche')"
44
+ class="flex size-5 items-center justify-center rounded hover:bg-new-primary/15"
45
+ @click="globalSearch = ''"
46
+ >
47
+ <RiCloseLine
48
+ class="size-3.5"
49
+ aria-hidden="true"
50
+ />
51
+ </button>
52
+ </span>
55
53
 
56
- <!-- Clear all -->
57
- <BrandedButton
58
- v-if="withClear"
59
- color="tertiary"
60
- size="2xs"
61
- :icon="RiCloseLine"
62
- @click="clearAllFilters(); sort = null"
63
- >
64
- {{ t('Tout effacer') }}
65
- </BrandedButton>
54
+ <!-- Filter chips -->
55
+ <span
56
+ v-for="af in activeFilters"
57
+ :key="af.column"
58
+ :data-testid="`active-filter-${af.column}`"
59
+ class="inline-flex h-6 items-center gap-1 rounded border border-new-primary/20 bg-new-primary/10 pl-1.5 pr-0.5 text-[12px] leading-4 text-new-primary"
60
+ >
61
+ <component
62
+ :is="getColumnDisplay(af.column).icon"
63
+ class="size-3.5"
64
+ aria-hidden="true"
65
+ />
66
+ <span class="font-medium">{{ af.column }}</span>
67
+ <span>{{ af.label }}</span>
68
+ <button
69
+ :aria-label="t('Supprimer ce filtre')"
70
+ class="flex size-5 items-center justify-center rounded hover:bg-new-primary/15"
71
+ @click="removeFilter(af.column)"
72
+ >
73
+ <RiCloseLine
74
+ class="size-3.5"
75
+ aria-hidden="true"
76
+ />
77
+ </button>
78
+ </span>
79
+ </div>
80
+
81
+ <div class="flex shrink-0 items-center gap-1">
82
+ <!-- A sort alone narrows nothing: the file would be the whole resource, which
83
+ the resource's own download already gives. And with no matching row, the
84
+ API answers an empty file, without even the header. -->
85
+ <BrandedButton
86
+ v-if="(activeFilters.length > 0 || globalSearch) && tableData?.meta.total"
87
+ color="tertiary"
88
+ size="2xs"
89
+ :icon="RiDownloadLine"
90
+ :href="filteredDownloadUrl"
91
+ >
92
+ {{ t('Télécharger les données filtrées') }}
93
+ </BrandedButton>
94
+ <!-- Clear all -->
95
+ <BrandedButton
96
+ v-if="withClear"
97
+ color="tertiary"
98
+ size="2xs"
99
+ :icon="RiCloseLine"
100
+ @click="clearAllFilters(); sort = null"
101
+ >
102
+ {{ t('Tout effacer') }}
103
+ </BrandedButton>
104
+ </div>
66
105
  </div>
67
106
  </template>
68
107
 
69
108
  <script setup lang="ts">
70
- import { RiArrowDownLine, RiArrowUpLine, RiCloseLine } from '@remixicon/vue'
109
+ import { RiArrowDownLine, RiArrowUpLine, RiCloseLine, RiDownloadLine, RiSearchLine } from '@remixicon/vue'
71
110
  import BrandedButton from '../BrandedButton.vue'
72
111
  import { useTranslation } from '../../composables/useTranslation'
73
112
  import { useTabularContext } from './useTabularContext'
74
113
 
114
+ // Framing (borders, padding) belongs to the call site, like the toolbar's.
75
115
  defineProps<{ withClear?: boolean }>()
76
116
 
77
117
  const { t } = useTranslation()
78
- const { activeFilters, sort, removeFilter, clearAllFilters, getColumnDisplay } = useTabularContext()
118
+ const { tableData, activeFilters, sort, globalSearch, removeFilter, clearAllFilters, getColumnDisplay, filteredDownloadUrl } = useTabularContext()
79
119
  </script>
@@ -8,11 +8,16 @@
8
8
 
9
9
  <!-- Same skeleton as the Suspense fallback above us: in both cases the slot isn't
10
10
  rendered yet, so its toolbar is a placeholder too. -->
11
- <TabularSkeleton v-else-if="previewLoading" />
11
+ <TabularSkeleton
12
+ v-else-if="previewLoading"
13
+ :search-input
14
+ />
12
15
 
13
16
  <!-- 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">
17
+ the provided context, so it controls the framing and layout. Once loaded, it
18
+ stays: a failed search or filter only empties the rows, so the controls that
19
+ caused it remain there to undo it. -->
20
+ <template v-else-if="(tableData || loadedOnce) && profileData">
16
21
  <slot />
17
22
  </template>
18
23
  </div>
@@ -32,15 +37,11 @@ import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters, DateFi
32
37
  import { provideTabularContext, type ActiveFilter } from './useTabularContext'
33
38
  import { useColumnMetadata } from './useColumnMetadata'
34
39
 
35
- const props = defineProps<{
40
+ const props = withDefaults(defineProps<{
36
41
  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
42
+ // Whether the toolbar shows its search field. A page with a search of its own
43
+ // hides it and drives the search through `v-model:global-search` instead.
44
+ searchInput?: boolean
44
45
  // Filters seeded on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
45
46
  // The explorer owns them afterwards: later changes to this prop are ignored,
46
47
  // so pass a fresh instance (or remount) to reset them.
@@ -49,7 +50,18 @@ const props = defineProps<{
49
50
  // Same ownership rule as `initialFilters`: the explorer owns it afterwards,
50
51
  // and the user can drop it from the active-sort chip.
51
52
  initialSort?: SortConfig
52
- }>()
53
+ }>(), {
54
+ searchInput: true,
55
+ })
56
+
57
+ // Searches across multiple columns using the Tabular API's or(...) parameter. Text
58
+ // and categorical columns get a __contains filter; number columns get a __exact
59
+ // filter (since __contains is not supported for numbers by the API). Year, date and
60
+ // boolean columns are excluded.
61
+ // Combined via AND with any column-specific `contains` filter, so it acts as an
62
+ // additional narrowing constraint, not a replacement.
63
+ // Owned here like the filters; a parent binds it to drive or keep it.
64
+ const globalSearch = defineModel<string>('globalSearch', { default: '' })
53
65
 
54
66
  const { t } = useTranslation()
55
67
  const config = useComponentsConfig()
@@ -85,8 +97,10 @@ const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
85
97
 
86
98
  const PAGE_SIZE = 50
87
99
 
88
- const dataQuery = computed(() => {
89
- const q: Record<string, string | number> = { page: 1, page_size: PAGE_SIZE }
100
+ // What the table shows (sort, filters, search), apart from its pagination: the
101
+ // filtered download sends the very same params, so the file can't drift from the table.
102
+ const tableQuery = computed(() => {
103
+ const q: Record<string, string | number> = {}
90
104
  if (sort.value) {
91
105
  q[`${sort.value.column}__sort`] = sort.value.direction
92
106
  }
@@ -116,21 +130,28 @@ const dataQuery = computed(() => {
116
130
  Object.assign(q, buildDateFilterParams(col, filter.date))
117
131
  }
118
132
  }
119
- if (props.globalSearch && profileData.value?.profile) {
120
- const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
133
+ if (globalSearch.value && profileData.value?.profile) {
134
+ const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, globalSearch.value)
121
135
  q.or = '(' + conditions.join(',') + ')'
122
136
  }
123
137
  return q
124
138
  })
125
139
 
140
+ const dataQuery = computed(() => ({ page: 1, page_size: PAGE_SIZE, ...tableQuery.value }))
141
+
126
142
  const { data: tableData, error, status: dataStatus } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
127
143
 
144
+ // A failed request clears `tableData`: telling a resource that can't be previewed
145
+ // from a search or filter the API rejects needs to know whether it ever loaded.
146
+ const loadedOnce = ref(!!tableData.value)
147
+
128
148
  // The component renders nothing useful until the profile is available
129
149
  // (allColumns is derived from it). Surface a clear loading / error state
130
150
  // so we don't end up with an empty table + a spinner running forever.
131
151
  const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
132
- const previewError = computed(() => error.value || profileError.value)
133
- const previewLoading = computed(() => !previewError.value && (!tableData.value || profileLoading.value))
152
+ const previewError = computed(() => profileError.value || (!loadedOnce.value && error.value))
153
+ const queryFailed = computed(() => loadedOnce.value && !!error.value)
154
+ const previewLoading = computed(() => !previewError.value && (profileLoading.value || (!tableData.value && !loadedOnce.value)))
134
155
  // A search / filter / sort change refetches while the previous rows stay on
135
156
  // screen: without a signal, the table looks unchanged for several seconds.
136
157
  const isRefreshing = computed(() => dataStatus.value === 'pending' && !previewLoading.value)
@@ -145,10 +166,15 @@ const generation = ref(0)
145
166
  watch(() => tableData.value, (data) => {
146
167
  generation.value++
147
168
  if (data) {
169
+ loadedOnce.value = true
148
170
  allRows.value = [...data.data]
149
171
  currentPage.value = 1
150
172
  hasMore.value = data.data.length < data.meta.total
151
173
  }
174
+ else if (queryFailed.value) {
175
+ allRows.value = []
176
+ hasMore.value = false
177
+ }
152
178
  }, { immediate: true })
153
179
 
154
180
  async function loadNextPage() {
@@ -275,8 +301,18 @@ function removeFilter(column: string) {
275
301
  filters.value = rest
276
302
  }
277
303
 
304
+ // The Tabular API's CSV export takes the params of the data endpoint and ignores the
305
+ // pagination: the file holds every filtered row, not just the pages loaded on screen.
306
+ // All the columns, not only the displayed ones: the API splits `columns` on every
307
+ // comma, so a column whose name holds one can't be requested by name.
308
+ const filteredDownloadUrl = computed(() => {
309
+ const params = new URLSearchParams(Object.entries(tableQuery.value).map(([key, value]) => [key, String(value)]))
310
+ return `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/csv/?${params}`
311
+ })
312
+
278
313
  function clearAllFilters() {
279
314
  filters.value = {}
315
+ globalSearch.value = ''
280
316
  }
281
317
 
282
318
  function hasFilterForColumn(col: string): boolean {
@@ -300,6 +336,10 @@ provideTabularContext({
300
336
  activeFilters,
301
337
  removeFilter,
302
338
  clearAllFilters,
339
+ globalSearch,
340
+ searchInput: computed(() => props.searchInput),
341
+ queryFailed,
342
+ filteredDownloadUrl,
303
343
  hasFilterForColumn,
304
344
  allColumns,
305
345
  visibleColumns,
@@ -0,0 +1,40 @@
1
+ <template>
2
+ <label class="flex h-8 w-[clamp(160px,22vw,220px)] min-w-0 shrink-0 items-center gap-1 rounded border border-gray-default bg-gray-some px-2">
3
+ <RiSearchLine
4
+ class="size-3.5 shrink-0 text-gray-medium"
5
+ aria-hidden="true"
6
+ />
7
+ <input
8
+ v-model="text"
9
+ type="search"
10
+ class="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-gray-medium [&::-webkit-search-cancel-button]:appearance-none"
11
+ :aria-label="t('Rechercher une valeur')"
12
+ :placeholder="t('Rechercher une valeur')"
13
+ >
14
+ </label>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { ref, watch } from 'vue'
19
+ import { RiSearchLine } from '@remixicon/vue'
20
+ import { useComponentsConfig } from '../../config'
21
+ import { useDebouncedRef } from '../../composables/useDebouncedRef'
22
+ import { useTranslation } from '../../composables/useTranslation'
23
+ import { useTabularContext } from './useTabularContext'
24
+
25
+ const { t } = useTranslation()
26
+ const config = useComponentsConfig()
27
+ const { globalSearch } = useTabularContext()
28
+
29
+ // Only typing is debounced: each settled value is a request to the Tabular API.
30
+ const text = ref(globalSearch.value)
31
+ const { debounced } = useDebouncedRef(text, config.searchDebounce ?? 300)
32
+ watch(debounced, (value) => {
33
+ globalSearch.value = value.trim()
34
+ })
35
+
36
+ // Cleared from elsewhere (its chip, "Tout effacer"): the field follows.
37
+ watch(globalSearch, (value) => {
38
+ if (value !== text.value.trim()) text.value = value
39
+ })
40
+ </script>
@@ -1,9 +1,13 @@
1
1
  <template>
2
- <!-- Toolbar row — mirrors TabularToolbar: the mobile filter button on the left (it
3
- only shows below md), and the columns menu (h-6 button, which drives the row
4
- height) plus the rows info on the right. The active-filters area stays empty
5
- until the user filters, so nothing stands in for it. -->
2
+ <!-- Toolbar row — mirrors TabularToolbar: the search field and the mobile filter
3
+ button (it only shows below md) on the left, the columns menu plus the rows info
4
+ on the right. The active-filters line stays empty until the user filters, so
5
+ nothing stands in for it. -->
6
6
  <div class="animate-pulse-placeholder flex shrink-0 items-center gap-2 border-b border-gray-default p-2">
7
+ <div
8
+ v-if="searchInput"
9
+ class="h-8 w-[clamp(160px,22vw,220px)] shrink-0 rounded bg-gray-200"
10
+ />
7
11
  <div class="h-6 w-24 rounded bg-gray-200 md:hidden" />
8
12
  <div class="flex flex-1 items-center justify-end gap-4">
9
13
  <div class="h-6 w-24 rounded bg-gray-200" />
@@ -83,10 +87,14 @@
83
87
  <script setup lang="ts">
84
88
  import { useTranslation } from '../../composables/useTranslation'
85
89
 
86
- defineProps<{
90
+ withDefaults(defineProps<{
87
91
  // Fill the available height (fullscreen) instead of the default capped height.
88
92
  fill?: boolean
89
- }>()
93
+ // Same as the explorer's: whether the toolbar has its search field.
94
+ searchInput?: boolean
95
+ }>(), {
96
+ searchInput: true,
97
+ })
90
98
 
91
99
  const { t } = useTranslation()
92
100
  </script>
@@ -113,11 +113,12 @@
113
113
  class="py-16 text-center"
114
114
  >
115
115
  <div class="flex flex-col items-center gap-2">
116
- <RiSearchLine
116
+ <component
117
+ :is="queryFailed ? RiErrorWarningLine : RiSearchLine"
117
118
  class="size-8 text-gray-low"
118
119
  aria-hidden="true"
119
120
  />
120
- <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
121
+ <span class="text-sm text-gray-low">{{ emptyMessage }}</span>
121
122
  </div>
122
123
  </td>
123
124
  </tr>
@@ -175,11 +176,12 @@
175
176
  class="py-16 text-center"
176
177
  >
177
178
  <div class="flex flex-col items-center gap-2">
178
- <RiSearchLine
179
+ <component
180
+ :is="queryFailed ? RiErrorWarningLine : RiSearchLine"
179
181
  class="size-8 text-gray-low"
180
182
  aria-hidden="true"
181
183
  />
182
- <span class="text-sm text-gray-low">{{ t('Aucun résultat trouvé.') }}</span>
184
+ <span class="text-sm text-gray-low">{{ emptyMessage }}</span>
183
185
  </div>
184
186
  </div>
185
187
  <div
@@ -256,7 +258,7 @@
256
258
  <script setup lang="ts">
257
259
  import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
258
260
  import { useElementSize, useScroll } from '@vueuse/core'
259
- import { RiArrowDownLine, RiArrowDownSLine, RiArrowUpLine, RiSearchLine } from '@remixicon/vue'
261
+ import { RiArrowDownLine, RiArrowDownSLine, RiArrowUpLine, RiErrorWarningLine, RiSearchLine } from '@remixicon/vue'
260
262
  import AppLink from '../AppLink.vue'
261
263
  import BrandedButton from '../BrandedButton.vue'
262
264
  import InfiniteLoader from '../InfiniteLoader.vue'
@@ -300,8 +302,13 @@ const {
300
302
  getColumnDisplay,
301
303
  getNullPercent,
302
304
  getBooleanCounts,
305
+ queryFailed,
303
306
  } = useTabularContext()
304
307
 
308
+ const emptyMessage = computed(() => queryFailed.value
309
+ ? t('Les données n\'ont pas pu être chargées avec cette recherche ou ces filtres.')
310
+ : t('Aucun résultat trouvé.'))
311
+
305
312
  function getRowHref(row: TabularRow): string | undefined {
306
313
  return props.rowHref?.href(row)
307
314
  }
@@ -1,10 +1,8 @@
1
1
  <template>
2
2
  <div class="flex items-center gap-2">
3
3
  <div class="flex min-w-0 flex-1 items-center gap-1.5">
4
+ <TabularSearchInput v-if="searchInput" />
4
5
  <TabularMobileFilterButton class="md:hidden" />
5
- <div class="hidden md:block">
6
- <TabularActiveFilters with-clear />
7
- </div>
8
6
  </div>
9
7
  <div class="flex shrink-0 items-center gap-4">
10
8
  <TabularColumnsMenu />
@@ -14,12 +12,17 @@
14
12
  </template>
15
13
 
16
14
  <script setup lang="ts">
17
- import TabularActiveFilters from './TabularActiveFilters.vue'
18
15
  import TabularColumnsMenu from './TabularColumnsMenu.vue'
19
16
  import TabularMobileFilterButton from './TabularMobileFilterButton.vue'
20
17
  import TabularRowsInfo from './TabularRowsInfo.vue'
18
+ import TabularSearchInput from './TabularSearchInput.vue'
19
+ import { useTabularContext } from './useTabularContext'
21
20
 
22
21
  // Framing (borders, padding, spacing) belongs to the call site: this toolbar sits in a
23
22
  // bordered panel in the resource viewer and in the page flow on the standalone explore
24
23
  // pages. Single root element, so the caller's `class` merges onto it.
24
+ // The active filters are not in here: they get a line of their own, placed by the
25
+ // call site under the toolbar (<TabularActiveFilters>).
26
+
27
+ const { searchInput } = useTabularContext()
25
28
  </script>