@datagouv/components-next 1.0.2-dev.11 → 1.0.2-dev.111

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 (104) hide show
  1. package/assets/main.css +4 -0
  2. package/dist/{Control-DuZJdKV_.js → Control-ZFh5ta_U.js} +1 -1
  3. package/dist/{Datafair.client-8haHXl47.js → Datafair.client-CKB2P_X1.js} +1 -1
  4. package/dist/{Event--kp8kMdJ.js → Event-DSQcW7OF.js} +24 -24
  5. package/dist/{Image-34hvypZI.js → Image-BijNEG0p.js} +6 -6
  6. package/dist/JsonPreview.client-Bx11-jfT.js +40 -0
  7. package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
  8. package/dist/{MapContainer.client-l6HuXTHR.js → MapContainer.client-CdZSeT_L.js} +37 -38
  9. package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
  10. package/dist/{PdfPreview.client-4OueK-2Z.js → PdfPreview.client-Bh9lP-qU.js} +822 -850
  11. package/dist/{Pmtiles.client-4j3VTYkz.js → Pmtiles.client-Bi46wN14.js} +1 -1
  12. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BnC7vWGP.js +61 -0
  13. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-hJQIqcZm.js} +2 -2
  14. package/dist/{Tile-DbNFNPfU.js → Tile-Dcl7oIVu.js} +35 -35
  15. package/dist/{TileImage-BsXBxMtq.js → TileImage-BJeHipMX.js} +4 -4
  16. package/dist/{View-BR92hTWP.js → View-xp_P_OHw.js} +412 -401
  17. package/dist/XmlPreview.client-oFAOv828.js +34 -0
  18. package/dist/{common-PJfpC179.js → common-BjQlan3k.js} +36 -36
  19. package/dist/components-next.css +6 -6
  20. package/dist/components-next.js +165 -142
  21. package/dist/components.css +1 -1
  22. package/dist/{index-CVTIoZQ0.js → index-CxCuKQ81.js} +32886 -27183
  23. package/dist/main-CQ9ZQG7n.js +73607 -0
  24. package/dist/{proj-DsetBcW7.js → proj-CsNo9yH1.js} +532 -512
  25. package/dist/{tilecoord-Db24Px13.js → tilecoord-A0fLnBZr.js} +28 -28
  26. package/dist/{vue3-xml-viewer.common-CWer_T5-.js → vue3-xml-viewer.common-B9qp90K_.js} +1 -1
  27. package/package.json +25 -11
  28. package/src/chart.ts +5 -0
  29. package/src/components/ActivityList/ActivityList.vue +3 -2
  30. package/src/components/Chart/ChartViewer.vue +226 -0
  31. package/src/components/Chart/ChartViewerWrapper.vue +170 -0
  32. package/src/components/DataserviceCard.vue +3 -0
  33. package/src/components/DatasetCard.vue +9 -4
  34. package/src/components/Form/Listbox.vue +101 -0
  35. package/src/components/Form/SearchableSelect.vue +2 -1
  36. package/src/components/InfiniteLoader.vue +53 -0
  37. package/src/components/ObjectCardHeader.vue +11 -4
  38. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  39. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  40. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  41. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  42. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  43. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  44. package/src/components/OpenApiViewer/openapi.ts +150 -0
  45. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  46. package/src/components/Pagination.vue +8 -5
  47. package/src/components/RadioInput.vue +7 -2
  48. package/src/components/ReadMore.vue +1 -1
  49. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  50. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  51. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  52. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
  53. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  54. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
  55. package/src/components/ResourceAccordion/Preview.vue +11 -11
  56. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  57. package/src/components/ResourceAccordion/ResourceAccordion.vue +11 -110
  58. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  59. package/src/components/ResourceExplorer/ResourceExplorer.vue +14 -10
  60. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  61. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +50 -148
  62. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  63. package/src/components/ReuseCard.vue +12 -4
  64. package/src/components/Search/GlobalSearch.vue +201 -113
  65. package/src/components/Search/SearchInput.vue +5 -4
  66. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  67. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  68. package/src/components/TabularExplorer/TabularExplorer.vue +973 -0
  69. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  70. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  71. package/src/components/TabularExplorer/types.ts +83 -0
  72. package/src/composables/useHasTabularData.ts +13 -0
  73. package/src/composables/useMetrics.ts +1 -1
  74. package/src/composables/useResourceCapabilities.ts +1 -1
  75. package/src/composables/useSearchFilter.ts +118 -0
  76. package/src/composables/useStableQueryParams.ts +38 -6
  77. package/src/composables/useTabularProfile.ts +70 -0
  78. package/src/config.ts +20 -3
  79. package/src/functions/activities.ts +3 -3
  80. package/src/functions/api.ts +9 -37
  81. package/src/functions/api.types.ts +1 -0
  82. package/src/functions/charts.ts +68 -0
  83. package/src/functions/datasets.ts +0 -17
  84. package/src/functions/metrics.ts +6 -4
  85. package/src/functions/resources.ts +56 -1
  86. package/src/functions/tabular.ts +60 -0
  87. package/src/functions/tabularApi.ts +138 -11
  88. package/src/main.ts +90 -9
  89. package/src/types/dataservices.ts +2 -0
  90. package/src/types/pages.ts +0 -5
  91. package/src/types/posts.ts +2 -2
  92. package/src/types/reports.ts +5 -1
  93. package/src/types/search.ts +63 -1
  94. package/src/types/site.ts +5 -3
  95. package/src/types/ui.ts +2 -0
  96. package/src/types/users.ts +2 -1
  97. package/src/types/visualizations.ts +89 -0
  98. package/assets/swagger-themes/newspaper.css +0 -1670
  99. package/dist/JsonPreview.client-D53pj9Cw.js +0 -72
  100. package/dist/Swagger.client-DPBmsH9q.js +0 -4
  101. package/dist/XmlPreview.client-XElkoA4F.js +0 -64
  102. package/dist/main-BbT-LUXy.js +0 -105854
  103. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  104. package/src/functions/pagination.ts +0 -9
