@datagouv/components-next 0.2.0 → 1.0.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.
Files changed (140) hide show
  1. package/README.md +1 -1
  2. package/assets/main.css +56 -1
  3. package/dist/Control-BNCDn-8E.js +148 -0
  4. package/dist/{Datafair.client-x39O4yfF.js → Datafair.client-Dls5AHTE.js} +1 -1
  5. package/dist/Event-BOgJUhNR.js +738 -0
  6. package/dist/Image-BN-4XkIn.js +247 -0
  7. package/dist/{JsonPreview.client-BMsC5JcY.js → JsonPreview.client-DPDTs433.js} +14 -14
  8. package/dist/Map-BdT3i2C4.js +7609 -0
  9. package/dist/MapContainer.client-BdAzd7bj.js +105 -0
  10. package/dist/OSM-CamriM9b.js +71 -0
  11. package/dist/{PdfPreview.client-COOkEkRA.js → PdfPreview.client-CopqSDyt.js} +3 -3
  12. package/dist/{Pmtiles.client-BaiIo4VZ.js → Pmtiles.client-mF6xaOO_.js} +2 -2
  13. package/dist/ScaleLine-BiesrgOv.js +165 -0
  14. package/dist/Swagger.client-eJ7gpfZA.js +4 -0
  15. package/dist/Tile-DCuqwNOI.js +1206 -0
  16. package/dist/TileImage-CmZf8EdU.js +1067 -0
  17. package/dist/View-DcDc7N2K.js +2858 -0
  18. package/dist/{XmlPreview.client-CAdN0w_Y.js → XmlPreview.client-C0OgBkSq.js} +7 -7
  19. package/dist/common-C4rDcQpp.js +243 -0
  20. package/dist/components-next.css +1 -1
  21. package/dist/components-next.js +153 -117
  22. package/dist/components.css +1 -1
  23. package/dist/{MapContainer.client-DeSo8EvG.js → index-BRGqW8aQ.js} +4975 -21416
  24. package/dist/leaflet-src-7m1mB8LI.js +6338 -0
  25. package/dist/{main-Dgri3TQL.js → main-CNHxAJ8J.js} +56758 -51450
  26. package/dist/proj-CKwYjU38.js +1569 -0
  27. package/dist/tilecoord-YW3qEH_j.js +884 -0
  28. package/dist/{vue3-xml-viewer.common-D6skc_Ai.js → vue3-xml-viewer.common-CmAdQfIy.js} +1 -1
  29. package/package.json +5 -1
  30. package/src/components/ActivityList/ActivityList.vue +6 -2
  31. package/src/components/AppLink.vue +4 -1
  32. package/src/components/Avatar.vue +2 -2
  33. package/src/components/AvatarWithName.vue +8 -4
  34. package/src/components/BouncingDots.vue +21 -0
  35. package/src/components/BrandedButton.vue +2 -0
  36. package/src/components/CopyButton.vue +19 -7
  37. package/src/components/DataserviceCard.vue +83 -118
  38. package/src/components/DatasetCard.vue +110 -171
  39. package/src/components/DatasetInformation/DatasetEmbedSection.vue +43 -0
  40. package/src/components/DatasetInformation/DatasetInformationSection.vue +73 -0
  41. package/src/components/DatasetInformation/DatasetSchemaSection.vue +74 -0
  42. package/src/components/DatasetInformation/DatasetSpatialSection.vue +59 -0
  43. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +45 -0
  44. package/src/components/DatasetInformation/index.ts +5 -0
  45. package/src/components/DatasetQualityTooltipContent.vue +3 -3
  46. package/src/components/DescriptionList.vue +1 -4
  47. package/src/components/DescriptionListDetails.vue +5 -0
  48. package/src/components/DescriptionListTerm.vue +5 -0
  49. package/src/components/DiscussionMessageCard.vue +63 -0
  50. package/src/components/ExtraAccordion.vue +4 -4
  51. package/src/components/Form/BadgeSelect.vue +35 -0
  52. package/src/components/Form/FormatSelect.vue +28 -0
  53. package/src/components/Form/GeozoneSelect.vue +52 -0
  54. package/src/components/Form/GranularitySelect.vue +29 -0
  55. package/src/components/Form/LicenseSelect.vue +30 -0
  56. package/src/components/Form/OrganizationSelect.vue +62 -0
  57. package/src/components/Form/OrganizationTypeSelect.vue +34 -0
  58. package/src/components/Form/ReuseTopicSelect.vue +29 -0
  59. package/src/components/Form/SchemaSelect.vue +30 -0
  60. package/src/components/Form/SearchableSelect.vue +334 -0
  61. package/src/components/Form/SelectGroup.vue +132 -0
  62. package/src/components/Form/TagSelect.vue +38 -0
  63. package/src/components/LeafletMap.vue +31 -0
  64. package/src/components/LicenseBadge.vue +24 -0
  65. package/src/components/LoadingBlock.vue +23 -2
  66. package/src/components/MarkdownViewer.vue +3 -1
  67. package/src/components/ObjectCard.vue +42 -0
  68. package/src/components/ObjectCardBadge.vue +22 -0
  69. package/src/components/ObjectCardHeader.vue +35 -0
  70. package/src/components/ObjectCardOwner.vue +43 -0
  71. package/src/components/ObjectCardShortDescription.vue +28 -0
  72. package/src/components/OrganizationCard.vue +35 -20
  73. package/src/components/OrganizationLogo.vue +1 -1
  74. package/src/components/OrganizationNameWithCertificate.vue +13 -7
  75. package/src/components/OwnerTypeIcon.vue +1 -0
  76. package/src/components/Pagination.vue +1 -1
  77. package/src/components/Placeholder.vue +5 -2
  78. package/src/components/PostCard.vue +62 -0
  79. package/src/components/RadioGroup.vue +32 -0
  80. package/src/components/RadioInput.vue +64 -0
  81. package/src/components/ResourceAccordion/EditButton.vue +2 -3
  82. package/src/components/ResourceAccordion/MapContainer.client.vue +20 -16
  83. package/src/components/ResourceAccordion/Metadata.vue +11 -24
  84. package/src/components/ResourceAccordion/Pmtiles.client.vue +1 -1
  85. package/src/components/ResourceAccordion/Preview.vue +1 -1
  86. package/src/components/ResourceAccordion/ResourceAccordion.vue +30 -20
  87. package/src/components/ResourceAccordion/ResourceIcon.vue +1 -0
  88. package/src/components/ResourceAccordion/SchemaBadge.vue +2 -2
  89. package/src/components/ResourceExplorer/ResourceExplorer.vue +243 -0
  90. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +116 -0
  91. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +361 -0
  92. package/src/components/ReuseCard.vue +8 -28
  93. package/src/components/ReuseHorizontalCard.vue +80 -0
  94. package/src/components/Search/BasicAndAdvancedFilters.vue +49 -0
  95. package/src/components/Search/Filter/AccessTypeFilter.vue +37 -0
  96. package/src/components/Search/Filter/DatasetBadgeFilter.vue +40 -0
  97. package/src/components/Search/Filter/FilterButtonGroup.vue +78 -0
  98. package/src/components/Search/Filter/FormatFamilyFilter.vue +39 -0
  99. package/src/components/Search/Filter/LastUpdateRangeFilter.vue +37 -0
  100. package/src/components/Search/Filter/ProducerTypeFilter.vue +39 -0
  101. package/src/components/Search/Filter/ReuseTypeFilter.vue +42 -0
  102. package/src/components/Search/GlobalSearch.vue +611 -0
  103. package/src/components/Search/SearchInput.vue +63 -0
  104. package/src/components/Search/Sidemenu.vue +38 -0
  105. package/src/components/StatBox.vue +5 -5
  106. package/src/components/Tag.vue +30 -0
  107. package/src/components/Toggletip.vue +6 -2
  108. package/src/components/Tooltip.vue +2 -3
  109. package/src/components/TopicCard.vue +134 -0
  110. package/src/components/radioGroupContext.ts +9 -0
  111. package/src/composables/useDebouncedRef.ts +31 -0
  112. package/src/composables/useMetrics.ts +4 -3
  113. package/src/composables/useResourceCapabilities.ts +118 -0
  114. package/src/composables/useRouteQueryBoolean.ts +10 -0
  115. package/src/composables/useSelectModelSync.ts +89 -0
  116. package/src/composables/useStableQueryParams.ts +84 -0
  117. package/src/config.ts +4 -0
  118. package/src/functions/api.ts +17 -6
  119. package/src/functions/api.types.ts +4 -2
  120. package/src/functions/datasets.ts +1 -29
  121. package/src/functions/description.ts +33 -0
  122. package/src/functions/helpers.ts +11 -0
  123. package/src/functions/markdown.ts +60 -16
  124. package/src/functions/metrics.ts +33 -0
  125. package/src/functions/organizations.ts +5 -5
  126. package/src/main.ts +89 -7
  127. package/src/types/dataservices.ts +14 -12
  128. package/src/types/datasets.ts +20 -7
  129. package/src/types/discussions.ts +20 -0
  130. package/src/types/licenses.ts +3 -3
  131. package/src/types/organizations.ts +13 -1
  132. package/src/types/owned.ts +4 -2
  133. package/src/types/pages.ts +70 -0
  134. package/src/types/posts.ts +27 -0
  135. package/src/types/resources.ts +6 -0
  136. package/src/types/reuses.ts +14 -5
  137. package/src/types/search.ts +379 -0
  138. package/src/types/users.ts +12 -3
  139. package/dist/Swagger.client-CpLgaLg6.js +0 -4
  140. package/src/components/DatasetInformationPanel.vue +0 -211
