@datagouv/components-next 1.0.2-dev.232 → 1.0.2-dev.234
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/components-next.js +8787 -8664
- package/package.json +1 -1
- package/src/components/ResourceExplorer/ResourceExplorer.vue +8 -3
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +3 -0
- package/src/components/TabularExplorer/TabularActiveFilters.vue +4 -4
- package/src/components/TabularExplorer/TabularExplorer.vue +45 -8
- package/src/components/TabularExplorer/TabularFilterContent.vue +2 -1
- package/src/components/TabularExplorer/TabularMobileFilters.vue +4 -3
- package/src/components/TabularExplorer/TabularSearchInput.vue +1 -1
- package/src/components/TabularExplorer/types.ts +9 -1
- package/src/components/TabularExplorer/useTabularContext.ts +3 -2
- package/src/components/TabularExplorer/useTabularUrlState.ts +69 -0
- package/src/config.ts +7 -0
- package/src/functions/tabular.ts +134 -1
- package/src/main.ts +2 -1
package/package.json
CHANGED
|
@@ -112,6 +112,7 @@ import { useRoute } from 'vue-router'
|
|
|
112
112
|
import type { RouteLocationRaw } from 'vue-router'
|
|
113
113
|
import { useTranslation } from '../../composables/useTranslation'
|
|
114
114
|
import { useDatasetResources } from '../../composables/useDatasetResources'
|
|
115
|
+
import { TABULAR_FILTERS_PARAM, TABULAR_SEARCH_PARAM, TABULAR_SORT_PARAM } from '../../functions/tabular'
|
|
115
116
|
import type { DatasetV2 } from '../../types/datasets'
|
|
116
117
|
import type { Resource } from '../../types/resources'
|
|
117
118
|
import ResourceExplorerSidebar from './ResourceExplorerSidebar.vue'
|
|
@@ -161,7 +162,11 @@ watch(selectedResource, resource => emit('select', resource), { immediate: true
|
|
|
161
162
|
const sidebarCollapsed = ref(false)
|
|
162
163
|
const resourceListExpanded = ref(false)
|
|
163
164
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
// The filters and sort of the table name the columns of the resource they were
|
|
166
|
+
// set on: carried over, they would query columns the next one may not have. The
|
|
167
|
+
// search goes with them: it was typed for the content of the previous resource.
|
|
168
|
+
const resourceTo = (resource: Resource): RouteLocationRaw => {
|
|
169
|
+
const { [TABULAR_FILTERS_PARAM]: _filters, [TABULAR_SORT_PARAM]: _sort, [TABULAR_SEARCH_PARAM]: _search, ...query } = route.query
|
|
170
|
+
return { query: { ...query, resource_id: resource.id } }
|
|
171
|
+
}
|
|
167
172
|
</script>
|
|
@@ -63,9 +63,12 @@
|
|
|
63
63
|
v-if="previewKind === 'tabular'"
|
|
64
64
|
:timeout="200"
|
|
65
65
|
>
|
|
66
|
+
<!-- Fullscreen, the explorer owns the page and its URL: the filters
|
|
67
|
+
can live there. Inline, the URL belongs to the dataset page. -->
|
|
66
68
|
<TabularExplorer
|
|
67
69
|
v-model:global-search="search"
|
|
68
70
|
:resource-id="resource.id"
|
|
71
|
+
:sync-url="fullscreen"
|
|
69
72
|
>
|
|
70
73
|
<TabularToolbar class="shrink-0 border-b border-gray-default p-2" />
|
|
71
74
|
<div class="hidden shrink-0 md:block">
|
|
@@ -93,13 +93,13 @@
|
|
|
93
93
|
</BrandedButton>
|
|
94
94
|
<!-- Clear all -->
|
|
95
95
|
<BrandedButton
|
|
96
|
-
v-if="withClear"
|
|
96
|
+
v-if="withClear && canReset"
|
|
97
97
|
color="tertiary"
|
|
98
98
|
size="2xs"
|
|
99
99
|
:icon="RiCloseLine"
|
|
100
|
-
@click="
|
|
100
|
+
@click="reset"
|
|
101
101
|
>
|
|
102
|
-
{{ t('Tout
|
|
102
|
+
{{ t('Tout réinitialiser') }}
|
|
103
103
|
</BrandedButton>
|
|
104
104
|
</div>
|
|
105
105
|
</div>
|
|
@@ -115,5 +115,5 @@ import { useTabularContext } from './useTabularContext'
|
|
|
115
115
|
defineProps<{ withClear?: boolean }>()
|
|
116
116
|
|
|
117
117
|
const { t } = useTranslation()
|
|
118
|
-
const { tableData, activeFilters, sort, globalSearch, removeFilter,
|
|
118
|
+
const { tableData, activeFilters, sort, globalSearch, removeFilter, canReset, reset, getColumnDisplay, filteredDownloadUrl } = useTabularContext()
|
|
119
119
|
</script>
|
|
@@ -30,12 +30,13 @@ import { useFetch } from '../../functions/api'
|
|
|
30
30
|
import { useComponentsConfig } from '../../config'
|
|
31
31
|
import { useTranslation } from '../../composables/useTranslation'
|
|
32
32
|
import { injectTabularProfile } from '../../composables/useTabularProfile'
|
|
33
|
-
import { hasFilterForColumn as _hasFilterForColumn, buildDateFilterParams, buildGlobalSearchConditions, useFormatTabular } from '../../functions/tabular'
|
|
33
|
+
import { hasFilterForColumn as _hasFilterForColumn, buildDateFilterParams, buildGlobalSearchConditions, isSameSort, useFormatTabular } from '../../functions/tabular'
|
|
34
34
|
import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
|
|
35
35
|
import TabularSkeleton from './TabularSkeleton.vue'
|
|
36
|
-
import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters, DateFilter } from './types'
|
|
36
|
+
import type { TabularDataResponse, TabularRow, SortConfig, ColumnFilters, DateFilter, TabularUrlAlias } from './types'
|
|
37
37
|
import { provideTabularContext, type ActiveFilter } from './useTabularContext'
|
|
38
38
|
import { useColumnMetadata } from './useColumnMetadata'
|
|
39
|
+
import { useTabularUrlState } from './useTabularUrlState'
|
|
39
40
|
|
|
40
41
|
const props = withDefaults(defineProps<{
|
|
41
42
|
resourceId: string
|
|
@@ -45,11 +46,21 @@ const props = withDefaults(defineProps<{
|
|
|
45
46
|
// Filters seeded on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
|
|
46
47
|
// The explorer owns them afterwards: later changes to this prop are ignored,
|
|
47
48
|
// so pass a fresh instance (or remount) to reset them.
|
|
49
|
+
// Ignored with `syncUrl`: the filters come from the URL then.
|
|
48
50
|
initialFilters?: Record<string, ColumnFilters>
|
|
49
51
|
// Sort seeded on mount, e.g. { column: 'Séance', direction: 'desc' }.
|
|
50
52
|
// Same ownership rule as `initialFilters`: the explorer owns it afterwards,
|
|
51
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.
|
|
52
55
|
initialSort?: SortConfig
|
|
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>
|
|
53
64
|
}>(), {
|
|
54
65
|
searchInput: true,
|
|
55
66
|
})
|
|
@@ -61,7 +72,7 @@ const props = withDefaults(defineProps<{
|
|
|
61
72
|
// Combined via AND with any column-specific `contains` filter, so it acts as an
|
|
62
73
|
// additional narrowing constraint, not a replacement.
|
|
63
74
|
// Owned here like the filters; a parent binds it to drive or keep it.
|
|
64
|
-
const
|
|
75
|
+
const globalSearchModel = defineModel<string>('globalSearch', { default: '' })
|
|
65
76
|
|
|
66
77
|
const { t } = useTranslation()
|
|
67
78
|
const config = useComponentsConfig()
|
|
@@ -91,9 +102,29 @@ const {
|
|
|
91
102
|
getBooleanCounts,
|
|
92
103
|
} = useColumnMetadata(profileData, allColumns, t)
|
|
93
104
|
|
|
94
|
-
// Sort &
|
|
95
|
-
const
|
|
96
|
-
|
|
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
|
+
}
|
|
97
128
|
|
|
98
129
|
const PAGE_SIZE = 50
|
|
99
130
|
|
|
@@ -310,9 +341,14 @@ const filteredDownloadUrl = computed(() => {
|
|
|
310
341
|
return `${config.tabularApiUrl}/api/resources/${props.resourceId}/data/csv/?${params}`
|
|
311
342
|
})
|
|
312
343
|
|
|
313
|
-
|
|
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() {
|
|
314
349
|
filters.value = {}
|
|
315
350
|
globalSearch.value = ''
|
|
351
|
+
sort.value = props.initialSort ? { ...props.initialSort } : null
|
|
316
352
|
}
|
|
317
353
|
|
|
318
354
|
function hasFilterForColumn(col: string): boolean {
|
|
@@ -335,7 +371,8 @@ provideTabularContext({
|
|
|
335
371
|
filters,
|
|
336
372
|
activeFilters,
|
|
337
373
|
removeFilter,
|
|
338
|
-
|
|
374
|
+
canReset,
|
|
375
|
+
reset,
|
|
339
376
|
globalSearch,
|
|
340
377
|
searchInput: computed(() => props.searchInput),
|
|
341
378
|
queryFailed,
|
|
@@ -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,
|
|
@@ -33,7 +33,7 @@ watch(debounced, (value) => {
|
|
|
33
33
|
globalSearch.value = value.trim()
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
// Cleared from elsewhere (its chip, "Tout
|
|
36
|
+
// Cleared from elsewhere (its chip, "Tout réinitialiser"): the field follows.
|
|
37
37
|
watch(globalSearch, (value) => {
|
|
38
38
|
if (value !== text.value.trim()) text.value = value
|
|
39
39
|
})
|
|
@@ -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,8 +42,9 @@ export type TabularContext = {
|
|
|
42
42
|
filters: Ref<Record<string, ColumnFilters>>
|
|
43
43
|
activeFilters: ComputedRef<ActiveFilter[]>
|
|
44
44
|
removeFilter: (column: string) => void
|
|
45
|
-
//
|
|
46
|
-
|
|
45
|
+
// Back to how the table opened: no filter nor search, the initial sort
|
|
46
|
+
canReset: ComputedRef<boolean>
|
|
47
|
+
reset: () => void
|
|
47
48
|
|
|
48
49
|
// Global search across all columns, and whether the toolbar shows its field
|
|
49
50
|
globalSearch: Ref<string>
|
|
@@ -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
|
+
}
|
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'
|
|
@@ -270,6 +270,7 @@ export type {
|
|
|
270
270
|
ColumnType,
|
|
271
271
|
ColumnsDefinition,
|
|
272
272
|
TabularRow,
|
|
273
|
+
TabularUrlAlias,
|
|
273
274
|
FilterCondition,
|
|
274
275
|
Filter,
|
|
275
276
|
AndFilters,
|