@@ -0,0 +1,118 @@
1
+ import { type InjectionKey, type Ref, inject, onMounted, onScopeDispose } from 'vue'
2
+ import { useRoute, useRouter } from 'vue-router'
3
+ import { useRouteQuery } from '@vueuse/router'
4
+ import type { SearchTypeConfig } from '../types/search'
5
+
6
+ export function configKey(c: SearchTypeConfig): string {
7
+ return c.key ?? c.class
8
+ }
9
+
10
+ export interface CustomFilterEntry {
11
+ apiParam: string
12
+ ref: Ref<string | undefined>
13
+ defaultValue: string | undefined
14
+ typeKeys?: string[] // undefined = applies to all types
15
+ }
16
+
17
+ export interface SearchFilterContext {
18
+ register(urlParam: string, entry: CustomFilterEntry): void
19
+ unregister(urlParam: string): void
20
+ }
21
+
22
+ export function isCustomFilterActive(entry: CustomFilterEntry): boolean {
23
+ const v = entry.ref.value
24
+ return v !== undefined && v !== null && v !== '' && v !== entry.defaultValue
25
+ }
26
+
27
+ export function forEachActiveCustomFilter(
28
+ registry: Map<string, CustomFilterEntry>,
29
+ apply: (apiParam: string, value: string) => void,
30
+ typeKey?: string,
31
+ ): void {
32
+ for (const entry of registry.values()) {
33
+ if (!isCustomFilterActive(entry)) continue
34
+ if (typeKey && entry.typeKeys && !entry.typeKeys.includes(typeKey)) continue
35
+ apply(entry.apiParam, String(entry.ref.value))
36
+ }
37
+ }
38
+
39
+ export const searchFilterContextKey: InjectionKey<SearchFilterContext>
40
+ = Symbol('SearchFilterContext')
41
+
42
+ export interface UseSearchFilterOptions {
43
+ /** The API parameter name to map this filter to. Defaults to the urlParam. */
44
+ apiParam?: string
45
+ /** Default value when not present in URL. Defaults to undefined. */
46
+ defaultValue?: string
47
+ /** One or more type config keys this filter applies to. Undefined means all types. */
48
+ typeKeys?: string | string[]
49
+ }
50
+
51
+ /**
52
+ * Registers a custom filter with the parent GlobalSearch component.
53
+ *
54
+ * Must be called inside a component rendered within GlobalSearch's
55
+ * `#custom-filters-top` or `#custom-filters-bottom` slot.
56
+ *
57
+ * @param urlParam - The URL query parameter name (e.g. 'theme' → `?theme=value`)
58
+ * @param options - Optional: `apiParam` to map to a different API param (e.g. 'tag'), `defaultValue`
59
+ * @returns A reactive ref bound to the URL parameter, suitable for v-model
60
+ *
61
+ * @example
62
+ * ```vue
63
+ * <script setup>
64
+ * import { useSearchFilter } from '@datagouv/components-next'
65
+ * // URL: ?theme=environment → API: ?tag=environment
66
+ * const value = useSearchFilter('theme', { apiParam: 'tag' })
67
+ * </script>
68
+ * ```
69
+ */
70
+ export function useSearchFilter(
71
+ urlParam: string,
72
+ options: UseSearchFilterOptions = {},
73
+ ): Ref<string | undefined> {
74
+ const context = inject(searchFilterContextKey)
75
+ if (!context) {
76
+ throw new Error(
77
+ `useSearchFilter("${urlParam}") must be used inside a <GlobalSearch> component.`,
78
+ )
79
+ }
80
+
81
+ const { apiParam = urlParam, defaultValue = undefined, typeKeys } = options
82
+ const normalizedTypeKeys = typeKeys
83
+ ? (Array.isArray(typeKeys) ? typeKeys : [typeKeys])
84
+ : undefined
85
+
86
+ const route = useRoute()
87
+ const router = useRouter()
88
+ const value = useRouteQuery<string | undefined>(urlParam, defaultValue)
89
+
90
+ // Register in onMounted to avoid SSR/hydration mismatch: the registry must be
91
+ // empty during SSR so server and client produce the same initial HTML.
92
+ onMounted(() => {
93
+ context.register(urlParam, { apiParam, ref: value, defaultValue, typeKeys: normalizedTypeKeys })
94
+ })
95
+
96
+ onScopeDispose(() => {
97
+ // Clear the URL param when the scope ends. This mirrors GlobalSearch's
98
+ // own `watch(currentType)` logic that drops built-in filters which don't
99
+ // apply to the new type: a custom filter's applicability is signalled
100
+ // by the consumer via `v-if`, so its unmount is the equivalent signal.
101
+ //
102
+ // We cannot use `value.value = defaultValue` here because VueUse's
103
+ // useRouteQuery registers its own tryOnScopeDispose cleanup that zeroes
104
+ // the internal `query` variable to undefined (FIFO order, it runs first).
105
+ // The setter then sees `query === v` and early-returns without ever
106
+ // calling router.replace(). Instead we read the live route.query directly
107
+ // (which is router state, not affected by that cleanup) and push the update.
108
+ if (route.query[urlParam] !== undefined) {
109
+ const { [urlParam]: _removed, ...restQuery } = route.query
110
+ router.replace({
111
+ query: defaultValue === undefined ? restQuery : { ...restQuery, [urlParam]: String(defaultValue) },
112
+ })
113
+ }
114
+ context.unregister(urlParam)
115
+ })
116
+
117
+ return value
118
+ }
@@ -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
+ }
package/src/config.ts CHANGED
@@ -1,10 +1,12 @@
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
@@ -18,10 +20,21 @@ export type PluginConfig = {
18
20
  schemasSiteUrl?: string
19
21
  schemasSiteName?: string
20
22
  tabularApiUrl?: string
23
+ chartsApiBase?: string
21
24
  tabularApiPageSize?: number
22
25
  tabularAllowRemote?: boolean
23
26
  tabularApiDataserviceId?: string
24
27
  customUseFetch?: UseFetchFunction | null
28
+ /**
29
+ * Imperative configured fetch (auth, headers, error handling): the single source of truth for
30
+ * requests. The default `useFetch` uses it as its transport, and imperative helpers (metrics CSV
31
+ * export, …) call it directly — so they never need a `?? ofetch` fallback.
32
+ * Optional for consumers: when omitted, the plugin defaults it to an `ofetch` instance built from
33
+ * the `onRequest`/`onResponse` hooks below (see the `datagouv` plugin install). A consumer can
34
+ * instead provide its own (e.g. a Bearer-authenticated `$fetch`) and skip the hooks entirely.
35
+ */
36
+ $fetch?: $Fetch | null
37
+ /** Auth/headers/error hooks. Folded into the default `$fetch` when no `$fetch` is provided. */
25
38
  onRequest?: FetchOptions['onRequest']
26
39
  onRequestError?: FetchOptions['onRequestError']
27
40
  onResponse?: FetchOptions['onResponse']
@@ -35,8 +48,12 @@ export type PluginConfig = {
35
48
 
36
49
  export const configKey = Symbol() as InjectionKey<PluginConfig>
37
50
 
38
- export function useComponentsConfig(): PluginConfig {
51
+ // After the `datagouv` plugin install, `$fetch` is always set (defaulted to an ofetch instance), so
52
+ // consumers of the config can rely on it without a fallback.
53
+ export type ResolvedPluginConfig = PluginConfig & { $fetch: $Fetch }
54
+
55
+ export function useComponentsConfig(): ResolvedPluginConfig {
39
56
  const config = inject(configKey)
40
57
  if (!config) throw new Error('Calling `useComponentsConfig` outside @datagouv/components')
41
- return config
58
+ return config as ResolvedPluginConfig
42
59
  }
@@ -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,12 +17,11 @@ 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')
@@ -35,37 +33,13 @@ export async function useFetch<DataT, ErrorT = never>(
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 })
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 })
69
43
  status.value = 'success'