@@ -0,0 +1,38 @@
1
+ <template>
2
+ <nav :aria-labelledby="titleId">
3
+ <button
4
+ class="flex w-[calc(100%+2rem)] items-center justify-between -mx-4 px-4 py-3 font-bold md:hidden"
5
+ :aria-expanded="open"
6
+ @click="open = !open"
7
+ >
8
+ {{ buttonText }}
9
+ <RiArrowDownSLine
10
+ class="size-4 transition-transform"
11
+ :class="{ 'rotate-180': open }"
12
+ />
13
+ </button>
14
+ <div v-if="open || !isMobile">
15
+ <p
16
+ :id="titleId"
17
+ class="text-sm font-bold leading-tight mb-6 hidden md:block"
18
+ >
19
+ <slot name="title" />
20
+ </p>
21
+ <slot />
22
+ </div>
23
+ </nav>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ import { ref, useId } from 'vue'
28
+ import { RiArrowDownSLine } from '@remixicon/vue'
29
+ import { useMediaQuery } from '@vueuse/core'
30
+
31
+ defineProps<{
32
+ buttonText: string
33
+ }>()
34
+
35
+ const titleId = useId()
36
+ const isMobile = useMediaQuery('(max-width: 767px)')
37
+ const open = ref(false)
38
+ </script>
@@ -1,8 +1,8 @@
1
1
  <template>
