@datagouv/components-next 1.0.2-dev.19 → 1.0.2-dev.191

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 (156) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-fxOwwaND.js} +9 -9
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-XSR9THWR.js +36 -0
  8. package/dist/JsonPreview.client-CU1FWFIy.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-D0-Dn7jq.js} +41 -42
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-B7eDgq8y.js} +835 -856
  13. package/dist/Pmtiles.client-CCpvggkZ.js +26333 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BzhNjYoY.js +60 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-CyjpNh5i.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +194 -149
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-BBnxH-Nj.js} +21842 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-Be0Ohp2k.js} +185 -187
  26. package/dist/{main-vqhkVhVS.js → main-C2kCZkPD.js} +41515 -73245
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-__BZsi8i.js} +16 -16
  32. package/package.json +47 -38
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +3 -2
  35. package/src/components/AppLink.vue +3 -0
  36. package/src/components/BannerAction.vue +6 -2
  37. package/src/components/Chart/ChartViewer.vue +320 -0
  38. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  39. package/src/components/CopyButton.vue +6 -2
  40. package/src/components/DataserviceCard.vue +9 -0
  41. package/src/components/DataserviceQuality.vue +82 -0
  42. package/src/components/DataserviceQualityInline.vue +31 -0
  43. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  44. package/src/components/DatasetCard.vue +9 -4
  45. package/src/components/DatasetQuality.vue +11 -11
  46. package/src/components/DatasetQualityInline.vue +2 -2
  47. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  48. package/src/components/Form/Listbox.vue +101 -0
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/InfiniteLoader.vue +53 -0
  51. package/src/components/ObjectCardHeader.vue +11 -4
  52. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  53. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  54. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  55. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  56. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  57. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  58. package/src/components/OpenApiViewer/openapi.ts +150 -0
  59. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  60. package/src/components/Pagination.vue +23 -58
  61. package/src/components/RadioInput.vue +7 -2
  62. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  63. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  64. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  65. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  66. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  67. package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
  68. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  69. package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
  70. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  71. package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
  72. package/src/components/ResourceAccordion/Preview.vue +11 -11
  73. package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
  74. package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
  75. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  76. package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
  77. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
  78. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
  79. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
  80. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  81. package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
  82. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
  83. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  84. package/src/components/ResourceListItem.vue +161 -0
  85. package/src/components/ReuseCard.vue +14 -6
  86. package/src/components/ReuseDetails.vue +2 -2
  87. package/src/components/ReuseHorizontalCard.vue +2 -2
  88. package/src/components/SchemaCard.vue +10 -1
  89. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  90. package/src/components/Search/GlobalSearch.vue +220 -117
  91. package/src/components/Search/SearchInput.vue +5 -4
  92. package/src/components/Search/Sidemenu.vue +4 -3
  93. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  94. package/src/components/TabularExplorer/TabularCell.vue +50 -0
  95. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  96. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  97. package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
  98. package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
  99. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  100. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  101. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  102. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  103. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  104. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  105. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  106. package/src/components/TabularExplorer/types.ts +81 -0
  107. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  108. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  109. package/src/composables/useActiveDescendant.ts +5 -0
  110. package/src/composables/useDatasetResources.ts +164 -0
  111. package/src/composables/useHasTabularData.ts +15 -1
  112. package/src/composables/useMetrics.ts +1 -1
  113. package/src/composables/useResizable.ts +46 -0
  114. package/src/composables/useResourceCapabilities.ts +48 -40
  115. package/src/composables/useSearchFilter.ts +120 -0
  116. package/src/composables/useStableQueryParams.ts +38 -6
  117. package/src/composables/useTabularProfile.ts +70 -0
  118. package/src/composables/useTranslation.ts +24 -9
  119. package/src/config.ts +21 -3
  120. package/src/functions/activities.ts +3 -3
  121. package/src/functions/api.ts +20 -42
  122. package/src/functions/api.types.ts +1 -0
  123. package/src/functions/charts.ts +99 -0
  124. package/src/functions/datasets.ts +0 -17
  125. package/src/functions/dates.ts +3 -2
  126. package/src/functions/helpers.ts +15 -0
  127. package/src/functions/metrics.ts +63 -60
  128. package/src/functions/paginate.ts +46 -0
  129. package/src/functions/resourceCapabilities.ts +34 -0
  130. package/src/functions/resources.ts +136 -1
  131. package/src/functions/schemas.ts +27 -0
  132. package/src/functions/tabular.ts +214 -0
  133. package/src/functions/tabularApi.ts +96 -11
  134. package/src/main.ts +117 -14
  135. package/src/types/dataservices.ts +17 -0
  136. package/src/types/organizations.ts +10 -0
  137. package/src/types/pages.ts +0 -5
  138. package/src/types/posts.ts +2 -2
  139. package/src/types/reports.ts +5 -1
  140. package/src/types/resources.ts +0 -1
  141. package/src/types/reuses.ts +12 -1
  142. package/src/types/search.ts +63 -1
  143. package/src/types/site.ts +5 -3
  144. package/src/types/ui.ts +2 -0
  145. package/src/types/users.ts +2 -1
  146. package/src/types/visualizations.ts +93 -0
  147. package/assets/swagger-themes/newspaper.css +0 -1670
  148. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  149. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  150. package/dist/Swagger.client-B4y0hXME.js +0 -4
  151. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  152. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  153. package/src/functions/pagination.ts +0 -9
  154. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  155. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  156. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -1,11 +1,13 @@
