@datagouv/components-next 1.0.2-dev.231 → 1.0.2-dev.233

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 (34) hide show
  1. package/dist/{ImagePreview.client-H7jGf9xS.js → ImagePreview.client-CSRz7eUF.js} +2 -2
  2. package/dist/{JsonPreview.client-CQSCGT6V.js → JsonPreview.client-wT2hfYLv.js} +2 -2
  3. package/dist/{PdfPreview.client-Cz_jnvla.js → PdfPreview.client-DifxM8Th.js} +2 -2
  4. package/dist/{Pmtiles.client-CSoax_YA.js → Pmtiles.client-OyXiX_7g.js} +4 -4
  5. package/dist/{PreviewWrapper-DBlpLgs2.js → PreviewWrapper-D7yfQyyH.js} +1 -1
  6. package/dist/{XmlPreview.client-8vye8TAa.js → XmlPreview.client-BkPGZLn6.js} +2 -2
  7. package/dist/components-next.js +10226 -9938
  8. package/dist/components.css +1 -1
  9. package/dist/{france-Bu5x7PSp.js → france-BNatwStf.js} +1 -1
  10. package/dist/{resources-BBaRfEol.js → resources-kuI_FosB.js} +5 -5
  11. package/dist/{vue-D62CcCib.js → vue-D5eMxsCr.js} +78 -74
  12. package/package.json +1 -1
  13. package/src/components/ResourceExplorer/ResourceExplorer.vue +16 -5
  14. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +140 -90
  15. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +20 -2
  16. package/src/components/ResourceExplorer/ResourceSelector.vue +3 -0
  17. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +3 -5
  18. package/src/components/ResourceIconBadge.vue +28 -0
  19. package/src/components/ResourceListItem.vue +83 -27
  20. package/src/components/TabularExplorer/TabularActiveFilters.vue +94 -54
  21. package/src/components/TabularExplorer/TabularExplorer.vue +102 -25
  22. package/src/components/TabularExplorer/TabularFilterContent.vue +2 -1
  23. package/src/components/TabularExplorer/TabularMobileFilters.vue +4 -3
  24. package/src/components/TabularExplorer/TabularSearchInput.vue +40 -0
  25. package/src/components/TabularExplorer/TabularSkeleton.vue +14 -6
  26. package/src/components/TabularExplorer/TabularTable.vue +12 -5
  27. package/src/components/TabularExplorer/TabularToolbar.vue +7 -4
  28. package/src/components/TabularExplorer/types.ts +9 -1
  29. package/src/components/TabularExplorer/useTabularContext.ts +12 -1
  30. package/src/components/TabularExplorer/useTabularUrlState.ts +69 -0
  31. package/src/composables/useResourceCapabilities.ts +5 -0
  32. package/src/config.ts +7 -0
  33. package/src/functions/tabular.ts +134 -1
  34. package/src/main.ts +4 -1
@@ -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 && canReset"
97
+ color="tertiary"
98
+ size="2xs"
99
+ :icon="RiCloseLine"
100
+ @click="reset"
101
+ >
102
+ {{ t('Tout réinitialiser') }}
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, canReset, reset, 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>
@@ -25,31 +30,49 @@ import { useFetch } from '../../functions/api'
25
30
  import { useComponentsConfig } from '../../config'
26
31
  import { useTranslation } from '../../composables/useTranslation'
27
32
  import { injectTabularProfile } from '../../composables/useTabularProfile'
28
- import { hasFilterForColumn as _hasFilterForColumn, buildDateFilterParams, buildGlobalSearchConditions, useFormatTabular } from '../../functions/tabular'
33
+ import { hasFilterForColumn as _hasFilterForColumn, buildDateFilterParams, buildGlobalSearchConditions, isSameSort, useFormatTabular } from '../../functions/tabular'
29
34
  import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
30
35
  import TabularSkeleton from './TabularSkeleton.vue'
31
- import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters, DateFilter } from './types'
36
+ import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters, DateFilter, TabularUrlAlias } from './types'
32
37
  import { provideTabularContext, type ActiveFilter } from './useTabularContext'
