@datagouv/components-next 1.0.2-dev.232 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datagouv/components-next",
3
- "version": "1.0.2-dev.232",
3
+ "version": "1.0.2-dev.233",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -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
- const resourceTo = (resource: Resource): RouteLocationRaw => ({
165
- query: { ...route.query, resource_id: resource.id },
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="clearAllFilters(); sort = null"
100
+ @click="reset"
101
101
  >
102
- {{ t('Tout effacer') }}
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, clearAllFilters, getColumnDisplay, filteredDownloadUrl } = useTabularContext()
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 globalSearch = defineModel<string>('globalSearch', { default: '' })
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 & filter state
95
- const sort = ref<SortConfig | null>(props.initialSort ? { ...props.initialSort } : null)
96
- const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
105
+ // Sort, filter & search state
106
+ const urlState = props.syncUrl
107
+ ? useTabularUrlState(props.urlAliases ?? {}, props.initialSort ?? null, allColumns)
108
+ : null
109
+ const { sort, filters, globalSearch } = urlState ?? {
110
+ sort: ref<SortConfig | null>(props.initialSort ? { ...props.initialSort } : null),
111
+ filters: ref<Record<string, ColumnFilters>>({ ...props.initialFilters }),
112
+ globalSearch: globalSearchModel,
113
+ }
114
+
115
+ // With `syncUrl` the search lives in the URL, and a bound `v-model` mirrors it.
116
+ // A search the parent already holds at mount wins: it was typed while the
117
+ // explorer was loading, after the URL was read.
118
+ if (urlState) {
119
+ if (globalSearchModel.value) urlState.globalSearch.value = globalSearchModel.value
120
+ else globalSearchModel.value = urlState.globalSearch.value
121
+ watch(urlState.globalSearch, (value) => {
122
+ globalSearchModel.value = value
123
+ })
124
+ watch(globalSearchModel, (value) => {
125
+ urlState.globalSearch.value = value
126
+ })
127
+ }
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
- function clearAllFilters() {
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
- clearAllFilters,
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
- filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: q } }
253
+ // v-model on a `type="number"` input hands over a number, both criteria are strings
254
+ filters.value = { ...filters.value, [props.column]: { ...existing, [operator]: String(q) } }
254
255
  }
255
256
  else {
256
257
  const { [operator]: _, ...rest } = existing
@@ -96,14 +96,14 @@
96
96
  </div>
97
97
  <!-- Reset all -->
98
98
  <div
99
- v-if="activeFilters.length > 0 || sort"
99
+ v-if="canReset"
100
100
  class="border-t border-gray-default px-4 py-3"
101
101
  >
102
102
  <BrandedButton
103
103
  color="secondary"
104
104
  size="xs"
105
105
  class="w-full"
106
- @click="clearAllFilters(); sort = null; mobileFilterOpen = false"
106
+ @click="reset(); mobileFilterOpen = false"
107
107
  >
108
108
  {{ t('Tout réinitialiser') }}
109
109
  </BrandedButton>
@@ -132,7 +132,8 @@ const {
132
132
  sort,
133
133
  filters,
134
134
  hasFilterForColumn,
135
- clearAllFilters,
135
+ canReset,
136
+ reset,
136
137
  totalLines,
137
138
  getColumnDisplay,
138
139
  getColumnType,
@@ -33,7 +33,7 @@ watch(debounced, (value) => {
33
33
  globalSearch.value = value.trim()
34
34
  })
35
35
 
36
- // Cleared from elsewhere (its chip, "Tout effacer"): the field follows.
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 type DateFilterOperator = 'is' | 'before' | 'after' | 'between'
70
+ export const DATE_FILTER_OPERATORS = ['is', 'before', 'after', 'between'] as const
71
+ export type DateFilterOperator = typeof DATE_FILTER_OPERATORS[number]
71
72
 
72
73
  export type DateFilter = {
73
74
  operator: DateFilterOperator
@@ -87,6 +88,13 @@ export type ColumnFilters = {
87
88
  date?: DateFilter
88
89
  }
89
90
 
91
+ // A short URL param standing for a single-operator filter on one column, e.g.
92
+ // `?administration=Mairie` for `{ Administration: { contains: 'Mairie' } }`.
93
+ export type TabularUrlAlias = {
94
+ column: string
95
+ operator: 'contains' | 'exact'
96
+ }
97
+
90
98
  export type SortDirection = 'asc' | 'desc'
91
99
 
92
100
  export type SortConfig = {
@@ -42,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
- // Also empties the global search.
46
- clearAllFilters: () => void
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
  }
@@ -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 { ColumnFilters, ColumnType, DateFilter } from '../components/TabularExplorer/types'
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,