1
- import { ref, watch, type Ref } from 'vue'
1
+ import { computed, ref, watch, type Ref } from 'vue'
2
2
  import type { SearchTypeConfig } from '../types/search'
3
+ import { configKey, forEachActiveCustomFilter, type CustomFilterEntry } from './useSearchFilter'
3
4
 
4
5
  type FilterRefs = Record<string, Ref<unknown>>
5
6
 
6
7
  interface StableQueryParamsOptions {
7
8
  typeConfig: SearchTypeConfig | undefined
8
9
  allFilters: FilterRefs
10
+ customFilterRegistry: Map<string, CustomFilterEntry>
9
11
  q: Ref<string>
10
12
  sort: Ref<string | undefined>
11
13
  page: Ref<number>
@@ -17,7 +19,7 @@ interface StableQueryParamsOptions {
17
19
  * Applies hiddenFilters first, then user filters (which can override hiddenFilters).
18
20
  */
19
21
  export function useStableQueryParams(options: StableQueryParamsOptions) {
20
- const { typeConfig, allFilters, q, sort, page, pageSize } = options
22
+ const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options
21
23
  const stableParams = ref<Record<string, unknown>>({})
22
24
 
23
25
  const buildParams = () => {
@@ -50,14 +52,34 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
50
52
  }
51
53
  }
52
54
 
55
+ // 3.5. Apply custom filter values. Concatenate into an array on collision
56
+ // so a custom filter mapped onto a built-in apiParam (e.g. theme → tag)
57
+ // combines with an existing built-in value instead of overwriting it.
58
+ // Pass the current type key so filters scoped to specific types are excluded
59
+ // from background fetches for other types.
60
+ const currentTypeKey = typeConfig ? configKey(typeConfig) : undefined
61
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
62
+ const existing = params[apiParam]
63
+ if (existing === undefined) {
64
+ params[apiParam] = value
65
+ }
66
+ else {
67
+ params[apiParam] = Array.isArray(existing) ? [...existing, value] : [existing, value]
68
+ }
69
+ }, currentTypeKey)
70
+
53
71
  // 4. Always include q, sort (if valid for this type), page, page_size
54
72
  if (q.value) {
55
73
  params.q = q.value
56
74
  }