33
38
  import { useColumnMetadata } from './useColumnMetadata'
39
+ import { useTabularUrlState } from './useTabularUrlState'
34
40
 
35
- const props = defineProps<{
41
+ const props = withDefaults(defineProps<{
36
42
  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
43
+ // Whether the toolbar shows its search field. A page with a search of its own
44
+ // hides it and drives the search through `v-model:global-search` instead.
45
+ searchInput?: boolean
44
46
  // Filters seeded on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
45
47
  // The explorer owns them afterwards: later changes to this prop are ignored,
46
48
  // so pass a fresh instance (or remount) to reset them.
49
+ // Ignored with `syncUrl`: the filters come from the URL then.
47
50
  initialFilters?: Record<string, ColumnFilters>
48
51
  // Sort seeded on mount, e.g. { column: 'Séance', direction: 'desc' }.
49
52
  // Same ownership rule as `initialFilters`: the explorer owns it afterwards,
50
53
  // and the user can drop it from the active-sort chip.
54
+ // With `syncUrl`, it is the sort applied when the URL names none.
51
55
  initialSort?: SortConfig
52
- }>()
56
+ // Keeps sort, filters and global search in the URL, so that going back to the
57
+ // page restores them. Only for an explorer that owns its page: the params are
58
+ // not namespaced.
59
+ syncUrl?: boolean
60
+ // With `syncUrl`, readable params for the simple filters of some columns,
61
+ // e.g. { administration: { column: 'Administration', operator: 'contains' } }.
62
+ // Any other filter goes into a JSON `filters` param.
63
+ urlAliases?: Record<string, TabularUrlAlias>
64
+ }>(), {
65
+ searchInput: true,
66
+ })
67
+
68
+ // Searches across multiple columns using the Tabular API's or(...) parameter. Text
69
+ // and categorical columns get a __contains filter; number columns get a __exact
70
+ // filter (since __contains is not supported for numbers by the API). Year, date and
71
+ // boolean columns are excluded.
72
+ // Combined via AND with any column-specific `contains` filter, so it acts as an
73
+ // additional narrowing constraint, not a replacement.
74
+ // Owned here like the filters; a parent binds it to drive or keep it.
75
+ const globalSearchModel = defineModel<string>('globalSearch', { default: '' })
53
76
 
54
77
  const { t } = useTranslation()
55
78
  const config = useComponentsConfig()
@@ -79,14 +102,36 @@ const {
79
102
  getBooleanCounts,
80
103
  } = useColumnMetadata(profileData, allColumns, t)
81
104
 
82
- // Sort & filter state
83
- const sort = ref<SortConfig | null>(props.initialSort ? { ...props.initialSort } : null)
84
- const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
105
+ // Sort, filter & search state
106
+ const urlState = props.syncUrl
107
+ ? useTabularUrlState(props.urlAliases ?? {}, props.initialSort ?? null, allColumns)
108
+ : null
109
+ const { sort, filters, globalSearch } = urlState ?? {
110
+ sort: ref<SortConfig | null>(props.initialSort ? { ...props.initialSort } : null),
111
+ filters: ref<Record<string, ColumnFilters>>({ ...props.initialFilters }),
112
+ globalSearch: globalSearchModel,
113
+ }
114
+
115
+ // With `syncUrl` the search lives in the URL, and a bound `v-model` mirrors it.
116
+ // A search the parent already holds at mount wins: it was typed while the
117
+ // explorer was loading, after the URL was read.
118
+ if (urlState) {
119
+ if (globalSearchModel.value) urlState.globalSearch.value = globalSearchModel.value
120
+ else globalSearchModel.value = urlState.globalSearch.value
121
+ watch(urlState.globalSearch, (value) => {
122
+ globalSearchModel.value = value
123
+ })
124
+ watch(globalSearchModel, (value) => {
125
+ urlState.globalSearch.value = value
126
+ })
127
+ }
85
128
 
86
129
  const PAGE_SIZE = 50
87
130
 