70
44
  }
71
45
  catch (e) {
@@ -90,9 +64,7 @@ export async function useFetch<DataT, ErrorT = never>(
90
64
 
91
65
  return {
92
66
  data,
93
- refresh: async () => {
94
- execute()
95
- },
67
+ refresh: () => execute(),
96
68
  execute,
97
69
  clear: () => {
98
70
  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,68 @@
1
+ import type { Chart, ChartForm, ChartForApi, Filter } from '../types/visualizations'
2
+
3
+ export function toChartForm(chart: Chart) {
4
+ const seriesFilter = chart.series[0]?.filters as Filter | null
5
+
6
+ return {
7
+ title: chart.title,
8
+ description: chart.description,
9
+ private: chart.private,
10
+ owned: chart.organization ? { organization: chart.organization.id, owner: null } : { owner: chart.owner.id, organization: null },
11
+ x_axis: {
12
+ column_x: chart.x_axis.column_x,
13
+ type: chart.x_axis.type,
14
+ sort_combined: chart.x_axis.sort_x_by && chart.x_axis.sort_x_direction
15
+ ? `${chart.x_axis.sort_x_by}-${chart.x_axis.sort_x_direction}`
16
+ : '',
17
+ },
18
+ y_axis: {
19
+ label: chart.y_axis.label || '',
20
+ min: chart.y_axis.min,
21
+ max: chart.y_axis.max,
22
+ unit: chart.y_axis.unit || '',
23
+ unit_position: chart.y_axis.unit_position || 'suffix',
24
+ },
25
+ series: chart.series.map(serie => ({
26
+ ...serie,
27
+ aggregate_y: serie.aggregate_y || '',
28
+ })),
29
+ extras: chart.extras,
30
+ chart_type: chart.series[0] ? chart.series[0].type : null,
31
+ filter: seriesFilter ?? null,
32
+ } satisfies ChartForm
33
+ }
34
+
35
+ export function toChartApi(chartForm: ChartForm): ChartForApi {
36
+ const xAxis = {
37
+ column_x: chartForm.x_axis.column_x,
38
+ type: chartForm.x_axis.type,
39
+ sort_x_by: chartForm.x_axis.sort_combined
40
+ ? (chartForm.x_axis.sort_combined.split('-')[0] as 'axis_x' | 'axis_y')
41
+ : null,
42
+ sort_x_direction: chartForm.x_axis.sort_combined
43
+ ? (chartForm.x_axis.sort_combined.split('-')[1] as 'asc' | 'desc')
44
+ : null,
45
+ }
46
+
47
+ return {
48
+ ...chartForm.owned,
49
+ title: chartForm.title,
50
+ description: chartForm.description,
51
+ private: chartForm.private,
52
+ x_axis: xAxis,
53
+ y_axis: {
54
+ label: chartForm.y_axis.label ?? null,
55
+ min: chartForm.y_axis.min ?? null,
56
+ max: chartForm.y_axis.max ?? null,
57
+ unit: chartForm.y_axis.unit ?? null,
58
+ unit_position: chartForm.y_axis.unit_position ?? null,
59
+ },
60
+ series: chartForm.series.map((serie, index) => ({
61
+ ...serie,
62
+ type: index === 0 && chartForm.chart_type ? chartForm.chart_type : serie.type,
63
+ aggregate_y: serie.aggregate_y || null,
64
+ filters: serie.filters || (index === 0 && chartForm.filter ? chartForm.filter : null),
65
+ })),
66
+ extras: chartForm.extras,
67
+ }
68
+ }
@@ -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
- }
@@ -1,5 +1,5 @@
1
1
  import { escapeCsvValue } from './helpers'
2
- import { ofetch } from 'ofetch'
2
+ import { ofetch, type $Fetch } from 'ofetch'
3
3
  import type { DatasetV2 } from '../types/datasets'
4
4
  import type { PaginatedArray } from '../types/api'
5
5
 
@@ -119,14 +119,16 @@ export async function getDatasetMetrics(datasetId: string, metricsApi: string):
119
119
  }
120
120
  }
121
121
 
122
- export async function createDatasetsForOrganizationMetricsUrl(organizationId: string, metricsApi: string, apiBase: string) {
122
+ export async function createDatasetsForOrganizationMetricsUrl(organizationId: string, metricsApi: string, apiBase: string, apiFetch: $Fetch) {
123
123
  let data = 'dataset_title,dataset_id,month,monthly_visit,monthly_download_resource\n'
124
124
 
125
- // fetch datasets info from organization datasets
125
+ // fetch datasets info from organization datasets through the configured fetch, so it carries the
126
+ // consumer's auth (cookie for cdata via `$api`, Bearer for verticals) instead of a hardcoded
127
+ // `credentials: 'include'`, which breaks CORS cross-origin on the verticals.
126
128
  const datasets: Record<string, Record<string, string>> = {}
127
129
  let datasetsUrl: string | null = `/api/2/datasets/?organization=${organizationId}&page_size=200`
128
130
  while (datasetsUrl) {
129
- const body: PaginatedArray<DatasetV2> = await ofetch(datasetsUrl, { baseURL: apiBase, credentials: 'include' })
131
+ const body: PaginatedArray<DatasetV2> = await apiFetch(datasetsUrl, { baseURL: apiBase })
130
132
  datasetsUrl = body.next_page
131
133
  for (const row of body.data) {
132
134
  datasets[row.id] = { title: row.title }
@@ -1,13 +1,15 @@
1
1
  import { readonly, type Component } from 'vue'
2
2
 
3
3
  import { RiEarthLine, RiMap2Line } from '@remixicon/vue'
4
+ import { useComponentsConfig } from '../config'
4
5
  import Archive from '../components/Icons/Archive.vue'
5
6
  import Code from '../components/Icons/Code.vue'
7
+ import type { Dataset, DatasetV2 } from '../types/datasets'
6
8
  import Documentation from '../components/Icons/Documentation.vue'
7
9
  import Image from '../components/Icons/Image.vue'
8
10
  import Link from '../components/Icons/Link.vue'
9
11
  import Table from '../components/Icons/Table.vue'
10
- import type { Resource } from '../types/resources'
12
+ import type { CommunityResource, Resource } from '../types/resources'
11
13
  import { useTranslation } from '../composables/useTranslation'
12
14
 
13
15
  export function getResourceFormatIcon(format: string): Component | null {
@@ -129,3 +131,56 @@ export const detectOgcService = (resource: Resource) => {
129
131
  }
130
132
  return false
131
133
  }
134
+
135
+ export function isCommunityResource(resource: Resource | CommunityResource): boolean {
136
+ return 'organization' in resource || 'owner' in resource
137
+ }
138
+
139
+ export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>, resource: Resource | CommunityResource): string {
140
+ return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
141
+ }
142
+
143
+ export function getResourceFilesize(resource: Resource): null | number {
144
+ if (resource.filesize) return resource.filesize
145
+ if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number
146
+
147
+ return null
148
+ }
149
+
150
+ type CorsStatus = 'allowed' | 'blocked' | 'unknown'
151
+
152
+ export const getResourceCorsStatus = (resource: Resource): CorsStatus => {
153
+ const extras = resource.extras
154
+ if (!extras || !('check:cors:allow-origin' in extras)) {
155
+ return 'unknown'
156
+ }
157
+
158
+ const allowOrigin = extras['check:cors:allow-origin'] as string | undefined
159
+ const rawMethods = extras['check:cors:allow-methods'] as string | undefined
160
+
161
+ // Check if allow-origin is '*' or contains one of our trusted domains
162
+ const config = useComponentsConfig()
163
+ const trustedDomains = config.trustedDomains ?? []
164
+ const hasPublicCors = allowOrigin === '*'
165
+ const hasSpecificCors = allowOrigin
166
+ ? trustedDomains.some((domain) => {
167
+ try {
168
+ const hostname = new URL(allowOrigin).hostname
169
+ return hostname === domain || hostname.endsWith(`.${domain}`)
170
+ }
171
+ catch {
172
+ return false
173
+ }
174
+ })
175
+ : false
176
+
177
+ const isOriginAllowed = hasPublicCors || hasSpecificCors
178
+
179
+ // Ensure GET method is allowed
180
+ const allowedMethods = rawMethods
181
+ ? rawMethods.split(',').map(m => m.trim().toUpperCase())
182
+ : []
183
+ const supportsGet = allowedMethods.length === 0 || allowedMethods.includes('GET')
184
+
185
+ return isOriginAllowed && supportsGet ? 'allowed' : 'blocked'
186
+ }