@datagouv/components-next 1.2.0 → 1.4.0
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/README.md +7 -6
- package/dist/{Control-ZFh5ta_U.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-rf4T1IkA.js → Datafair.client-D_6yzJUG.js} +9 -9
- package/dist/{Event-DSQcW7OF.js → Event-iXDrp-fy.js} +11 -11
- package/dist/{Image-BijNEG0p.js → Image-mNMBOofs.js} +5 -5
- package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
- package/dist/{JsonPreview.client-dzar6iuh.js → JsonPreview.client-DSl1kiwC.js} +5 -5
- package/dist/{Map-BUtPf5GN.js → Map-BfqKMw0S.js} +1306 -1166
- package/dist/{MapContainer.client-D-MoRNhG.js → MapContainer.client-BSfpk_rT.js} +15 -15
- package/dist/{OSM-D4MTdBtk.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DoDYLmJD.js → PdfPreview.client-BANFeM6K.js} +441 -434
- package/dist/Pmtiles.client-DH9ZrsZR.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
- package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-Dcl7oIVu.js → Tile-CSpn0Ca8.js} +153 -156
- package/dist/{TileImage-BJeHipMX.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-xp_P_OHw.js → View-BYBpt-AU.js} +167 -117
- package/dist/{XmlPreview.client-cOhwff6P.js → XmlPreview.client-KCB5mwVf.js} +5 -5
- package/dist/{common-BjQlan3k.js → common-BoeTUnDZ.js} +26 -26
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +201 -170
- package/dist/components.css +2 -2
- package/dist/{index-NofRBuyf.js → index-DHWzX5vK.js} +5999 -5472
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-pVQfonBI.js} +162 -164
- package/dist/{main-Iz1ZCL6k.js → main-BDcYkhOc.js} +36671 -29402
- package/dist/{proj-CsNo9yH1.js → proj-DxGbobp7.js} +312 -296
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-A0fLnBZr.js → tilecoord-DvX7WWiA.js} +264 -255
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-tVI9uXUz.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
- package/package.json +40 -39
- package/src/components/ActivityList/ActivityList.vue +21 -12
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +111 -17
- package/src/components/Chart/ChartViewerWrapper.vue +28 -16
- package/src/components/ChartCard.vue +112 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +23 -6
- package/src/components/DataserviceQuality.vue +82 -0
- package/src/components/DataserviceQualityInline.vue +31 -0
- package/src/components/DataserviceQualityTooltipContent.vue +79 -0
- package/src/components/DatasetCard.vue +17 -6
- package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
- package/src/components/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- package/src/components/DiscussionMessageCard.vue +14 -15
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/FormattedDate.vue +69 -0
- package/src/components/LoadingBlock.vue +4 -1
- package/src/components/ObjectCard.vue +4 -1
- package/src/components/ObjectCardOwner.vue +13 -1
- package/src/components/Pagination.vue +18 -58
- package/src/components/Placeholder.vue +3 -2
- package/src/components/PostCard.vue +13 -15
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +19 -7
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
- package/src/components/ResourceAccordion/Metadata.vue +21 -5
- package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +30 -19
- package/src/components/ResourceAccordion/Preview.vue +8 -4
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +31 -11
- package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +140 -184
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
- package/src/components/ResourceListItem.vue +168 -0
- package/src/components/ReuseCard.vue +18 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +4 -3
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +28 -10
- package/src/components/Search/SearchInput.vue +1 -1
- package/src/components/Search/Sidemenu.vue +4 -3
- package/src/components/SmallChart.vue +11 -10
- package/src/components/StatBox.vue +13 -5
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +7 -8
- package/src/components/TabularExplorer/TabularCellPopover.vue +21 -5
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularDateField.vue +41 -0
- package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +127 -780
- package/src/components/TabularExplorer/TabularFilterContent.vue +48 -38
- package/src/components/TabularExplorer/TabularFilterPopover.vue +27 -32
- package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
- package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
- package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
- package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
- package/src/components/TabularExplorer/TabularTable.vue +484 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +40 -28
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/components/TopicCard.vue +15 -14
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +2 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +48 -40
- package/src/composables/useSearchFilter.ts +7 -5
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +1 -0
- package/src/functions/api.ts +13 -7
- package/src/functions/charts.ts +32 -1
- package/src/functions/dates.ts +26 -7
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +57 -56
- package/src/functions/owned.ts +21 -0
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +88 -0
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +222 -5
- package/src/functions/tabularApi.ts +1 -43
- package/src/main.ts +32 -6
- package/src/types/dataservices.ts +15 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +2 -2
- package/src/types/visualizations.ts +5 -0
- package/dist/Pmtiles.client-Dzm01Zfm.js +0 -25185
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js +0 -61
- /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
- /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
- /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { computed, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
|
|
2
|
+
import type { TranslationFunction } from '../../composables/useTranslation'
|
|
3
|
+
import {
|
|
4
|
+
buildFormatConfig,
|
|
5
|
+
buildTypeConfig,
|
|
6
|
+
GENERIC_FORMATS,
|
|
7
|
+
humanizeFormat,
|
|
8
|
+
isFalsy,
|
|
9
|
+
isTruthy,
|
|
10
|
+
resolveColumnType,
|
|
11
|
+
} from '../../functions/tabular'
|
|
12
|
+
import type { ColumnDisplay } from './useTabularContext'
|
|
13
|
+
import type {
|
|
14
|
+
ColumnType,
|
|
15
|
+
TabularColumnProfile,
|
|
16
|
+
TabularProfileResponse,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
// Pure derivations of the csv-detective profile: a column's display type, its
|
|
20
|
+
// human-facing label/icon, null ratio and boolean counts.
|
|
21
|
+
// Kept out of TabularExplorer so the fetch/pagination/filter state doesn't drown
|
|
22
|
+
// under a hundred lines of metadata math — and so this logic stays unit-testable
|
|
23
|
+
// in isolation from the component.
|
|
24
|
+
export function useColumnMetadata(
|
|
25
|
+
profileData: Readonly<Ref<TabularProfileResponse | null>>,
|
|
26
|
+
allColumns: MaybeRefOrGetter<string[]>,
|
|
27
|
+
t: TranslationFunction,
|
|
28
|
+
) {
|
|
29
|
+
const typeConfig = buildTypeConfig(t)
|
|
30
|
+
const formatConfig = buildFormatConfig(t)
|
|
31
|
+
|
|
32
|
+
function getColumnProfile(col: string): TabularColumnProfile | null {
|
|
33
|
+
return profileData.value?.profile?.profile?.[col] ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getColumnTypeFromName(col: string): ColumnType {
|
|
37
|
+
const profile = profileData.value?.profile
|
|
38
|
+
if (!profile) return 'text'
|
|
39
|
+
const colInfo = profile.columns[col]
|
|
40
|
+
if (!colInfo) return 'text'
|
|
41
|
+
return resolveColumnType(colInfo, profile.categorical?.includes(col) ?? false)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const columnTypesMap = computed<Record<string, ColumnType>>(() => {
|
|
45
|
+
const map: Record<string, ColumnType> = {}
|
|
46
|
+
for (const col of toValue(allColumns)) {
|
|
47
|
+
map[col] = getColumnTypeFromName(col)
|
|
48
|
+
}
|
|
49
|
+
return map
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function getColumnType(col: string): ColumnType {
|
|
53
|
+
return columnTypesMap.value[col] ?? 'text'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getNullPercent(col: string): string {
|
|
57
|
+
const colProfile = getColumnProfile(col)
|
|
58
|
+
const total = profileData.value?.profile?.total_lines
|
|
59
|
+
if (!colProfile || !total) return '0%'
|
|
60
|
+
return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getTypeConfig(col: string) {
|
|
64
|
+
return typeConfig[getColumnType(col)]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Header display: prefer the rich semantic format detected by csv-detective
|
|
68
|
+
// (code INSEE, UAI, département, SIREN, email…) over the generic display type.
|
|
69
|
+
// Tolerant: generic formats fall back to the type config, and unknown semantic
|
|
70
|
+
// formats are humanized rather than dropped.
|
|
71
|
+
function getColumnDisplay(col: string): ColumnDisplay {
|
|
72
|
+
const format = profileData.value?.profile?.columns?.[col]?.format
|
|
73
|
+
const fallback = getTypeConfig(col)
|
|
74
|
+
if (!format || GENERIC_FORMATS.has(format)) return fallback
|
|
75
|
+
return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
|
|
79
|
+
const profile = getColumnProfile(col)
|
|
80
|
+
if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
|
|
81
|
+
let trueCount = 0
|
|
82
|
+
let falseCount = 0
|
|
83
|
+
for (const top of profile.tops) {
|
|
84
|
+
if (isTruthy(top.value ?? '')) trueCount += top.count
|
|
85
|
+
else if (isFalsy(top.value ?? '')) falseCount += top.count
|
|
86
|
+
}
|
|
87
|
+
return { trueCount, falseCount }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
columnTypesMap,
|
|
92
|
+
getColumnType,
|
|
93
|
+
getColumnProfile,
|
|
94
|
+
getColumnDisplay,
|
|
95
|
+
getNullPercent,
|
|
96
|
+
getBooleanCounts,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Component, ComputedRef, InjectionKey, Ref } from 'vue'
|
|
2
|
+
import { inject, provide } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
ColumnFilters,
|
|
5
|
+
ColumnType,
|
|
6
|
+
SortConfig,
|
|
7
|
+
TabularColumnProfile,
|
|
8
|
+
TabularDataResponse,
|
|
9
|
+
TabularRow,
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
export type ActiveFilter = {
|
|
13
|
+
column: string
|
|
14
|
+
label: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ColumnDisplay = {
|
|
18
|
+
icon: Component
|
|
19
|
+
label: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Data + cross-cutting state shared by the tabular explorer's parts (toolbar, header
|
|
23
|
+
// filters, table). Provided by <TabularExplorer>, injected by children so they can be
|
|
24
|
+
// arranged freely without prop-drilling.
|
|
25
|
+
//
|
|
26
|
+
// NOT here — kept local to whoever renders it, because nothing else needs it:
|
|
27
|
+
// - column widths / resize, cell popover, mobile row expansion → TabularTable
|
|
28
|
+
// - mobile filter sheet's expanded column → TabularMobileFilters
|
|
29
|
+
export type TabularContext = {
|
|
30
|
+
// Data & pagination (the parent owns the fetch, children just render it)
|
|
31
|
+
tableData: Ref<TabularDataResponse | null>
|
|
32
|
+
totalLines: ComputedRef<number>
|
|
33
|
+
allRows: Ref<TabularRow[]>
|
|
34
|
+
hasMore: Ref<boolean>
|
|
35
|
+
loadNextPage: () => Promise<void>
|
|
36
|
+
// A sort / filter / search change is refetching while the previous rows are
|
|
37
|
+
// still on screen.
|
|
38
|
+
isRefreshing: ComputedRef<boolean>
|
|
39
|
+
|
|
40
|
+
// Sort & filters
|
|
41
|
+
sort: Ref<SortConfig | null>
|
|
42
|
+
filters: Ref<Record<string, ColumnFilters>>
|
|
43
|
+
activeFilters: ComputedRef<ActiveFilter[]>
|
|
44
|
+
removeFilter: (column: string) => void
|
|
45
|
+
clearAllFilters: () => void
|
|
46
|
+
hasFilterForColumn: (col: string) => boolean
|
|
47
|
+
|
|
48
|
+
// Columns visibility
|
|
49
|
+
allColumns: ComputedRef<string[]>
|
|
50
|
+
visibleColumns: Ref<Set<string>>
|
|
51
|
+
displayedColumns: ComputedRef<string[]>
|
|
52
|
+
hiddenCount: ComputedRef<number>
|
|
53
|
+
toggleColumn: (col: string) => void
|
|
54
|
+
showAllColumns: () => void
|
|
55
|
+
hideAllColumns: () => void
|
|
56
|
+
selectOnlyColumn: (col: string) => void
|
|
57
|
+
|
|
58
|
+
// Mobile filter sheet open/close (button lives in the toolbar, sheet elsewhere)
|
|
59
|
+
mobileFilterOpen: Ref<boolean>
|
|
60
|
+
|
|
61
|
+
// Column metadata helpers (pure derivations of the profile)
|
|
62
|
+
columnTypesMap: ComputedRef<Record<string, ColumnType>>
|
|
63
|
+
getColumnType: (col: string) => ColumnType
|
|
64
|
+
getColumnProfile: (col: string) => TabularColumnProfile | null
|
|
65
|
+
getColumnDisplay: (col: string) => ColumnDisplay
|
|
66
|
+
getNullPercent: (col: string) => string
|
|
67
|
+
getBooleanCounts: (col: string) => { trueCount: number, falseCount: number }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const TABULAR_CONTEXT_KEY: InjectionKey<TabularContext> = Symbol('tabular-context')
|
|
71
|
+
|
|
72
|
+
export function provideTabularContext(context: TabularContext) {
|
|
73
|
+
provide(TABULAR_CONTEXT_KEY, context)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useTabularContext(): TabularContext {
|
|
77
|
+
const context = inject(TABULAR_CONTEXT_KEY)
|
|
78
|
+
if (!context) {
|
|
79
|
+
throw new Error('useTabularContext() must be used inside a <TabularExplorer>')
|
|
80
|
+
}
|
|
81
|
+
return context
|
|
82
|
+
}
|
|
@@ -35,18 +35,27 @@
|
|
|
35
35
|
<ObjectCardOwner
|
|
36
36
|
:organization="topic.organization"
|
|
37
37
|
:owner="topic.owner"
|
|
38
|
+
:organization-url="organizationUrl"
|
|
38
39
|
/>
|
|
39
40
|
<RiSubtractLine
|
|
40
41
|
v-if="(topic.organization || topic.owner) && topic.last_modified"
|
|
41
42
|
aria-hidden="true"
|
|
42
43
|
class="hidden md:block size-4 flex-none fill-gray-medium"
|
|
43
44
|
/>
|
|
44
|
-
<
|
|
45
|
+
<TranslationT
|
|
45
46
|
v-if="topic.last_modified"
|
|
47
|
+
tag="div"
|
|
46
48
|
class="w-full md:w-auto text-gray-medium whitespace-nowrap"
|
|
49
|
+
keypath="Mis à jour {date}"
|
|
47
50
|
>
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
<template #date>
|
|
52
|
+
<FormattedDate
|
|
53
|
+
:date="topic.last_modified"
|
|
54
|
+
format="relative"
|
|
55
|
+
:options="{ dateStyle: 'long' }"
|
|
56
|
+
/>
|
|
57
|
+
</template>
|
|
58
|
+
</TranslationT>
|
|
50
59
|
</div>
|
|
51
60
|
|
|
52
61
|
<ObjectCardShortDescription :text="topic.description" />
|
|
@@ -97,8 +106,6 @@
|
|
|
97
106
|
import { RiBookShelfLine, RiDatabase2Line, RiLineChartLine, RiSubtractLine, RiTerminalLine } from '@remixicon/vue'
|
|
98
107
|
import type { RouteLocationRaw } from 'vue-router'
|
|
99
108
|
import type { TopicV2 } from '../types/topics'
|
|
100
|
-
import { useFormatDate } from '../functions/dates'
|
|
101
|
-
import { useTranslation } from '../composables/useTranslation'
|
|
102
109
|
import ObjectCardHeader from './ObjectCardHeader.vue'
|
|
103
110
|
import ObjectCardOwner from './ObjectCardOwner.vue'
|
|
104
111
|
import ObjectCardShortDescription from './ObjectCardShortDescription.vue'
|
|
@@ -106,6 +113,8 @@ import OrganizationLogo from './OrganizationLogo.vue'
|
|
|
106
113
|
import Avatar from './Avatar.vue'
|
|
107
114
|
import Placeholder from './Placeholder.vue'
|
|
108
115
|
import ObjectCard from './ObjectCard.vue'
|
|
116
|
+
import FormattedDate from './FormattedDate.vue'
|
|
117
|
+
import TranslationT from './TranslationT.vue'
|
|
109
118
|
|
|
110
119
|
type TopicWithStats = TopicV2 & {
|
|
111
120
|
nb_datasets?: number
|
|
@@ -116,19 +125,11 @@ type TopicWithStats = TopicV2 & {
|
|
|
116
125
|
withDefaults(defineProps<{
|
|
117
126
|
topic: TopicWithStats
|
|
118
127
|
topicUrl?: RouteLocationRaw
|
|
128
|
+
organizationUrl?: RouteLocationRaw | null
|
|
119
129
|
showLogo?: boolean
|
|
120
130
|
showStats?: boolean
|
|
121
131
|
}>(), {
|
|
122
132
|
showLogo: true,
|
|
123
133
|
showStats: true,
|
|
124
134
|
})
|
|
125
|
-
|
|
126
|
-
const { t } = useTranslation()
|
|
127
|
-
const { formatRelativeIfRecentDate } = useFormatDate()
|
|
128
|
-
|
|
129
|
-
const formatDate = (dateString: string) => {
|
|
130
|
-
return formatRelativeIfRecentDate(dateString, {
|
|
131
|
-
dateStyle: 'long',
|
|
132
|
-
})
|
|
133
|
-
}
|
|
134
135
|
</script>
|
|
@@ -60,23 +60,28 @@ export function useActiveDescendant<T extends Option>(options: MaybeRefOrGetter<
|
|
|
60
60
|
|
|
61
61
|
function handleKeyPressForActiveDescendant(key: KeyboardEvent, alreadyMovedDown = false) {
|
|
62
62
|
switch (key.key as CommonKeyboardKeys) {
|
|
63
|
+
// An arrow that moves the active option must not also scroll the page behind it
|
|
63
64
|
case 'ArrowDown':
|
|
64
65
|
if (direction === 'vertical' && !alreadyMovedDown && !key.altKey) {
|
|
66
|
+
key.preventDefault()
|
|
65
67
|
activateNextOption()
|
|
66
68
|
}
|
|
67
69
|
break
|
|
68
70
|
case 'ArrowUp':
|
|
69
71
|
if (direction === 'vertical') {
|
|
72
|
+
key.preventDefault()
|
|
70
73
|
activatePreviousOption()
|
|
71
74
|
}
|
|
72
75
|
break
|
|
73
76
|
case 'ArrowLeft':
|
|
74
77
|
if (direction === 'horizontal') {
|
|
78
|
+
key.preventDefault()
|
|
75
79
|
activatePreviousOption()
|
|
76
80
|
}
|
|
77
81
|
break
|
|
78
82
|
case 'ArrowRight':
|
|
79
83
|
if (direction === 'horizontal' && !alreadyMovedDown && !key.altKey) {
|
|
84
|
+
key.preventDefault()
|
|
80
85
|
activateNextOption()
|
|
81
86
|
}
|
|
82
87
|
break
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
|
2
|
+
import { useRouteQuery } from '@vueuse/router'
|
|
3
|
+
import { useComponentsConfig } from '../config'
|
|
4
|
+
import { useDebouncedRef } from './useDebouncedRef'
|
|
5
|
+
import { useFetch } from '../functions/api'
|
|
6
|
+
import { RESOURCE_EXPLORER_PAGE_SIZE, RESOURCE_TYPE } from '../functions/resources'
|
|
7
|
+
import type { PaginatedArray } from '../types/api'
|
|
8
|
+
import type { DatasetV2 } from '../types/datasets'
|
|
9
|
+
import type { Resource, ResourceGroup, ResourceType } from '../types/resources'
|
|
10
|
+
|
|
11
|
+
// Fetches a dataset's resources grouped by type (paginated per type, searchable)
|
|
12
|
+
// and derives the currently selected resource from the `resource_id` query param.
|
|
13
|
+
// Shared by the dataset page and the explore page so the left navigation behaves
|
|
14
|
+
// identically in both.
|
|
15
|
+
export async function useDatasetResources(datasetGetter: MaybeRefOrGetter<DatasetV2>) {
|
|
16
|
+
const config = useComponentsConfig()
|
|
17
|
+
|
|
18
|
+
const search = ref('')
|
|
19
|
+
const { debounced: searchDebounced, flush } = useDebouncedRef(search, config.searchDebounce ?? 300)
|
|
20
|
+
|
|
21
|
+
const datasetId = computed(() => toValue(datasetGetter).id)
|
|
22
|
+
const url = computed(() => `/api/2/datasets/${datasetId.value}/resources/`)
|
|
23
|
+
|
|
24
|
+
const makeParams = (type: ResourceType) => computed(() => ({
|
|
25
|
+
type,
|
|
26
|
+
page_size: RESOURCE_EXPLORER_PAGE_SIZE,
|
|
27
|
+
q: searchDebounced.value || undefined,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
// Evaluated once at setup (before any search) — never changes afterwards
|
|
31
|
+
const hasAnyResources = computed(() => toValue(datasetGetter).resources.total > 0)
|
|
32
|
+
|
|
33
|
+
const extraResourcesByType: Ref<Resource[]>[] = RESOURCE_TYPE.map(() => ref<Resource[]>([]))
|
|
34
|
+
const pageByType: Ref<number>[] = RESOURCE_TYPE.map(() => ref(1))
|
|
35
|
+
|
|
36
|
+
watch(searchDebounced, () => {
|
|
37
|
+
for (let i = 0; i < RESOURCE_TYPE.length; i++) {
|
|
38
|
+
extraResourcesByType[i]!.value = []
|
|
39
|
+
pageByType[i]!.value = 1
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Separate useFetch for loadMore, initialized with immediate: false so it doesn't
|
|
44
|
+
// fetch until execute() is called from the event handler.
|
|
45
|
+
const loadMoreType = ref<ResourceType>('main')
|
|
46
|
+
const loadMorePage = ref(1)
|
|
47
|
+
const loadMoreParams = computed(() => ({
|
|
48
|
+
type: loadMoreType.value,
|
|
49
|
+
page_size: RESOURCE_EXPLORER_PAGE_SIZE,
|
|
50
|
+
page: loadMorePage.value,
|
|
51
|
+
q: searchDebounced.value || undefined,
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const resourceIdQuery = useRouteQuery<string | undefined>('resource_id')
|
|
55
|
+
const initialResourceId = resourceIdQuery.value
|
|
56
|
+
|
|
57
|
+
// Vue rule: composables must be called synchronously, before any `await` — `inject()`
|
|
58
|
+
// needs the active component instance, which a plain .ts composable loses after its
|
|
59
|
+
// first await (documented in useTabularProfile.ts). The package's `useFetch` calls
|
|
60
|
+
// `useComponentsConfig()` (an inject) on every call, so we kick off every request
|
|
61
|
+
// up-front, then await them together. This is also the idiomatic Nuxt parallel-fetch.
|
|
62
|
+
const typeFetches = RESOURCE_TYPE.map(type =>
|
|
63
|
+
useFetch<PaginatedArray<Resource>>(url, {
|
|
64
|
+
params: makeParams(type),
|
|
65
|
+
// Only main is server-rendered; the rest hydrate client-side to keep SSR light.
|
|
66
|
+
server: type === 'main',
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
const loadMoreFetch = useFetch<PaginatedArray<Resource>>(url, { params: loadMoreParams, immediate: false, watch: false })
|
|
70
|
+
// The resource pointed at by `resource_id` (SSR / deep link) may live beyond the first
|
|
71
|
+
// page of its type group, so it wouldn't be in flatResources — fetch it directly.
|
|
72
|
+
const fetchedResourceFetch = initialResourceId
|
|
73
|
+
? useFetch<Resource>(`/api/1/datasets/${datasetId.value}/resources/${initialResourceId}/`)
|
|
74
|
+
: null
|
|
75
|
+
|
|
76
|
+
const typeResults = await Promise.all(typeFetches)
|
|
77
|
+
const { data: loadMoreData, execute: executeLoadMore } = await loadMoreFetch
|
|
78
|
+
const fetchedResource = fetchedResourceFetch ? (await fetchedResourceFetch).data : ref<Resource | null>(null)
|
|
79
|
+
|
|
80
|
+
const rawDataByType = typeResults.map(result => result.data)
|
|
81
|
+
|
|
82
|
+
// Which type group is currently loading its next page. A single loadMoreFetch is
|
|
83
|
+
// shared across types, so we track the type here to show the spinner on the right
|
|
84
|
+
// group's "Charger plus…" button.
|
|
85
|
+
const loadingType = ref<ResourceType | null>(null)
|
|
86
|
+
|
|
87
|
+
const loadMore = async (type: ResourceType) => {
|
|
88
|
+
const index = RESOURCE_TYPE.indexOf(type)
|
|
89
|
+
if (index === -1) return
|
|
90
|
+
const pageRef = pageByType[index]!
|
|
91
|
+
const extraRef = extraResourcesByType[index]!
|
|
92
|
+
pageRef.value++
|
|
93
|
+
|
|
94
|
+
loadMoreType.value = type
|
|
95
|
+
loadMorePage.value = pageRef.value
|
|
96
|
+
loadingType.value = type
|
|
97
|
+
try {
|
|
98
|
+
await executeLoadMore()
|
|
99
|
+
|
|
100
|
+
if (loadMoreData.value) {
|
|
101
|
+
extraRef.value = [...extraRef.value, ...loadMoreData.value.data]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
loadingType.value = null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const groups = computed<ResourceGroup[]>(() => {
|
|
110
|
+
return RESOURCE_TYPE
|
|
111
|
+
.map((type, index) => {
|
|
112
|
+
const rawData = rawDataByType[index]!
|
|
113
|
+
const extraData = extraResourcesByType[index]!
|
|
114
|
+
return {
|
|
115
|
+
type: type as ResourceType,
|
|
116
|
+
total: rawData.value?.total ?? 0,
|
|
117
|
+
items: [...(rawData.value?.data ?? []), ...extraData.value],
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
.filter(group => group.items.length > 0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const flatResources = computed(() => groups.value.flatMap(g => g.items))
|
|
124
|
+
|
|
125
|
+
// The URL is the single source of truth for the selection: no ref to keep in
|
|
126
|
+
// sync, navigation is done with plain links that change `resource_id`.
|
|
127
|
+
let lastShown: Resource | null = null
|
|
128
|
+
const remember = (resource: Resource | null) => {
|
|
129
|
+
if (resource) lastShown = resource
|
|
130
|
+
return resource
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const selectedResource = computed<Resource | null>(() => {
|
|
134
|
+
const wantedId = resourceIdQuery.value
|
|
135
|
+
const fromList = flatResources.value.find(r => r.id === wantedId)
|
|
136
|
+
if (fromList) return remember(fromList)
|
|
137
|
+
// `fetchedResource` answers for `initialResourceId` only: it holds that one
|
|
138
|
+
// resource, so using it for any other id would show something the URL doesn't
|
|
139
|
+
// point at.
|
|
140
|
+
if (wantedId && wantedId === initialResourceId) return remember(fetchedResource.value)
|
|
141
|
+
// A search that matches nothing narrows the navigation, it doesn't unload the
|
|
142
|
+
// viewer — keep showing what we had, which is the dataset's first resource when
|
|
143
|
+
// nothing has been clicked yet.
|
|
144
|
+
return remember(flatResources.value[0] ?? null) ?? lastShown
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
function updateSearch(newSearch: string) {
|
|
148
|
+
search.value = newSearch
|
|
149
|
+
// Only a reset skips the debounce: typing must stay debounced, since each
|
|
150
|
+
// change fires one request per resource type.
|
|
151
|
+
if (!newSearch) flush()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
groups,
|
|
156
|
+
flatResources,
|
|
157
|
+
hasAnyResources,
|
|
158
|
+
selectedResource,
|
|
159
|
+
loadMore,
|
|
160
|
+
loadingType,
|
|
161
|
+
search,
|
|
162
|
+
updateSearch,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useComponentsConfig } from '../config'
|
|
2
|
+
import { hasTabularParsingError } from '../functions/resources'
|
|
2
3
|
import type { Resource } from '../types/resources'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -20,7 +21,7 @@ export const useHasTabularData = () => {
|
|
|
20
21
|
return (
|
|
21
22
|
config.tabularApiUrl
|
|
22
23
|
&& resource.extras['analysis:parsing:parsing_table']
|
|
23
|
-
&& !resource
|
|
24
|
+
&& !hasTabularParsingError(resource)
|
|
24
25
|
&& !sourceUnreachable
|
|
25
26
|
&& (config.tabularAllowRemote || resource.filetype === 'file')
|
|
26
27
|
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { useEventListener } from '@vueuse/core'
|
|
3
|
+
|
|
4
|
+
type UseResizableOptions = {
|
|
5
|
+
initialWidth: number
|
|
6
|
+
minWidth: number
|
|
7
|
+
maxWidth: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Drag-to-resize a horizontal panel. Returns the reactive width, a `resizing`
|
|
11
|
+
// flag (to disable width transitions while dragging) and a `startResize` handler
|
|
12
|
+
// to bind on a drag handle's `mousedown`. Encapsulates the drag math, clamping and
|
|
13
|
+
// the temporary body cursor / text-selection lock. VueUse has no ready-made panel
|
|
14
|
+
// resize (useResizeObserver only observes size), so we build it on `useEventListener`
|
|
15
|
+
// which handles listener cleanup and SSR for us.
|
|
16
|
+
export function useResizable({ initialWidth, minWidth, maxWidth }: UseResizableOptions) {
|
|
17
|
+
const width = ref(initialWidth)
|
|
18
|
+
const resizing = ref(false)
|
|
19
|
+
let startX = 0
|
|
20
|
+
let startWidth = 0
|
|
21
|
+
|
|
22
|
+
const clamp = (value: number) => Math.min(maxWidth, Math.max(minWidth, Math.round(value)))
|
|
23
|
+
|
|
24
|
+
function startResize(event: MouseEvent) {
|
|
25
|
+
event.preventDefault()
|
|
26
|
+
startX = event.clientX
|
|
27
|
+
startWidth = width.value
|
|
28
|
+
resizing.value = true
|
|
29
|
+
document.body.style.userSelect = 'none'
|
|
30
|
+
document.body.style.cursor = 'col-resize'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
useEventListener('mousemove', (event: MouseEvent) => {
|
|
34
|
+
if (!resizing.value) return
|
|
35
|
+
width.value = clamp(startWidth + event.clientX - startX)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
useEventListener('mouseup', () => {
|
|
39
|
+
if (!resizing.value) return
|
|
40
|
+
resizing.value = false
|
|
41
|
+
document.body.style.userSelect = ''
|
|
42
|
+
document.body.style.cursor = ''
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return { width, resizing, startResize }
|
|
46
|
+
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { computed,
|
|
1
|
+
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
|
|
2
2
|
import { useComponentsConfig } from '../config'
|
|
3
|
-
import { useTranslation } from './useTranslation'
|
|
4
|
-
import { useHasTabularData } from './useHasTabularData'
|
|
5
|
-
import { detectOgcService } from '../functions/resources'
|
|
6
3
|
import { isOrganizationCertified } from '../functions/organizations'
|
|
7
|
-
import
|
|
4
|
+
import { getGeneratedFormats, getWfsExportFormats } from '../functions/resourceCapabilities'
|
|
5
|
+
import { detectOgcService, getParsingErrorMessage, getParsingErrorStep, isImagePreviewFormat } from '../functions/resources'
|
|
8
6
|
import type { Dataset, DatasetV2 } from '../types/datasets'
|
|
9
|
-
import {
|
|
7
|
+
import type { Resource, WfsMetadata } from '../types/resources'
|
|
8
|
+
import { useHasTabularData } from './useHasTabularData'
|
|
9
|
+
import { useTranslation } from './useTranslation'
|
|
10
10
|
|
|
11
|
-
const GENERATED_FORMATS = ['parquet', 'pmtiles', 'geojson']
|
|
12
11
|
const URL_FORMATS = ['url', 'doi', 'www:link', 'www:link-1.0-http--link', 'www:link-1.0-http--partners', 'www:link-1.0-http--related', 'www:link-1.0-http--samples']
|
|
13
12
|
|
|
13
|
+
// Which preview the data tab shows. Single source of truth: the viewer renders it and
|
|
14
|
+
// the skeleton picks the matching placeholder shape, so each holding its own copy of
|
|
15
|
+
// this precedence would let the two drift apart.
|
|
16
|
+
export type PreviewKind = 'json' | 'pdf' | 'xml' | 'image' | 'datafair' | 'openapi' | 'tabular' | 'unavailable'
|
|
17
|
+
|
|
14
18
|
export function useResourceCapabilities(
|
|
15
19
|
resource: MaybeRefOrGetter<Resource>,
|
|
16
20
|
dataset: MaybeRefOrGetter<Dataset | DatasetV2>,
|
|
@@ -19,11 +23,6 @@ export function useResourceCapabilities(
|
|
|
19
23
|
const { t } = useTranslation()
|
|
20
24
|
const checkTabularData = useHasTabularData()
|
|
21
25
|
|
|
22
|
-
const hasPreview = computed(() => {
|
|
23
|
-
const format = toValue(resource).format?.toLowerCase()
|
|
24
|
-
return format === 'json' || format === 'pdf' || format === 'xml'
|
|
25
|
-
})
|
|
26
|
-
|
|
27
26
|
const hasTabularData = computed(() => {
|
|
28
27
|
const r = toValue(resource)
|
|
29
28
|
return checkTabularData(r)
|
|
@@ -49,24 +48,12 @@ export function useResourceCapabilities(
|
|
|
49
48
|
const ogcService = computed(() => detectOgcService(toValue(resource)))
|
|
50
49
|
const ogcWms = computed(() => ogcService.value === 'wms')
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
size: r.extras[`analysis:parsing:${format}_size`] as number | undefined,
|
|
59
|
-
format,
|
|
60
|
-
}))
|
|
61
|
-
if ('analysis:parsing:parsing_table' in r.extras) {
|
|
62
|
-
formats.push({
|
|
63
|
-
url: `${config.tabularApiUrl}/api/resources/${r.id}/data/json/`,
|
|
64
|
-
size: undefined,
|
|
65
|
-
format: 'json',
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
return formats
|
|
69
|
-
})
|
|
51
|
+
// The map is built from a pmtiles export; when that export fails we still want
|
|
52
|
+
// to show the Carte tab, but with the reason it could not be generated.
|
|
53
|
+
const hasPmtilesError = computed(() => getParsingErrorStep(toValue(resource)) === 'pmtiles_export')
|
|
54
|
+
const pmtilesError = computed(() => hasPmtilesError.value ? getParsingErrorMessage(toValue(resource)) : null)
|
|
55
|
+
|
|
56
|
+
const generatedFormats = computed(() => getGeneratedFormats(toValue(resource), config.tabularApiUrl))
|
|
70
57
|
|
|
71
58
|
const wfsFormats = computed(() => {
|
|
72
59
|
return getWfsExportFormats(toValue(resource))
|
|
@@ -81,43 +68,64 @@ export function useResourceCapabilities(
|
|
|
81
68
|
|
|
82
69
|
const isResourceUrl = computed(() => URL_FORMATS.includes(toValue(resource).format))
|
|
83
70
|
|
|
71
|
+
// A dedicated preview always wins over the interactive table: a PDF that also
|
|
72
|
+
// carries a parsing table is a PDF first.
|
|
73
|
+
const previewKind = computed<PreviewKind>(() => {
|
|
74
|
+
const format = toValue(resource).format?.toLowerCase()
|
|
75
|
+
if (format === 'json') return 'json'
|
|
76
|
+
if (format === 'pdf') return 'pdf'
|
|
77
|
+
if (format === 'xml') return 'xml'
|
|
78
|
+
if (isImagePreviewFormat(format)) return 'image'
|
|
79
|
+
if (hasDatafairPreview.value) return 'datafair'
|
|
80
|
+
if (hasOpenAPIPreview.value) return 'openapi'
|
|
81
|
+
if (hasTabularData.value) return 'tabular'
|
|
82
|
+
return 'unavailable'
|
|
83
|
+
})
|
|
84
|
+
|
|
84
85
|
const tabsOptions = computed(() => {
|
|
85
86
|
const r = toValue(resource)
|
|
86
87
|
const options = []
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
const dataTab = { key: 'data', label: hasTabularData.value ? t('Données') : t('Aperçu') }
|
|
90
|
+
const mapTab = { key: 'map', label: t('Carte') }
|
|
91
|
+
const hasMap = hasPmtiles.value || ogcWms.value
|
|
92
|
+
|
|
93
|
+
// The map is the most visual preview: when it is available, show it first.
|
|
94
|
+
// When its generation failed, keep the data preview first and surface the
|
|
95
|
+
// Carte tab (with its error) right after.
|
|
96
|
+
if (hasMap) {
|
|
97
|
+
options.push(mapTab, dataTab)
|
|
98
|
+
}
|
|
99
|
+
else if (hasPmtilesError.value) {
|
|
100
|
+
options.push(dataTab, mapTab)
|
|
90
101
|
}
|
|
91
102
|
else {
|
|
92
|
-
options.push(
|
|
103
|
+
options.push(dataTab)
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
if (hasTabularData.value) {
|
|
96
107
|
options.push({ key: 'data-structure', label: t('Structure des données') })
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
if (hasPmtiles.value || ogcWms.value) {
|
|
100
|
-
options.push({ key: 'map', label: t('Carte') })
|
|
101
|
-
}
|
|
102
|
-
|
|
103
110
|
if (r.description) {
|
|
104
111
|
options.push({ key: 'description', label: t('Description') })
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
options.push({ key: 'metadata', label: t('Métadonnées') })
|
|
108
|
-
options.push({ key: 'downloads', label: t('Téléchargements') })
|
|
109
115
|
|
|
110
116
|
if (hasTabularData.value) {
|
|
111
|
-
options.push({ key: '
|
|
117
|
+
options.push({ key: 'api', label: t('API') })
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
return options
|
|
115
121
|
})
|
|
116
122
|
|
|
117
123
|
return {
|
|
118
|
-
|
|
124
|
+
previewKind,
|
|
119
125
|
hasTabularData,
|
|
120
126
|
hasPmtiles,
|
|
127
|
+
hasPmtilesError,
|
|
128
|
+
pmtilesError,
|
|
121
129
|
hasDatafairPreview,
|
|
122
130
|
hasOpenAPIPreview,
|
|
123
131
|
ogcService,
|
|
@@ -94,10 +94,12 @@ export function useSearchFilter(
|
|
|
94
94
|
})
|
|
95
95
|
|
|
96
96
|
onScopeDispose(() => {
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
97
|
+
// Type-scoped filters (typeKeys set) are cleared by GlobalSearch's
|
|
98
|
+
// watch(currentType) instead, so their URL update batches with the other
|
|
99
|
+
// type-change resets via useRouteQuery rather than racing them with the
|
|
100
|
+
// separate router.replace below. Here we only clear filters that apply to
|
|
101
|
+
// all types: their unmount is a consumer signal unrelated to a type switch,
|
|
102
|
+
// which GlobalSearch has no way to detect.
|
|
101
103
|
//
|
|
102
104
|
// We cannot use `value.value = defaultValue` here because VueUse's
|
|
103
105
|
// useRouteQuery registers its own tryOnScopeDispose cleanup that zeroes
|
|
@@ -105,7 +107,7 @@ export function useSearchFilter(
|
|
|
105
107
|
// The setter then sees `query === v` and early-returns without ever
|
|
106
108
|
// calling router.replace(). Instead we read the live route.query directly
|
|
107
109
|
// (which is router state, not affected by that cleanup) and push the update.
|
|
108
|
-
if (route.query[urlParam] !== undefined) {
|
|
110
|
+
if (normalizedTypeKeys === undefined && route.query[urlParam] !== undefined) {
|
|
109
111
|
const { [urlParam]: _removed, ...restQuery } = route.query
|
|
110
112
|
router.replace({
|
|
111
113
|
query: defaultValue === undefined ? restQuery : { ...restQuery, [urlParam]: String(defaultValue) },
|