57
- if (sort.value) {
75
+ const sortToUse = sort.value ?? typeConfig?.defaultSort
76
+ if (sortToUse) {
58
77
  const validSortValues = typeConfig?.sortOptions?.map(o => o.value as string) ?? []
59
- if (validSortValues.includes(sort.value)) {
60
- params.sort = sort.value
78
+ if (validSortValues.includes(sortToUse)) {
79
+ params.sort = sortToUse
80
+ }
81
+ else if (import.meta.env.DEV && typeConfig?.defaultSort && typeConfig?.sortOptions && sortToUse === typeConfig.defaultSort) {
82
+ console.warn(`[GlobalSearch] defaultSort "${typeConfig.defaultSort}" is not in sortOptions for "${typeConfig.class}". Valid values: ${validSortValues.join(', ')}`)
61
83
  }
62
84
  }
63
85
  params.page = page.value
@@ -66,9 +88,19 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
66
88
  return params
67
89
  }
68
90
 
91
+ // Computed that reads all custom filter values, establishing reactive dependencies
92
+ // on both the Map mutations (shallowReactive) and each entry's ref.value.
93
+ const customFilterValues = computed(() => {
94
+ const snapshot: Record<string, unknown> = {}
95
+ for (const [urlParam, entry] of customFilterRegistry) {
96
+ snapshot[urlParam] = entry.ref.value
97
+ }
98
+ return snapshot
99
+ })
100
+
69
101
  // Watch all dependencies and update only if content changed
70
102
  watch(
71
- [q, sort, page, ...Object.values(allFilters)],
103
+ [q, sort, page, ...Object.values(allFilters), customFilterValues],
72
104
  () => {
73
105
  const newParams = buildParams()
74
106
  // JSON.stringify comparison is safe here because buildParams() builds the object deterministically
@@ -0,0 +1,70 @@
1
+ import { computed, inject, provide, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
2
+ import { useComponentsConfig } from '../config'
3
+ import { useFetch } from '../functions/api'
4
+ import type { AsyncDataRequestStatus } from '../functions/api.types'
5
+ import type { TabularProfileResponse } from '../components/TabularExplorer/types'
6
+
7
+ const TABULAR_PROFILE_KEY = Symbol('tabular-profile')
8
+
9
+ export type TabularProfileState = {
10
+ resourceId: Readonly<Ref<string>>
11
+ data: Readonly<Ref<TabularProfileResponse | null>>
12
+ error: Readonly<Ref<unknown | null>>
13
+ status: Readonly<Ref<AsyncDataRequestStatus>>
14
+ refresh: () => Promise<void>
15
+ }
16
+
17
+ // What is shared through provide/inject: the resourceId (to let descendants
18
+ // check they target the same resource) and the in-flight fetch promise. We
19
+ // share the promise rather than the resolved state because `provide()` must
20
+ // run synchronously during setup — see `provideTabularProfile`.
21
+ type TabularProfileShared = {
22
+ resourceId: Readonly<Ref<string>>
23
+ state: Promise<TabularProfileState>
24
+ }
25
+
26
+ async function createProfileState(resourceId: MaybeRefOrGetter<string>): Promise<TabularProfileState> {
27
+ const config = useComponentsConfig()
28
+ const ridRef = computed(() => toValue(resourceId))
29
+
30
+ // Goes through the package's useFetch, which delegates to the host's
31
+ // customUseFetch (Nuxt useFetch in cdata) so the response is shared
32
+ // between SSR and CSR via the Nuxt payload — avoiding a double fetch.
33
+ const profileUrl = computed(() =>
34
+ ridRef.value ? `${config.tabularApiUrl}/api/resources/${ridRef.value}/profile/` : null,
35
+ )
36
+
37
+ const { data, error, status, refresh } = await useFetch<TabularProfileResponse>(profileUrl, { raw: true })
38
+
39
+ return { resourceId: ridRef, data, error, status, refresh }
40
+ }
41
+
42
+ /**
43
+ * Parent: fetch the tabular profile once and share it with descendants
44
+ * (TabularExplorer, DataStructure...) via provide/inject.
45
+ *
46
+ * Not async on purpose: `provide()` only works while the active component
47
+ * instance is set, which is lost after the first `await`. So we kick off the
48
+ * fetch, `provide()` the resulting promise synchronously, then return it for
49
+ * the caller to await.
50
+ */
51
+ export function provideTabularProfile(resourceId: MaybeRefOrGetter<string>): Promise<TabularProfileState> {
52
+ const ridRef = computed(() => toValue(resourceId))
53
+ const state = createProfileState(resourceId)
54
+ provide<TabularProfileShared>(TABULAR_PROFILE_KEY, { resourceId: ridRef, state })
55
+ return state
56
+ }
57
+
58
+ /**
59
+ * Child: get the tabular profile from an ancestor that called
60
+ * `provideTabularProfile` for the same resourceId. Falls back to
61
+ * fetching on its own if no compatible ancestor is found — preserves
62
+ * standalone usage of TabularExplorer / DataStructure.
63
+ */
64
+ export async function injectTabularProfile(resourceId: MaybeRefOrGetter<string>): Promise<TabularProfileState> {
65
+ const injected = inject<TabularProfileShared | null>(TABULAR_PROFILE_KEY, null)
66
+ if (injected && injected.resourceId.value === toValue(resourceId)) {
67
+ return await injected.state
68
+ }
69
+ return await createProfileState(resourceId)
70
+ }
@@ -14,24 +14,39 @@ const translationModules = import.meta.glob<Record<string, string>>('../../../lo
14
14
  import: 'default',
15
15
  })
16
16
 
17
+ const LANGUAGE_SUBTAG_REGEX = /^[a-z]{2,3}$/
18
+
19
+ /**
20
+ * Extract the language subtag of an `accept-language` header or of
21
+ * `navigator.language`, and return it only if it is a well-formed one.
22
+ *
23
+ * Both are user-controlled, and the locale is handed over to `Intl` formatters,
24
+ * which throw a `RangeError` on anything that is not a valid language tag. The
25
+ * wildcard `*` is rejected here too: it means "any language", so it must fall
26
+ * back to the next detection step.
27
+ */
28
+ function parseLanguage(value: string | undefined | null): string | null {
29
+ if (!value) {
30
+ return null
31
+ }
32
+ const language = value.split(';')[0]!.split(',')[0]!.split('-')[0]!.toLowerCase()
33
+ return LANGUAGE_SUBTAG_REGEX.test(language) ? language : null
34
+ }
35
+
17
36
  function detectLanguage(): string {
18
37
  // Server-side (Nuxt)
19
38
  try {
20
- const header = useRequestHeader?.('accept-language')
21
- const acceptLanguage = header
22
- if (acceptLanguage) {
23
- const primaryLang = acceptLanguage.split(';')[0]!.split(',')[0]!.split('-')[0]!.toLowerCase()
24
- // Ignore wildcard * language, that should fallback to client side detection or default language
25
- if (primaryLang !== '*') return primaryLang
26
- }
39
+ const language = parseLanguage(useRequestHeader?.('accept-language'))
40
+ if (language) return language
27
41
  }
28
42
  catch {
29
43
  // useRequestHeaders not available, continue to client-side detection
30
44
  }
31
45
 
32
46
  // Client-side
33
- if (typeof window !== 'undefined' && navigator.language) {
34
- return navigator.language.split('-')[0]!.toLowerCase()
47
+ if (typeof window !== 'undefined') {
48
+ const language = parseLanguage(navigator.language)
49
+ if (language) return language
35
50
  }
36
51
 
37
52
  return 'fr'
package/src/config.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  import { inject, type Component, type InjectionKey } from 'vue'
2
2
  import type { UseFetchFunction } from './functions/api.types'
3
- import type { FetchOptions } from 'ofetch'
3
+ import type { $Fetch, FetchOptions } from 'ofetch'
4
4
 
5
5
  export type PluginConfig = {
6
6
  name: string // Name of the application (ex: data.gouv.fr)
7
7
  baseUrl: string
8
+ /** Hostnames allowed in Access-Control-Allow-Origin for resource preview CORS checks (e.g. data.gouv.fr). */
9
+ trustedDomains?: string[]
8
10
  apiBase: string
9
11
  devApiKey?: string | null
10
12
  datasetQualityGuideUrl?: string
11
13
  maxJsonPreviewCharSize?: number // Maximum size of JSON to preview in characters. JSON preview module is partly collapsed by default so we can have a preview for large files.
12
14
  maxPdfPreviewByteSize?: number // Maximum size of PDF to preview in bytes
15
+ maxImagePreviewByteSize?: number // Maximum size of image to preview in bytes
13
16
  maxXmlPreviewCharSize?: number // Maximum size of XML to preview in characters. XML preview module can NOT be collapsed by default so we should not have a preview for large files.
14
17
  pmtilesViewerBaseUrl?: string | null // Base URL of a pmtiles viewer (ex: https://pmtiles.io/#url=)
15
18
  metricsApiUrl?: string
@@ -18,10 +21,21 @@ export type PluginConfig = {
18
21
  schemasSiteUrl?: string
19
22
  schemasSiteName?: string
20
23
  tabularApiUrl?: string
24
+ chartsApiBase?: string
21
25
  tabularApiPageSize?: number
22
26
  tabularAllowRemote?: boolean
23
27
  tabularApiDataserviceId?: string
24
28
  customUseFetch?: UseFetchFunction | null
29
+ /**
30
+ * Imperative configured fetch (auth, headers, error handling): the single source of truth for
31
+ * requests. The default `useFetch` uses it as its transport, and imperative helpers (metrics CSV
32
+ * export, …) call it directly — so they never need a `?? ofetch` fallback.
33
+ * Optional for consumers: when omitted, the plugin defaults it to an `ofetch` instance built from
34
+ * the `onRequest`/`onResponse` hooks below (see the `datagouv` plugin install). A consumer can
35
+ * instead provide its own (e.g. a Bearer-authenticated `$fetch`) and skip the hooks entirely.
36
+ */
37
+ $fetch?: $Fetch | null
38
+ /** Auth/headers/error hooks. Folded into the default `$fetch` when no `$fetch` is provided. */
25
39
  onRequest?: FetchOptions['onRequest']
26
40
  onRequestError?: FetchOptions['onRequestError']
27
41
  onResponse?: FetchOptions['onResponse']
@@ -35,8 +49,12 @@ export type PluginConfig = {
35
49
 
36
50
  export const configKey = Symbol() as InjectionKey<PluginConfig>
37
51
 
38
- export function useComponentsConfig(): PluginConfig {
52
+ // After the `datagouv` plugin install, `$fetch` is always set (defaulted to an ofetch instance), so
53
+ // consumers of the config can rely on it without a fallback.
54
+ export type ResolvedPluginConfig = PluginConfig & { $fetch: $Fetch }
55
+
56
+ export function useComponentsConfig(): ResolvedPluginConfig {
39
57
  const config = inject(configKey)
40
58
  if (!config) throw new Error('Calling `useComponentsConfig` outside @datagouv/components')
41
- return config
59
+ return config as ResolvedPluginConfig
42
60
  }
@@ -11,9 +11,9 @@ export function getActivityTranslation(activity: Activity): string {
11
11
  'dataset:deleted': t('a supprimé le jeu de données'),
12
12
  'dataset:discussed': t('a discuté du jeu de données'),
13
13
  'dataset:followed': t('suit le jeu de données'),
14
- 'dataset:resource:added': t('a ajouté une ressource'),
15
- 'dataset:resource:updated': t('a mis à jour une ressource'),
16
- 'dataset:resource:deleted': t('a supprimé une ressource'),
14
+ 'dataset:resource:added': t('a ajouté la ressource'),
15
+ 'dataset:resource:updated': t('a mis à jour la ressource'),
16
+ 'dataset:resource:deleted': t('a supprimé la ressource'),
17
17
  'dataservice:created': t('a créé le service de données'),
18
18
  'dataservice:updated': t('a mis à jour le service de données'),
19
19
  'dataservice:deleted': t('a supprimé le service de données'),
@@ -1,7 +1,6 @@
1
1
  import { ref, toValue, watchEffect, onMounted, type ComputedRef, type MaybeRefOrGetter, type Ref } from 'vue'
2
2
  import { ofetch } from 'ofetch'
3
3
  import { useComponentsConfig } from '../config'
4
- import { useTranslation } from '../composables/useTranslation'
5
4
  import type { AsyncData, AsyncDataRequestStatus, UseFetchOptions } from './api.types'
6
5
 
7
6
  function deepToValue(obj: MaybeRefOrGetter<Record<string, unknown> | undefined>): Record<string, unknown> | undefined {
@@ -18,57 +17,34 @@ export async function useFetch<DataT, ErrorT = never>(
18
17
  ): Promise<AsyncData<DataT, ErrorT>> {
19
18
  const config = useComponentsConfig()
20
19
 
21
- const { locale } = useTranslation()
22
-
23
20
  if (config.customUseFetch) {
24
21
  return await config.customUseFetch(url, options)
25
22
  }
26
23
 
24
+ const isRaw = options?.raw
27
25
  const data: Ref<DataT | null> = ref(null)
28
26
  const error: Ref<ErrorT | null> = ref(null)
29
27
  const status = ref<AsyncDataRequestStatus>('idle')
30
28
 
31
- const execute = async () => {
29
+ const execute = async (signal?: AbortSignal) => {
32
30
  const urlValue = toValue(url)
33
31
  if (!urlValue) return
34
32
  const params = deepToValue(options?.params)
35
33
  const query = deepToValue(options?.query)
36
34
  status.value = 'pending'
37
35
  try {
38
- data.value = await ofetch<DataT | null>(urlValue, {
39
- baseURL: config.apiBase,
40
- params: params ?? query,
41
- onRequest(param) {
42
- if (config.onRequest) {
43
- if (Array.isArray(config.onRequest)) {
44
- config.onRequest.forEach(r => r(param))
45
- }
46
- else {
47
- config.onRequest(param)
48
- }
49
- }
50
- const { options } = param
51
- options.headers.set('Content-Type', 'application/json')
52
- options.headers.set('Accept', 'application/json')
53
- options.credentials = 'include'
54
- if (config.devApiKey) {
55
- options.headers.set('X-API-KEY', config.devApiKey)
56
- }
57
-
58
- if (locale) {
59
- if (!options.params) {
60
- options.params = {}
61
- }
62
- options.params['lang'] = locale
63
- }
64
- },
65
- onRequestError: config.onRequestError,
66
- onResponse: config.onResponse,
67
- onResponseError: config.onResponseError,
68
- })
36
+ data.value = isRaw
37
+ // Raw targets another data.gouv service (the Tabular API in TabularExplorer) via its own
38
+ // absolute URL, so it must stay bare ofetch: no datagouv apiBase, no datagouv auth attached.
39
+ ? await ofetch<DataT | null>(urlValue, { params: params ?? query, signal })
40
+ // The configured `$fetch` carries baseURL + auth + headers (see the `datagouv` plugin install
41
+ // for the default one). We only forward the URL and params here.
42
+ : await config.$fetch<DataT | null>(urlValue, { baseURL: config.apiBase, params: params ?? query, signal })
69
43
  status.value = 'success'
70
44
  }
71
45
  catch (e) {
46
+ // Ignore aborted requests — they're superseded by a newer call, not a real error.
47
+ if (signal?.aborted) return
72
48
  error.value = e as ErrorT
73
49
  status.value = 'error'
74
50
  }
@@ -77,22 +53,24 @@ export async function useFetch<DataT, ErrorT = never>(
77
53
  // When server is false, only start watching after mount (client-side only)
78
54
  if (options?.server === false) {
79
55
  onMounted(() => {
80
- watchEffect(async () => {
81
- await execute()
56
+ watchEffect((onCleanup) => {
57
+ const controller = new AbortController()
58
+ onCleanup(() => controller.abort())
59
+ void execute(controller.signal)
82
60
  })
83
61
  })
84
62
  }
85
63
  else {
86
- watchEffect(async () => {
87
- await execute()
64
+ watchEffect((onCleanup) => {
65
+ const controller = new AbortController()
66
+ onCleanup(() => controller.abort())
67
+ void execute(controller.signal)
88
68
  })
89
69
  }
90
70
 
91
71
  return {
92
72
  data,
93
- refresh: async () => {
94
- execute()
95
- },
73
+ refresh: () => execute(),
96
74
  execute,
97
75
  clear: () => {
98
76
  data.value = null
@@ -20,6 +20,7 @@ export type UseFetchOptions<DataT> = {
20
20
  transform?: (input: DataT) => DataT | Promise<DataT>
21
21
  pick?: string[]
22
22
  watch?: WatchSource[] | false
23
+ raw?: boolean
23
24
  }
24
25
 
25
26
  export type AsyncData<DataT, ErrorT> = {
@@ -0,0 +1,99 @@
1
+ import type { Chart, ChartForm, ChartForApi, Filter, ColumnDefinition } from '../types/visualizations'
2
+ import type { ColumnType, TabularProfile } from '../components/TabularExplorer/types'
3
+ import { resolveColumnType } from './tabular'
4
+
5
+ export function toChartForm(chart: Chart) {
6
+ const seriesFilter = chart.series[0]?.filters as Filter | null
7
+
8
+ return {
9
+ title: chart.title,
10
+ description: chart.description,
11
+ private: chart.private,
12
+ owned: chart.organization ? { organization: chart.organization.id, owner: null } : { owner: chart.owner.id, organization: null },
13
+ x_axis: {
14
+ column_x: chart.x_axis.column_x,
15
+ type: chart.x_axis.type,
16
+ sort_combined: chart.x_axis.sort_x_by && chart.x_axis.sort_x_direction
17
+ ? `${chart.x_axis.sort_x_by}-${chart.x_axis.sort_x_direction}`
18
+ : '',
19
+ },
20
+ y_axis: {
21
+ label: chart.y_axis.label || '',
22
+ min: chart.y_axis.min,
23
+ max: chart.y_axis.max,
24
+ unit: chart.y_axis.unit || '',
25
+ unit_position: chart.y_axis.unit_position || 'suffix',
26
+ },
27
+ series: chart.series.map(serie => ({
28
+ ...serie,
29
+ aggregate_y: serie.aggregate_y || '',
30
+ })),
31
+ extras: chart.extras,
32
+ chart_type: chart.series[0] ? chart.series[0].type : null,
33
+ filter: seriesFilter ?? null,
34
+ } satisfies ChartForm
35
+ }
36
+
37
+ export function toChartApi(chartForm: ChartForm): ChartForApi {
38
+ const xAxis = {
39
+ column_x: chartForm.x_axis.column_x,
40
+ type: chartForm.x_axis.type,
41
+ sort_x_by: chartForm.x_axis.sort_combined
42
+ ? (chartForm.x_axis.sort_combined.split('-')[0] as 'axis_x' | 'axis_y')
43
+ : null,
44
+ sort_x_direction: chartForm.x_axis.sort_combined
45
+ ? (chartForm.x_axis.sort_combined.split('-')[1] as 'asc' | 'desc')
46
+ : null,
47
+ }
48
+
49
+ return {
50
+ ...chartForm.owned,
51
+ title: chartForm.title,
52
+ description: chartForm.description,
53
+ private: chartForm.private,
54
+ x_axis: xAxis,
55
+ y_axis: {
56
+ label: chartForm.y_axis.label ?? null,
57
+ min: chartForm.y_axis.min ?? null,
58
+ max: chartForm.y_axis.max ?? null,
59
+ unit: chartForm.y_axis.unit ?? null,
60
+ unit_position: chartForm.y_axis.unit_position ?? null,
61
+ },
62
+ series: chartForm.series.map((serie, index) => ({
63
+ ...serie,
64
+ type: index === 0 && chartForm.chart_type ? chartForm.chart_type : serie.type,
65
+ aggregate_y: serie.aggregate_y || null,
66
+ filters: serie.filters || (index === 0 && chartForm.filter ? chartForm.filter : null),
67
+ })),
68
+ extras: chartForm.extras,
69
+ }
70
+ }
71
+
72
+ export function buildColumnsFromProfile(profile: { profile: TabularProfile }): Array<ColumnDefinition> {
73
+ return profile.profile.header.map((name) => {
74
+ const colInfo = profile.profile.columns[name]
75
+ const isCategorical = profile.profile.categorical.includes(name)
76
+ // The chart stack only branches on 'number' and 'date' (continuous axis,
77
+ // aggregatable series, time formatting): it has no notion of a year column,
78
+ // and on an axis a year is a number.
79
+ const resolvedType = resolveColumnType(colInfo ?? { python_type: 'unknown', format: undefined }, isCategorical)
80
+ const colType = resolvedType === 'year' ? 'number' : resolvedType
81
+ const colProfile = profile.profile.profile[name]
82
+ return {
83
+ name,
84
+ type: colType,
85
+ min: normalizeProfileNumber(colProfile?.min, colType),
86
+ max: normalizeProfileNumber(colProfile?.max, colType),
87
+ }
88
+ })
89
+ }
90
+
91
+ function normalizeProfileNumber(value: number | string | undefined, type: ColumnType): number | undefined {
92
+ if (value === undefined || value === null) return undefined
93
+ if (type === 'date' && typeof value === 'string') {
94
+ const timestamp = new Date(value).getTime()
95
+ return Number.isNaN(timestamp) ? undefined : timestamp
96
+ }
97
+ const parsed = Number(value)
98
+ return Number.isNaN(parsed) ? undefined : parsed
99
+ }
@@ -1,6 +1,4 @@
1
1
  import { useComponentsConfig } from '../config'
2
- import type { Dataset, DatasetV2 } from '../types/datasets'
3
- import type { CommunityResource, Resource } from '../types/resources'
4
2
 
5
3
  function constructUrl(baseUrl: string, path: string): string {
6
4
  const url = new URL(baseUrl)
@@ -14,18 +12,3 @@ export function getDatasetOEmbedHtml(type: string, id: string): string {
14
12
  const staticUrl = constructUrl(config.baseUrl, 'oembed.js')
15
13
  return `<div data-udata-${type}="${id}"></div><script data-udata="${config.baseUrl}" src="${staticUrl}" async defer></script>`
16
14
  }
17
-
18
- export function isCommunityResource(resource: Resource | CommunityResource): boolean {
19
- return 'organization' in resource || 'owner' in resource
20
- }
21
-
22
- export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>, resource: Resource | CommunityResource): string {
23
- return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
24
- }
25
-
26
- export function getResourceFilesize(resource: Resource): null | number {
27
- if (resource.filesize) return resource.filesize
28
- if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number
29
-
30
- return null
31
- }
@@ -77,8 +77,9 @@ export function useFormatDate() {
77
77
  dateWithoutTime.setHours(0)
78
78
  dateWithoutTime.setMinutes(0)
79
79
  dateWithoutTime.setSeconds(0)
80
- const diff = Math.abs(dateWithoutTime.getTime() - today.getTime())
81
- if (Math.round(diff / (SECONDS_IN_A_DAY * 30)) >= 1) {
80
+ // `getTime()` returns milliseconds, `SECONDS_IN_A_DAY` is in seconds
81
+ const diffInSeconds = Math.abs(dateWithoutTime.getTime() - today.getTime()) / 1000
82
+ if (diffInSeconds >= SECONDS_IN_A_DAY * 30) {
82
83
  return t('le {date}', { date: formatDate(date, options) })
83
84
  }
84
85
  return formatFromNow(date)
@@ -38,6 +38,21 @@ export const summarize = (val: number, fractionDigits = 0) => {
38
38
  return `${toFixedIfNotZero(val)}Y`
39
39
  }
40
40
 
41
+ // Guards any value that ends up in an `href`: only absolute http(s) URLs are allowed.
42
+ // Some of these URLs come from resource extras, which are freely writable by the
43
+ // resource owner, so an unguarded value could carry a `javascript:` or `data:` scheme
44
+ // and execute in the platform origin when a visitor clicks the link.
45
+ export const isSafeHttpUrl = (value: unknown): value is string => {
46
+ if (typeof value !== 'string') return false
47
+ try {
48
+ const { protocol } = new URL(value)
49
+ return protocol === 'http:' || protocol === 'https:'
50
+ }
51
+ catch {
52
+ return false
53
+ }
54
+ }
55
+
41
56
  export const escapeCsvValue = (value: string | number | null | undefined): string => {
42
57
  if (value === null || value === undefined || value === '') {
43
58
  return ''