@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.
- 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 +10226 -9938
- 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 +16 -5
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +140 -90
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +20 -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 +102 -25
- package/src/components/TabularExplorer/TabularFilterContent.vue +2 -1
- package/src/components/TabularExplorer/TabularMobileFilters.vue +4 -3
- 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/types.ts +9 -1
- package/src/components/TabularExplorer/useTabularContext.ts +12 -1
- package/src/components/TabularExplorer/useTabularUrlState.ts +69 -0
- package/src/composables/useResourceCapabilities.ts +5 -0
- package/src/config.ts +7 -0
- package/src/functions/tabular.ts +134 -1
- 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
|
|
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 && 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,
|
|
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
|
|
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>
|
|
@@ -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
|
-
//
|
|
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
|
|
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 &
|
|
83
|
-
const
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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 (
|
|
120
|
-
const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType,
|
|
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(() =>
|
|
133
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
|
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>
|
|
@@ -67,7 +67,8 @@ export type TabularTopValue = {
|
|
|
67
67
|
|
|
68
68
|
export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
|
|
69
69
|
|
|
70
|
-
export
|
|
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
|
-
|
|
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
|