2
2
  <div v-if="size === 'sm'">
3
- <h3 class="text-sm font-bold m-0">
3
+ <p class="text-sm font-bold m-0">
4
4
  {{ title }}
5
- </h3>
5
+ </p>
6
6
  <div class="flex flex-wrap items-center">
7
7
  <ContentLoader
8
8
  v-if="summary === null"
@@ -82,9 +82,9 @@
82
82
  'text-gray-medium': !changesThisYear && !summary,
83
83
  }"
84
84
  >
85
- <h3 class="text-sm m-0">
85
+ <p class="text-sm m-0">
86
86
  {{ title }}
87
- </h3>
87
+ </p>
88
88
  <div class="flex flex-wrap items-center">
89
89
  <ContentLoader
90
90
  v-if="summary === null"
@@ -140,7 +140,7 @@
140
140
  </div>
141
141
  <p
142
142
  v-if="lastValue && lastMonth"
143
- class="mt-2 font-normal text-transform-none fr-badge fr-badge--no-icon fr-badge--success"
143
+ class="mt-2 font-normal normal-case fr-badge fr-badge--no-icon fr-badge--success"
144
144
  >
145
145
  <strong class="mr-1">
146
146
  + {{ summarize(lastValue, 2) }}
@@ -0,0 +1,30 @@
1
+ <template>
2
+ <span
3
+ class="inline-flex items-center space-x-1 text-xs rounded-xl px-2 py-0.5"
4
+ :class="classes"
5
+ >
6
+ <component
7
+ :is="icon"
8
+ v-if="icon"
9
+ class="size-3"
10
+ />
11
+ <span class="-mt-px">
12
+ <slot />
13
+ </span>
14
+ </span>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { computed, type Component } from 'vue'
19
+
20
+ const props = defineProps<{
21
+ icon?: Component
22
+ type: 'secondary'
23
+ }>()
24
+
25
+ const classes = computed(() => {
26
+ return {
27
+ secondary: 'text-gray-title bg-gray-lower',
28
+ }[props.type]
29
+ })
30
+ </script>
@@ -17,7 +17,10 @@
17
17
  :class="{ 'w-8 h-8 rounded-full bg-transparent': styledButton }"
