@datagouv/components-next 1.0.2-dev.231 → 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.
- package/dist/{ImagePreview.client-H7jGf9xS.js → ImagePreview.client-CSRz7eUF.js} +2 -2
- package/dist/{JsonPreview.client-CQSCGT6V.js → JsonPreview.client-wT2hfYLv.js} +2 -2
- package/dist/{PdfPreview.client-Cz_jnvla.js → PdfPreview.client-DifxM8Th.js} +2 -2
- package/dist/{Pmtiles.client-CSoax_YA.js → Pmtiles.client-OyXiX_7g.js} +4 -4
- package/dist/{PreviewWrapper-DBlpLgs2.js → PreviewWrapper-D7yfQyyH.js} +1 -1
- package/dist/{XmlPreview.client-8vye8TAa.js → XmlPreview.client-BkPGZLn6.js} +2 -2
- package/dist/components-next.js +9356 -9191
- package/dist/components.css +1 -1
- package/dist/{france-Bu5x7PSp.js → france-BNatwStf.js} +1 -1
- package/dist/{resources-BBaRfEol.js → resources-kuI_FosB.js} +5 -5
- package/dist/{vue-D62CcCib.js → vue-D5eMxsCr.js} +78 -74
- package/package.json +1 -1
- package/src/components/ResourceExplorer/ResourceExplorer.vue +8 -2
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +140 -90
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +17 -2
- package/src/components/ResourceExplorer/ResourceSelector.vue +3 -0
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +3 -5
- package/src/components/ResourceIconBadge.vue +28 -0
- package/src/components/ResourceListItem.vue +83 -27
- package/src/components/TabularExplorer/TabularActiveFilters.vue +94 -54
- package/src/components/TabularExplorer/TabularExplorer.vue +58 -18
- package/src/components/TabularExplorer/TabularSearchInput.vue +40 -0
- package/src/components/TabularExplorer/TabularSkeleton.vue +14 -6
- package/src/components/TabularExplorer/TabularTable.vue +12 -5
- package/src/components/TabularExplorer/TabularToolbar.vue +7 -4
- package/src/components/TabularExplorer/useTabularContext.ts +10 -0
- package/src/composables/useResourceCapabilities.ts +5 -0
- package/src/main.ts +2 -0
|
@@ -1,79 +1,119 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
|
-
v-if="activeFilters.length > 0 || sort"
|
|
4
|
-
class="flex
|
|
3
|
+
v-if="activeFilters.length > 0 || sort || globalSearch"
|
|
4
|
+
class="flex items-center justify-between gap-2"
|
|
5
5
|
>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
<
|
|
12
|
+
<component
|
|
13
|
+
:is="sort.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
|
|
24
14
|
class="size-3.5"
|
|
25
15
|
aria-hidden="true"
|
|
26
16
|
/>
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
<
|
|
36
|
+
<RiSearchLine
|
|
50
37
|
class="size-3.5"
|
|
51
38
|
aria-hidden="true"
|
|
52
39
|
/>
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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 (
|
|
120
|
-
const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType,
|
|
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(() =>
|
|
133
|
-
const
|
|
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
|
|
3
|
-
only shows below md)
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
<
|
|
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">{{
|
|
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
|
-
<
|
|
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">{{
|
|
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>
|
|
@@ -42,7 +42,17 @@ export type TabularContext = {
|
|
|
42
42
|
filters: Ref<Record<string, ColumnFilters>>
|
|
43
43
|
activeFilters: ComputedRef<ActiveFilter[]>
|
|
44
44
|
removeFilter: (column: string) => void
|
|
45
|
+
// Also empties the global search.
|
|
45
46
|
clearAllFilters: () => void
|
|
47
|
+
|
|
48
|
+
// Global search across all columns, and whether the toolbar shows its field
|
|
49
|
+
globalSearch: Ref<string>
|
|
50
|
+
searchInput: ComputedRef<boolean>
|
|
51
|
+
// The last request was rejected after a first successful load: the controls stay,
|
|
52
|
+
// the rows are replaced by the error.
|
|
53
|
+
queryFailed: ComputedRef<boolean>
|
|
54
|
+
// CSV export of the rows matching the filters and search
|
|
55
|
+
filteredDownloadUrl: ComputedRef<string>
|
|
46
56
|
hasFilterForColumn: (col: string) => boolean
|
|
47
57
|
|
|
48
58
|
// Columns visibility
|
|
@@ -82,6 +82,10 @@ export function useResourceCapabilities(
|
|
|
82
82
|
return 'unavailable'
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
// Whether opening the resource shows its content (a preview or a map), not just
|
|
86
|
+
// its metadata.
|
|
87
|
+
const hasPreview = computed(() => previewKind.value !== 'unavailable' || !!hasPmtiles.value || ogcWms.value)
|
|
88
|
+
|
|
85
89
|
const tabsOptions = computed(() => {
|
|
86
90
|
const r = toValue(resource)
|
|
87
91
|
const options = []
|
|
@@ -122,6 +126,7 @@ export function useResourceCapabilities(
|
|
|
122
126
|
|
|
123
127
|
return {
|
|
124
128
|
previewKind,
|
|
129
|
+
hasPreview,
|
|
125
130
|
hasTabularData,
|
|
126
131
|
hasPmtiles,
|
|
127
132
|
hasPmtilesError,
|
package/src/main.ts
CHANGED
|
@@ -109,6 +109,7 @@ import SearchableSelect from './components/Form/SearchableSelect.vue'
|
|
|
109
109
|
import SelectGroup from './components/Form/SelectGroup.vue'
|
|
110
110
|
import Listbox from './components/Form/Listbox.vue'
|
|
111
111
|
import InfiniteLoader from './components/InfiniteLoader.vue'
|
|
112
|
+
import TabularActiveFilters from './components/TabularExplorer/TabularActiveFilters.vue'
|
|
112
113
|
import TabularExplorer from './components/TabularExplorer/TabularExplorer.vue'
|
|
113
114
|
import TabularMobileFilters from './components/TabularExplorer/TabularMobileFilters.vue'
|
|
114
115
|
import TabularTable from './components/TabularExplorer/TabularTable.vue'
|
|
@@ -422,6 +423,7 @@ export {
|
|
|
422
423
|
SelectGroup,
|
|
423
424
|
Listbox,
|
|
424
425
|
InfiniteLoader,
|
|
426
|
+
TabularActiveFilters,
|
|
425
427
|
TabularExplorer,
|
|
426
428
|
TabularMobileFilters,
|
|
427
429
|
TabularTable,
|