@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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { computed, toValue } from 'vue'
|
|
2
|
+
import type { MaybeRefOrGetter, Ref } from 'vue'
|
|
3
|
+
import { useRouteQuery } from '@vueuse/router'
|
|
4
|
+
import { useRoute } from 'vue-router'
|
|
5
|
+
import type { LocationQueryValue } from 'vue-router'
|
|
6
|
+
import { useComponentsConfig } from '../../config'
|
|
7
|
+
import {
|
|
8
|
+
TABULAR_FILTERS_PARAM,
|
|
9
|
+
TABULAR_SEARCH_PARAM,
|
|
10
|
+
TABULAR_SORT_PARAM,
|
|
11
|
+
filtersFromUrlQuery,
|
|
12
|
+
filtersToUrlQuery,
|
|
13
|
+
searchFromUrlParam,
|
|
14
|
+
sortFromUrlParam,
|
|
15
|
+
sortToUrlParam,
|
|
16
|
+
} from '../../functions/tabular'
|
|
17
|
+
import type { ColumnFilters, SortConfig, TabularUrlAlias } from './types'
|
|
18
|
+
|
|
19
|
+
type QueryRef = Ref<LocationQueryValue | LocationQueryValue[] | undefined>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sort, filters and global search of the explorer, stored in the URL: going back
|
|
23
|
+
* to the page finds them again, and a link to it shares them. Only the `columns`
|
|
24
|
+
* of the resource are read from it.
|
|
25
|
+
*
|
|
26
|
+
* Every param goes through `useRouteQuery`, which batches the writes of a tick
|
|
27
|
+
* into a single `router.replace`: a change never stacks history entries, and
|
|
28
|
+
* moving a filter from an alias to the JSON param is one navigation, not two.
|
|
29
|
+
*/
|
|
30
|
+
export function useTabularUrlState(
|
|
31
|
+
aliases: Record<string, TabularUrlAlias>,
|
|
32
|
+
defaultSort: SortConfig | null,
|
|
33
|
+
columns: MaybeRefOrGetter<readonly string[]>,
|
|
34
|
+
) {
|
|
35
|
+
const config = useComponentsConfig()
|
|
36
|
+
const route = (config.useRoute ?? useRoute)()
|
|
37
|
+
const filterRefs = [...Object.keys(aliases), TABULAR_FILTERS_PARAM]
|
|
38
|
+
.map(param => [param, useRouteQuery(param, undefined, { route }) as QueryRef] as const)
|
|
39
|
+
const sortRef = useRouteQuery(TABULAR_SORT_PARAM, undefined, { route }) as QueryRef
|
|
40
|
+
const searchRef = useRouteQuery(TABULAR_SEARCH_PARAM, undefined, { route }) as QueryRef
|
|
41
|
+
|
|
42
|
+
const filters = computed<Record<string, ColumnFilters>>({
|
|
43
|
+
get: () => filtersFromUrlQuery(
|
|
44
|
+
Object.fromEntries(filterRefs.map(([param, ref]) => [param, ref.value])),
|
|
45
|
+
aliases,
|
|
46
|
+
toValue(columns),
|
|
47
|
+
),
|
|
48
|
+
set: (value) => {
|
|
49
|
+
const query = filtersToUrlQuery(value, aliases)
|
|
50
|
+
for (const [param, ref] of filterRefs) ref.value = query[param]
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const sort = computed<SortConfig | null>({
|
|
55
|
+
get: () => sortFromUrlParam(sortRef.value, defaultSort, toValue(columns)),
|
|
56
|
+
set: (value) => {
|
|
57
|
+
sortRef.value = sortToUrlParam(value, defaultSort)
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const globalSearch = computed<string>({
|
|
62
|
+
get: () => searchFromUrlParam(searchRef.value),
|
|
63
|
+
set: (value) => {
|
|
64
|
+
searchRef.value = value || undefined
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return { filters, sort, globalSearch }
|
|
69
|
+
}
|
|
@@ -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/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { inject, type Component, type InjectionKey } from 'vue'
|
|
2
2
|
import type { UseFetchFunction } from './functions/api.types'
|
|
3
3
|
import type { $Fetch, FetchOptions } from 'ofetch'
|
|
4
|
+
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
4
5
|
|
|
5
6
|
export type PluginConfig = {
|
|
6
7
|
name: string // Name of the application (ex: data.gouv.fr)
|
|
@@ -43,6 +44,12 @@ export type PluginConfig = {
|
|
|
43
44
|
textClamp?: string | Component | null
|
|
44
45
|
appLink?: Component | null
|
|
45
46
|
clientOnly?: Component | null
|
|
47
|
+
/**
|
|
48
|
+
* The route of the page a component is rendered in. vue-router's `useRoute` is the
|
|
49
|
+
* global one: while the next page loads, the page being left is still mounted and
|
|
50
|
+
* already sees the next URL. Nuxt's `useRoute` keeps each page on its own route.
|
|
51
|
+
*/
|
|
52
|
+
useRoute?: () => RouteLocationNormalizedLoaded
|
|
46
53
|
searchDebounce?: number
|
|
47
54
|
forumUrl?: string
|
|
48
55
|
}
|
package/src/functions/tabular.ts
CHANGED
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
} from '@remixicon/vue'
|
|
24
24
|
import { useTranslation } from '../composables/useTranslation'
|
|
25
25
|
import type { TranslationFunction } from '../composables/useTranslation'
|
|
26
|
-
import type {
|
|
26
|
+
import type { LocationQueryValue } from 'vue-router'
|
|
27
|
+
import { DATE_FILTER_OPERATORS } from '../components/TabularExplorer/types'
|
|
28
|
+
import type { ColumnFilters, ColumnType, DateFilter, DateFilterOperator, SortConfig, TabularUrlAlias } from '../components/TabularExplorer/types'
|
|
27
29
|
import { parseDateValue } from './dates'
|
|
28
30
|
|
|
29
31
|
export function hasFilterForColumn(filters: Record<string, ColumnFilters>, column: string): boolean {
|
|
@@ -126,6 +128,137 @@ export function buildCellValueFilter(columnType: ColumnType, value: unknown, exi
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
export const TABULAR_FILTERS_PARAM = 'filters'
|
|
132
|
+
export const TABULAR_SORT_PARAM = 'sort'
|
|
133
|
+
export const TABULAR_SEARCH_PARAM = 'q'
|
|
134
|
+
|
|
135
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
136
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function firstQueryValue(value: LocationQueryValue | LocationQueryValue[] | undefined): string | undefined {
|
|
140
|
+
const first = Array.isArray(value) ? value[0] : value
|
|
141
|
+
return first ?? undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The criteria of a column filter that actually filter something, read from any
|
|
146
|
+
* value: the ones from the URL can be anything a user typed. Empty criteria are
|
|
147
|
+
* dropped too, so that a column left with none of them has no key left.
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeColumnFilters(value: unknown): ColumnFilters {
|
|
150
|
+
if (!isRecord(value)) return {}
|
|
151
|
+
const filter: ColumnFilters = {}
|
|
152
|
+
if (Array.isArray(value.in) && value.in.length && value.in.every(v => typeof v === 'string')) filter.in = value.in
|
|
153
|
+
if (typeof value.exact === 'string' && value.exact) filter.exact = value.exact
|
|
154
|
+
if (typeof value.min === 'number' && Number.isFinite(value.min)) filter.min = value.min
|
|
155
|
+
if (typeof value.max === 'number' && Number.isFinite(value.max)) filter.max = value.max
|
|
156
|
+
if (typeof value.contains === 'string' && value.contains) filter.contains = value.contains
|
|
157
|
+
if (value.null === 'only' || value.null === 'exclude') filter.null = value.null
|
|
158
|
+
const date = value.date
|
|
159
|
+
if (isRecord(date) && DATE_FILTER_OPERATORS.includes(date.operator as DateFilterOperator) && typeof date.start === 'string') {
|
|
160
|
+
filter.date = { operator: date.operator as DateFilterOperator, start: date.start }
|
|
161
|
+
if (typeof date.end === 'string') filter.date.end = date.end
|
|
162
|
+
}
|
|
163
|
+
return filter
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The URL params holding `filters`. A column whose only criterion is the one of
|
|
168
|
+
* an alias is written under that alias; every other column goes, whole, into a
|
|
169
|
+
* single JSON param.
|
|
170
|
+
*/
|
|
171
|
+
export function filtersToUrlQuery(
|
|
172
|
+
filters: Record<string, ColumnFilters>,
|
|
173
|
+
aliases: Record<string, TabularUrlAlias>,
|
|
174
|
+
): Record<string, string> {
|
|
175
|
+
const query: Record<string, string> = {}
|
|
176
|
+
const rest: Record<string, ColumnFilters> = {}
|
|
177
|
+
for (const [column, raw] of Object.entries(filters)) {
|
|
178
|
+
const filter = normalizeColumnFilters(raw)
|
|
179
|
+
const criteria = Object.keys(filter)
|
|
180
|
+
if (!criteria.length) continue
|
|
181
|
+
const alias = criteria.length === 1
|
|
182
|
+
? Object.entries(aliases).find(([, a]) => a.column === column && a.operator === criteria[0])
|
|
183
|
+
: undefined
|
|
184
|
+
const aliasValue = alias && filter[alias[1].operator]
|
|
185
|
+
if (alias && aliasValue) query[alias[0]] = aliasValue
|
|
186
|
+
else rest[column] = filter
|
|
187
|
+
}
|
|
188
|
+
if (Object.keys(rest).length) query[TABULAR_FILTERS_PARAM] = JSON.stringify(rest)
|
|
189
|
+
return query
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The filters a URL holds, restricted to the `columns` of the resource: the
|
|
194
|
+
* Tabular API rejects a query naming any other column, and the explorer would be
|
|
195
|
+
* left without its controls to remove it. Such a column comes from a link to a
|
|
196
|
+
* resource since re-uploaded with other columns, or from a hand-edited URL.
|
|
197
|
+
*/
|
|
198
|
+
export function filtersFromUrlQuery(
|
|
199
|
+
query: Record<string, LocationQueryValue | LocationQueryValue[] | undefined>,
|
|
200
|
+
aliases: Record<string, TabularUrlAlias>,
|
|
201
|
+
columns: readonly string[],
|
|
202
|
+
): Record<string, ColumnFilters> {
|
|
203
|
+
const filters: Record<string, ColumnFilters> = {}
|
|
204
|
+
for (const [param, { column, operator }] of Object.entries(aliases)) {
|
|
205
|
+
const value = firstQueryValue(query[param])
|
|
206
|
+
if (value && columns.includes(column)) filters[column] = { [operator]: value }
|
|
207
|
+
}
|
|
208
|
+
const json = firstQueryValue(query[TABULAR_FILTERS_PARAM])
|
|
209
|
+
if (!json) return filters
|
|
210
|
+
let parsed: unknown
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(json)
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return filters
|
|
216
|
+
}
|
|
217
|
+
if (!isRecord(parsed)) return filters
|
|
218
|
+
for (const [column, raw] of Object.entries(parsed)) {
|
|
219
|
+
const filter = normalizeColumnFilters(raw)
|
|
220
|
+
if (Object.keys(filter).length && columns.includes(column)) filters[column] = filter
|
|
221
|
+
}
|
|
222
|
+
return filters
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function searchFromUrlParam(param: LocationQueryValue | LocationQueryValue[] | undefined): string {
|
|
226
|
+
return firstQueryValue(param) ?? ''
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isSameSort(a: SortConfig | null | undefined, b: SortConfig | null | undefined): boolean {
|
|
230
|
+
return a?.column === b?.column && a?.direction === b?.direction
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The URL param holding `sort`, in the `-column` for descending convention of
|
|
235
|
+
* the udata API. The default sort stays out of the URL; dropping it is written as
|
|
236
|
+
* an empty value, since an absent param already means "the default sort".
|
|
237
|
+
*/
|
|
238
|
+
export function sortToUrlParam(sort: SortConfig | null, defaultSort: SortConfig | null): string | undefined {
|
|
239
|
+
if (isSameSort(sort, defaultSort)) return undefined
|
|
240
|
+
if (!sort) return ''
|
|
241
|
+
return sort.direction === 'desc' ? `-${sort.column}` : sort.column
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The sort a URL holds. One on a column the resource does not have falls back to
|
|
246
|
+
* the default sort, for the same reason as in `filtersFromUrlQuery`.
|
|
247
|
+
*/
|
|
248
|
+
export function sortFromUrlParam(
|
|
249
|
+
param: LocationQueryValue | LocationQueryValue[] | undefined,
|
|
250
|
+
defaultSort: SortConfig | null,
|
|
251
|
+
columns: readonly string[],
|
|
252
|
+
): SortConfig | null {
|
|
253
|
+
const value = firstQueryValue(param)
|
|
254
|
+
if (value === undefined) return defaultSort
|
|
255
|
+
if (value === '') return null
|
|
256
|
+
const sort: SortConfig = value.startsWith('-')
|
|
257
|
+
? { column: value.slice(1), direction: 'desc' }
|
|
258
|
+
: { column: value, direction: 'asc' }
|
|
259
|
+
return columns.includes(sort.column) ? sort : defaultSort
|
|
260
|
+
}
|
|
261
|
+
|
|
129
262
|
export type TypeDisplay = {
|
|
130
263
|
icon: Component
|
|
131
264
|
label: string
|
package/src/main.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { Weight, WellType } from './types/ui'
|
|
|
24
24
|
import type { User, UserReference } from './types/users'
|
|
25
25
|
import type { Report, ReportSubject, ReportReason } from './types/reports'
|
|
26
26
|
import type { Chart, ChartForm, ChartForApi, FilterCondition, Filter, AndFilters, OrFilters, GenericFilter, XAxisType, XAxisSortBy, SortDirection, XAxis, XAxisForm, UnitPosition, YAxis, DataSeriesType, DataSeries, DataSeriesForm, CombinedSort, ColumnDefinition, ColumnsDefinition } from './types/visualizations'
|
|
27
|
-
import type { ColumnFilters, ColumnType, TabularRow } from './components/TabularExplorer/types'
|
|
27
|
+
import type { ColumnFilters, ColumnType, TabularRow, TabularUrlAlias } from './components/TabularExplorer/types'
|
|
28
28
|
import type { GlobalSearchConfig, SearchType, SearchTypeConfig, SortOption, HiddenFilter, BuiltInFilterKey, DatasetSearchConfig, DatasetSearchFilters, DataserviceSearchConfig, DataserviceSearchFilters, ReuseSearchConfig, ReuseSearchFilters, OrganizationSearchConfig, OrganizationSearchFilters, TopicSearchConfig, TopicSearchFilters } from './types/search'
|
|
29
29
|
import { getDefaultDatasetConfig, getDefaultDataserviceConfig, getDefaultReuseConfig, getDefaultOrganizationConfig, getDefaultTopicConfig, getDefaultGlobalSearchConfig, defaultDatasetSortOptions, defaultDataserviceSortOptions, defaultReuseSortOptions, defaultOrganizationSortOptions } from './types/search'
|
|
30
30
|
import { useSearchFilter } from './composables/useSearchFilter'
|
|
@@ -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'
|
|
@@ -269,6 +270,7 @@ export type {
|
|
|
269
270
|
ColumnType,
|
|
270
271
|
ColumnsDefinition,
|
|
271
272
|
TabularRow,
|
|
273
|
+
TabularUrlAlias,
|
|
272
274
|
FilterCondition,
|
|
273
275
|
Filter,
|
|
274
276
|
AndFilters,
|
|
@@ -422,6 +424,7 @@ export {
|
|
|
422
424
|
SelectGroup,
|
|
423
425
|
Listbox,
|
|
424
426
|
InfiniteLoader,
|
|
427
|
+
TabularActiveFilters,
|
|
425
428
|
TabularExplorer,
|
|
426
429
|
TabularMobileFilters,
|
|
427
430
|
TabularTable,
|