18
18
  >
19
19
  <slot>
20
- <RiInformationLine class="size-5" />
20
+ <RiInformationLine
21
+ class="size-5"
22
+ aria-hidden="true"
23
+ />
21
24
  </slot>
22
25
  </PopoverButton>
23
26
 
@@ -26,9 +29,10 @@
26
29
  <PopoverPanel
27
30
  v-show="open"
28
31
  ref="panel"
29
- class="toggletip absolute z-[800]"
32
+ class="drop-shadow bg-white rounded-sm w-96 absolute z-[800]"
30
33
  :class="{
31
34
  'p-0': noMargin,
35
+ 'p-3': !noMargin,
32
36
  }"
33
37
  :style="floatingStyles"
34
38
  static
@@ -6,14 +6,13 @@
6
6
  @focusout="show = false"
7
7
  @mouseleave="show = false"
8
8
  >
9
- <p
9
+ <span
10
10
  ref="reference"
11
11
  v-bind="$attrs"
12
12
  :aria-describedby="id"
13
- class="!mb-0"
14
13
  >
15
14
  <slot />
16
- </p>
15
+ </span>
17
16
 
18
17
  <div
19
18
  v-if="show"
@@ -0,0 +1,134 @@
1
+ <template>
2
+ <ObjectCard>
3
+ <template
4
+ v-if="showLogo"
5
+ #media
6
+ >
7
+ <OrganizationLogo
8
+ v-if="topic.organization"
9
+ :organization="topic.organization"
10
+ size-class="size-12"
11
+ />
12
+ <Avatar
13
+ v-else-if="topic.owner"
14
+ :user="topic.owner"
15
+ :size="48"
16
+ />
17
+ <Placeholder
18
+ v-else
19
+ type="Topic"
20
+ class="size-12"
21
+ />
22
+ </template>
23
+
24
+ <ObjectCardHeader
25
+ :icon="RiBookShelfLine"
26
+ :url="topicUrl || topic.page || '#'"
27
+ >
28
+ {{ topic.name }}
29
+ </ObjectCardHeader>
30
+
31
+ <div
32
+ v-if="topic.organization || topic.owner"
33
+ class="text-sm m-0 flex flex-wrap md:flex-nowrap gap-y-1 items-center truncate"
34
+ >
35
+ <ObjectCardOwner
36
+ :organization="topic.organization"
37
+ :owner="topic.owner"
38
+ />
39
+ <RiSubtractLine
40
+ v-if="(topic.organization || topic.owner) && topic.last_modified"
41
+ aria-hidden="true"
42
+ class="hidden md:block size-4 flex-none fill-gray-medium"
43
+ />
44
+ <div
45
+ v-if="topic.last_modified"
46
+ class="w-full md:w-auto text-gray-medium whitespace-nowrap"
47
+ >
48
+ {{ t('Mis à jour {date}', { date: formatDate(topic.last_modified) }) }}
49
+ </div>
50
+ </div>
51
+
52
+ <ObjectCardShortDescription :text="topic.description" />
53
+
54
+ <div
55
+ v-if="showStats && (topic.nb_datasets || topic.nb_dataservices || topic.nb_reuses)"
56
+ class="flex items-center gap-4 mt-2 text-xs text-gray-medium"
57
+ >
58
+ <div
59
+ v-if="topic.nb_datasets"
60
+ class="flex items-center gap-1"
61
+ >
62
+ <RiDatabase2Line
63
+ aria-hidden="true"
64
+ class="size-4"
65
+ />
66
+ <span>{{ topic.nb_datasets }}</span>
67
+ </div>
68
+
69
+ <div
70
+ v-if="topic.nb_dataservices"
71
+ class="flex items-center gap-1"
72
+ >
73
+ <RiTerminalLine
74
+ aria-hidden="true"
75
+ class="size-4"
76
+ />
77
+ <span>{{ topic.nb_dataservices }}</span>
78
+ </div>
79
+
80
+ <div
81
+ v-if="topic.nb_reuses"
82
+ class="flex items-center gap-1"
83
+ >
84
+ <RiLineChartLine
85
+ aria-hidden="true"
86
+ class="size-4"
87
+ />
88
+ <span>{{ topic.nb_reuses }}</span>
89
+ </div>
90
+ </div>
91
+
92
+ <slot />
93
+ </ObjectCard>
94
+ </template>
95
+
96
+ <script setup lang="ts">
97
+ import { RiBookShelfLine, RiDatabase2Line, RiLineChartLine, RiSubtractLine, RiTerminalLine } from '@remixicon/vue'
98
+ import type { RouteLocationRaw } from 'vue-router'
99
+ import type { TopicV2 } from '../types/topics'
100
+ import { useFormatDate } from '../functions/dates'
101
+ import { useTranslation } from '../composables/useTranslation'
102
+ import ObjectCardHeader from './ObjectCardHeader.vue'
103
+ import ObjectCardOwner from './ObjectCardOwner.vue'
104
+ import ObjectCardShortDescription from './ObjectCardShortDescription.vue'
105
+ import OrganizationLogo from './OrganizationLogo.vue'
106
+ import Avatar from './Avatar.vue'
107
+ import Placeholder from './Placeholder.vue'
108
+ import ObjectCard from './ObjectCard.vue'
109
+
110
+ type TopicWithStats = TopicV2 & {
111
+ nb_datasets?: number
112
+ nb_dataservices?: number
113
+ nb_reuses?: number
114
+ }
115
+
116
+ withDefaults(defineProps<{
117
+ topic: TopicWithStats
118
+ topicUrl?: RouteLocationRaw
119
+ showLogo?: boolean
120
+ showStats?: boolean
121
+ }>(), {
122
+ showLogo: true,
123
+ showStats: true,
124
+ })
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
+ </script>
@@ -0,0 +1,9 @@
1
+ import type { InjectionKey, Ref } from 'vue'
2
+
3
+ export type RadioGroupContext = {
4
+ name: Ref<string>
5
+ modelValue: Ref<string>
6
+ select: (value: string) => void
7
+ }
8
+
9
+ export const radioGroupInjectionKey: InjectionKey<RadioGroupContext> = Symbol('radioGroup')
@@ -0,0 +1,31 @@
1
+ import { ref, readonly, watch, type Ref } from 'vue'
2
+
3
+ /**
4
+ * Like VueUse's refDebounced but with a flush() method to force immediate sync.
5
+ * Useful when resetting filters - no need to wait for debounce delay.
6
+ */
7
+ export function useDebouncedRef<T>(source: Ref<T>, delay: number) {
8
+ const debounced = ref(source.value) as Ref<T>
9
+ const debouncedReadonly = readonly(debounced)
10
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
11
+
12
+ watch(source, () => {
13
+ if (timeoutId) {
14
+ clearTimeout(timeoutId)
15
+ }
16
+ timeoutId = setTimeout(() => {
17
+ debounced.value = source.value
18
+ timeoutId = null
19
+ }, delay)
20
+ })
21
+
22
+ function flush() {
23
+ if (timeoutId) {
24
+ clearTimeout(timeoutId)
25
+ timeoutId = null
26
+ }
27
+ debounced.value = source.value
28
+ }
29
+
30
+ return { debounced: debouncedReadonly, flush }
31
+ }
@@ -1,11 +1,11 @@
1
1
  import { useComponentsConfig } from '../config'