88
- const dataQuery = computed(() => {
89
- const q: Record<string, string | number> = { page: 1, page_size: PAGE_SIZE }
131
+ // What the table shows (sort, filters, search), apart from its pagination: the
132
+ // filtered download sends the very same params, so the file can't drift from the table.
133
+ const tableQuery = computed(() => {
134
+ const q: Record<string, string | number> = {}
90
135
  if (sort.value) {
91
136
  q[`${sort.value.column}__sort`] = sort.value.direction
92
137
  }
@@ -116,21 +161,28 @@ const dataQuery = computed(() => {
116
161
  Object.assign(q, buildDateFilterParams(col, filter.date))
117
162
  }
118
163
  }
119
- if (props.globalSearch && profileData.value?.profile) {
120
- const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
164
+ if (globalSearch.value && profileData.value?.profile) {
165
+ const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, globalSearch.value)
121
166
  q.or = '(' + conditions.join(',') + ')'
122
167
  }
123
168
  return q
124
169
  })
125
170
 
171
+ const dataQuery = computed(() => ({ page: 1, page_size: PAGE_SIZE, ...tableQuery.value }))
172
+
126
173
  const { data: tableData, error, status: dataStatus } = await useFetch<TabularDataResponse>(dataUrl, { raw: true, query: dataQuery })
127
174
 
175
+ // A failed request clears `tableData`: telling a resource that can't be previewed
176
+ // from a search or filter the API rejects needs to know whether it ever loaded.
177
+ const loadedOnce = ref(!!tableData.value)
178
+
128
179
  // The component renders nothing useful until the profile is available
129
180
  // (allColumns is derived from it). Surface a clear loading / error state
130
181
  // so we don't end up with an empty table + a spinner running forever.
131
182
  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))
183
+ const previewError = computed(() => profileError.value || (!loadedOnce.value && error.value))
184
+ const queryFailed = computed(() => loadedOnce.value && !!error.value)
185
+ const previewLoading = computed(() => !previewError.value && (profileLoading.value || (!tableData.value && !loadedOnce.value)))
134
186
  // A search / filter / sort change refetches while the previous rows stay on
135
187
  // screen: without a signal, the table looks unchanged for several seconds.
136
188
  const isRefreshing = computed(() => dataStatus.value === 'pending' && !previewLoading.value)
@@ -145,10 +197,15 @@ const generation = ref(0)
145
197
  watch(() => tableData.value, (data) => {
146
198
  generation.value++
147
199
  if (data) {
200
+ loadedOnce.value = true
148
201
  allRows.value = [...data.data]
149
202
  currentPage.value = 1
150
203
  hasMore.value = data.data.length < data.meta.total
151
204
  }
205
+ else if (queryFailed.value) {
206
+ allRows.value = []
207
+ hasMore.value = false
208
+ }
152
209
  }, { immediate: true })
153
210
 
154
211
  async function loadNextPage() {
@@ -275,8 +332,23 @@ function removeFilter(column: string) {
275
332
  filters.value = rest
276
333
  }
277
334
 
278
- function clearAllFilters() {
335
+ // The Tabular API's CSV export takes the params of the data endpoint and ignores the
336
+ // pagination: the file holds every filtered row, not just the pages loaded on screen.
337
+ // All the columns, not only the displayed ones: the API splits `columns` on every
338
+ // comma, so a column whose name holds one can't be requested by name.
339
+ const filteredDownloadUrl = computed(() => {
340
+ const params = new URLSearchParams(Object.entries(tableQuery.value).map(([key, value]) => [key, String(value)]))
341
+ return `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/csv/?${params}`
342
+ })
343
+
344
+ // Resetting goes back to how the table opened: no filter nor search, but the
345
+ // initial sort — it is the table's default order, not a criterion the user added.
346
+ const canReset = computed(() => activeFilters.value.length > 0 || !!globalSearch.value || !isSameSort(sort.value, props.initialSort))
347
+
348
+ function reset() {
279
349
  filters.value = {}
350
+ globalSearch.value = ''
351
+ sort.value = props.initialSort ? { ...props.initialSort } : null
280
352
  }
281
353
 
282
354
  function hasFilterForColumn(col: string): boolean {
@@ -299,7 +371,12 @@ provideTabularContext({
299
371
  filters,
300
372
  activeFilters,
301
373
  removeFilter,
302
- clearAllFilters,
374
+ canReset,
375
+ reset,
376
+ globalSearch,
377
+ searchInput: computed(() => props.searchInput),
378
+ queryFailed,
379
+ filteredDownloadUrl,
303
380
  hasFilterForColumn,
304
381
  allColumns,
305
382
  visibleColumns,
@@ -250,7 +250,8 @@ watchDebounced(search, (q) => {
250
250
  const existing = filters.value[props.column] ?? {}
251
251
  const operator = isNumeric.value ? 'exact' : 'contains'
252
252
  if (q) {
253
- filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: q } }
253
+ // v-model on a `type="number"` input hands over a number, both criteria are strings
254
+ filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: String(q) } }
254
255
  }
255
256
  else {
256
257
  const { [operator]: _, ...rest } = existing
@@ -96,14 +96,14 @@
96
96
  </div>
97
97
  <!-- Reset all -->
98
98
  <div
99
- v-if="activeFilters.length > 0 || sort"
99
+ v-if="canReset"
100
100
  class="border-t border-gray-default px-4 py-3"
101
101
  >
102
102
  <BrandedButton
103
103
  color="secondary"
104
104
  size="xs"
105
105
  class="w-full"
106
- @click="clearAllFilters(); sort = null; mobileFilterOpen = false"
106
+ @click="reset(); mobileFilterOpen = false"
107
107
  >
108
108
  {{ t('Tout réinitialiser') }}
109
109
  </BrandedButton>
@@ -132,7 +132,8 @@ const {
132
132
  sort,
133
133
  filters,
134
134
  hasFilterForColumn,
135
- clearAllFilters,
135
+ canReset,
136
+ reset,
136
137
  totalLines,
137
138
  getColumnDisplay,
138
139
  getColumnType,
@@ -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 réinitialiser"): 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>
@@ -67,7 +67,8 @@ export type TabularTopValue = {
67
67
 
68
68
  export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
69
69
 
70
- export type DateFilterOperator = 'is' | 'before' | 'after' | 'between'
70
+ export const DATE_FILTER_OPERATORS = ['is', 'before', 'after', 'between'] as const
71
+ export type DateFilterOperator = typeof DATE_FILTER_OPERATORS[number]
71
72
 
72
73
  export type DateFilter = {
73
74
  operator: DateFilterOperator
@@ -87,6 +88,13 @@ export type ColumnFilters = {
87
88
  date?: DateFilter
88
89
  }
89
90
 
91
+ // A short URL param standing for a single-operator filter on one column, e.g.
92
+ // `?administration=Mairie` for `{ Administration: { contains: 'Mairie' } }`.
93
+ export type TabularUrlAlias = {
94
+ column: string
95
+ operator: 'contains' | 'exact'
96
+ }
97
+
90
98
  export type SortDirection = 'asc' | 'desc'
91
99
 
92
100
  export type SortConfig = {
@@ -42,7 +42,18 @@ export type TabularContext = {
42
42
  filters: Ref<Record<string, ColumnFilters>>
43
43
  activeFilters: ComputedRef<ActiveFilter[]>
44
44
  removeFilter: (column: string) => void
45
- clearAllFilters: () => void
45
+ // Back to how the table opened: no filter nor search, the initial sort
46
+ canReset: ComputedRef<boolean>
47
+ reset: () => void
48
+
49
+ // Global search across all columns, and whether the toolbar shows its field
50
+ globalSearch: Ref<string>
51
+ searchInput: ComputedRef<boolean>
52
+ // The last request was rejected after a first successful load: the controls stay,
53
+ // the rows are replaced by the error.
54
+ queryFailed: ComputedRef<boolean>
55
+ // CSV export of the rows matching the filters and search
56
+ filteredDownloadUrl: ComputedRef<string>
46
57
  hasFilterForColumn: (col: string) => boolean
47
58
 
48
59
  // Columns visibility