2
- import { getOrganizationMetrics, getDatasetMetrics, getDataserviceMetrics, getReuseMetrics } from '../functions/metrics'
2
+ import { getOrganizationMetrics, getDatasetMetrics, getDataserviceMetrics, getReuseMetrics, createDatasetsForOrganizationMetricsUrl } from '../functions/metrics'
3
3
 
4
4
  export function useMetrics() {
5
5
  const config = useComponentsConfig()
6
6
 
7
- if (!config.metricsApiUrl) {
8
- throw new Error('metricsApiUrl is not configured in @datagouv/components-next')
7
+ if (!config.metricsApiUrl || !config.apiBase) {
8
+ throw new Error('metricsApiUrl and apiBase must be configured in @datagouv/components-next')
9
9
  }
10
10
 
11
11
  return {
@@ -13,5 +13,6 @@ export function useMetrics() {
13
13
  getDatasetMetrics: (datasetId: string) => getDatasetMetrics(datasetId, config.metricsApiUrl!),
14
14
  getDataserviceMetrics: (dataserviceId: string) => getDataserviceMetrics(dataserviceId, config.metricsApiUrl!),
15
15
  getReuseMetrics: (reuseId: string) => getReuseMetrics(reuseId, config.metricsApiUrl!),
16
+ createDatasetsForOrganizationMetricsUrl: (organizationId: string) => createDatasetsForOrganizationMetricsUrl(organizationId, config.metricsApiUrl!, config.apiBase),
16
17
  }
17
18
  }
@@ -0,0 +1,118 @@
1
+ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
2
+ import { useComponentsConfig } from '../config'
3
+ import { useTranslation } from './useTranslation'
4
+ import { detectOgcService } from '../functions/resources'
5
+ import { isOrganizationCertified } from '../functions/organizations'
6
+ import type { Resource } from '../types/resources'
7
+ import type { Dataset, DatasetV2 } from '../types/datasets'
8
+
9
+ const GENERATED_FORMATS = ['parquet', 'pmtiles', 'geojson']
10
+ 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']
11
+
12
+ export function useResourceCapabilities(
13
+ resource: MaybeRefOrGetter<Resource>,
14
+ dataset: MaybeRefOrGetter<Dataset | DatasetV2>,
15
+ ) {
16
+ const config = useComponentsConfig()
17
+ const { t } = useTranslation()
18
+
19
+ const hasPreview = computed(() => {
20
+ const format = toValue(resource).format?.toLowerCase()
21
+ return format === 'json' || format === 'pdf' || format === 'xml'
22
+ })
23
+
24
+ const hasTabularData = computed(() => {
25
+ const r = toValue(resource)
26
+ return config.tabularApiUrl
27
+ && r.extras['analysis:parsing:parsing_table']
28
+ && !r.extras['analysis:parsing:error']
29
+ && (config.tabularAllowRemote || r.filetype === 'file')
30
+ })
31
+
32
+ const hasPmtiles = computed(() => {
33
+ const r = toValue(resource)
34
+ return r.extras['analysis:parsing:pmtiles_url'] || r.format === 'pmtiles'
35
+ })
36
+
37
+ const hasDatafairPreview = computed(() => {
38
+ const d = toValue(dataset)
39
+ const r = toValue(resource)
40
+ return isOrganizationCertified(d.organization) && r.extras['datafairEmbed']
41
+ })
42
+
43
+ const hasOpenAPIPreview = computed(() => {
44
+ const d = toValue(dataset)
45
+ const r = toValue(resource)
46
+ return isOrganizationCertified(d.organization) && r.extras['apidocUrl']
47
+ })
48
+
49
+ const ogcService = computed(() => detectOgcService(toValue(resource)))
50
+ const ogcWms = computed(() => ogcService.value === 'wms')
51
+
52
+ const generatedFormats = computed(() => {
53
+ const r = toValue(resource)
54
+ const formats = GENERATED_FORMATS
55
+ .filter(format => `analysis:parsing:${format}_url` in r.extras)
56
+ .map(format => ({
57
+ url: r.extras[`analysis:parsing:${format}_url`] as string,
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
+ })
70
+
71
+ const isResourceUrl = computed(() => URL_FORMATS.includes(toValue(resource).format))
72
+
73
+ const tabsOptions = computed(() => {
74
+ const r = toValue(resource)
75
+ const options = []
76
+
77
+ if (hasTabularData.value) {
78
+ options.push({ key: 'data', label: t('Données') })
79
+ }
80
+ else if (hasPreview.value || hasDatafairPreview.value || hasOpenAPIPreview.value) {
81
+ options.push({ key: 'data', label: t('Aperçu') })
82
+ }
83
+
84
+ if (hasTabularData.value) {
85
+ options.push({ key: 'data-structure', label: t('Structure des données') })
86
+ }
87
+
88
+ if (hasPmtiles.value || ogcWms.value) {
89
+ options.push({ key: 'map', label: t('Carte') })
90
+ }
91
+
92
+ if (r.description) {
93
+ options.push({ key: 'description', label: t('Description') })
94
+ }
95
+
96
+ options.push({ key: 'metadata', label: t('Métadonnées') })
97
+ options.push({ key: 'downloads', label: t('Téléchargements') })
98
+
99
+ if (hasTabularData.value) {
100
+ options.push({ key: 'swagger', label: t('Swagger') })
101
+ }
102
+
103
+ return options
104
+ })
105
+
106
+ return {
107
+ hasPreview,
108
+ hasTabularData,
109
+ hasPmtiles,
110
+ hasDatafairPreview,
111
+ hasOpenAPIPreview,
112
+ ogcService,
113
+ ogcWms,
114
+ generatedFormats,
115
+ isResourceUrl,
116
+ tabsOptions,
117
+ }
118
+ }
@@ -0,0 +1,10 @@
1
+ import { useRouteQuery } from '@vueuse/router'
2
+
3
+ export function useRouteQueryBoolean(name: string) {
4
+ return useRouteQuery<string | undefined, boolean | undefined>(name, undefined, {
5
+ transform: {
6
+ get: v => v === 'true' ? true : v === 'false' ? false : undefined,
7
+ set: v => v === undefined ? undefined : String(v),
8
+ },
9
+ })
10
+ }
@@ -0,0 +1,89 @@
1
+ import { ref, watch, type Ref } from 'vue'
2
+
3
+ /**
4
+ * Syncs a model (full object) with an id (identifier only) bidirectionally.
5
+ * Used in *Select components where the parent binds via v-model:id but internally we need the full object.
6
+ */
7
+ export function useSelectModelSync<T>(options: {
8
+ model: Ref<T | null>
9
+ id: Ref<string | undefined>
10
+ items: Ref<T[] | null | undefined>
11
+ getId: (item: T) => string
12
+ }) {
13
+ const { model, id, items, getId } = options
14
+
15
+ watch(model, (newModel) => {
16
+ id.value = newModel ? getId(newModel) : undefined
17
+ })
18
+
19
+ watch([id, items], ([newId]) => {
20
+ if (!newId) {
21
+ model.value = null
22
+ return
23
+ }
24
+ if (model.value && getId(model.value) === newId) return
25
+ model.value = items.value?.find(item => getId(item) === newId) ?? null
26
+ }, { immediate: true })
27
+ }
28
+
29
+ /**
30
+ * Simplified sync for string-based selects where model and id are both strings.
31
+ */
32
+ export function useStringSelectSync(options: {
33
+ model: Ref<string | null>
34
+ id: Ref<string | undefined>
35
+ }) {
36
+ const { model, id } = options
37
+
38
+ watch(model, (newModel) => {
39
+ id.value = newModel ?? undefined
40
+ })
41
+
42
+ watch(id, (newId) => {
43
+ if (!newId) {
44
+ model.value = null
45
+ return
46
+ }
47
+ if (model.value === newId) return
48
+ model.value = newId
49
+ }, { immediate: true })
50
+ }
51
+
52
+ /**
53
+ * Sync with async fetch when id changes and model needs to be fetched.
54
+ * Returns a `fetching` ref for loading state.
55
+ */
56
+ export function useAsyncSelectModelSync<T>(options: {
57
+ model: Ref<T | null>
58
+ id: Ref<string | undefined>
59
+ getId: (item: T) => string
60
+ fetchById: (id: string) => Promise<T | null>
61
+ }) {
62
+ const { model, id, getId, fetchById } = options
63
+ const fetching = ref(false)
64
+
65
+ watch(model, (newModel) => {
66
+ id.value = newModel ? getId(newModel) : undefined
67
+ })
68
+
69
+ watch(id, async (newId) => {
70
+ if (!newId) {
71
+ model.value = null
72
+ return
73
+ }
74
+ if (model.value && getId(model.value) === newId) return
75
+
76
+ fetching.value = true
77
+ try {
78
+ model.value = await fetchById(newId)
79
+ }
80
+ catch {
81
+ model.value = null
82
+ }
83
+ finally {
84
+ fetching.value = false
85
+ }
86
+ }, { immediate: true })
87
+
88
+ return { fetching }
89
+ }
@@ -0,0 +1,84 @@
1
+ import { ref, watch, type Ref } from 'vue'
2
+ import type { SearchTypeConfig } from '../types/search'
3
+
4
+ type FilterRefs = Record<string, Ref<unknown>>
5
+
6
+ interface StableQueryParamsOptions {
7
+ typeConfig: SearchTypeConfig | undefined
8
+ allFilters: FilterRefs
9
+ q: Ref<string>
10
+ sort: Ref<string | undefined>
11
+ page: Ref<number>
12
+ pageSize: number
13
+ }
14
+
15
+ /**
16
+ * Creates a stable ref for query params that only updates when content actually changes.
17
+ * Applies hiddenFilters first, then user filters (which can override hiddenFilters).
18
+ */
19
+ export function useStableQueryParams(options: StableQueryParamsOptions) {
20
+ const { typeConfig, allFilters, q, sort, page, pageSize } = options
21
+ const stableParams = ref<Record<string, unknown>>({})
22
+
23
+ const buildParams = () => {
24
+ const params: Record<string, unknown> = {}
25
+
26
+ // 1. Apply hiddenFilters first (can be overridden by user filters)
27
+ if (typeConfig?.hiddenFilters) {
28
+ for (const hf of typeConfig.hiddenFilters) {
29
+ if (hf) {
30
+ params[hf.key as string] = hf.value
31
+ }
32
+ }
33
+ }
34
+
35
+ // 2. Get enabled filters for this type
36
+ const enabledFilters = [
37
+ ...(typeConfig?.basicFilters ?? []),
38
+ ...(typeConfig?.advancedFilters ?? []),
39
+ ]
40
+
41
+ // 3. Apply user filter values (only enabled ones)
42
+ // Skip undefined/null/empty values so they're not sent to the API
43
+ for (const filterName of enabledFilters) {
44
+ const filterRef = allFilters[filterName as string]
45
+ if (filterRef) {
46
+ const value = filterRef.value
47
+ if (value !== undefined && value !== '' && value !== null) {
48
+ params[filterName as string] = value
49
+ }
50
+ }
51
+ }
52
+
53
+ // 4. Always include q, sort (if valid for this type), page, page_size
54
+ if (q.value) {
55
+ params.q = q.value
56
+ }
57
+ if (sort.value) {
58
+ const validSortValues = typeConfig?.sortOptions?.map(o => o.value as string) ?? []
59
+ if (validSortValues.includes(sort.value)) {
60
+ params.sort = sort.value
61
+ }
62
+ }
63
+ params.page = page.value
64
+ params.page_size = pageSize
65
+
66
+ return params
67
+ }
68
+
69
+ // Watch all dependencies and update only if content changed
70
+ watch(
71
+ [q, sort, page, ...Object.values(allFilters)],
72
+ () => {
73
+ const newParams = buildParams()
74
+ // JSON.stringify comparison is safe here because buildParams() builds the object deterministically
75
+ // (keys are always added in the same order), avoiding the key ordering edge case.
76
+ if (JSON.stringify(newParams) !== JSON.stringify(stableParams.value)) {
77
+ stableParams.value = newParams
78
+ }
79
+ },
80
+ { immediate: true },
81
+ )
82
+
83
+ return stableParams
84
+ }
package/src/config.ts CHANGED
@@ -15,6 +15,8 @@ export type PluginConfig = {
15
15
  metricsApiUrl?: string
16
16
  schemaValidataUrl?: string
17
17
  schemaDocumentationUrl?: string
18
+ schemasSiteUrl?: string
19
+ schemasSiteName?: string
18
20
  tabularApiUrl?: string
19
21
  tabularApiPageSize?: number
20
22
  tabularAllowRemote?: boolean
@@ -27,6 +29,8 @@ export type PluginConfig = {
27
29
  textClamp?: string | Component | null
28
30
  appLink?: Component | null
29
31
  clientOnly?: Component | null
32
+ searchDebounce?: number
33
+ forumUrl?: string
30
34
  }
31
35
 
32
36
  export const configKey = Symbol() as InjectionKey<PluginConfig>