@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.
- package/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-ZFh5ta_U.js} +1 -1
- package/dist/{Datafair.client-8haHXl47.js → Datafair.client-CKB2P_X1.js} +1 -1
- package/dist/{Event--kp8kMdJ.js → Event-DSQcW7OF.js} +24 -24
- package/dist/{Image-34hvypZI.js → Image-BijNEG0p.js} +6 -6
- package/dist/JsonPreview.client-Bx11-jfT.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
- package/dist/{MapContainer.client-l6HuXTHR.js → MapContainer.client-CdZSeT_L.js} +37 -38
- package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
- package/dist/{PdfPreview.client-4OueK-2Z.js → PdfPreview.client-Bh9lP-qU.js} +822 -850
- package/dist/{Pmtiles.client-4j3VTYkz.js → Pmtiles.client-Bi46wN14.js} +1 -1
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BnC7vWGP.js +61 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-hJQIqcZm.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-Dcl7oIVu.js} +35 -35
- package/dist/{TileImage-BsXBxMtq.js → TileImage-BJeHipMX.js} +4 -4
- package/dist/{View-BR92hTWP.js → View-xp_P_OHw.js} +412 -401
- package/dist/XmlPreview.client-oFAOv828.js +34 -0
- package/dist/{common-PJfpC179.js → common-BjQlan3k.js} +36 -36
- package/dist/components-next.css +6 -6
- package/dist/components-next.js +165 -142
- package/dist/components.css +1 -1
- package/dist/{index-CVTIoZQ0.js → index-CxCuKQ81.js} +32886 -27183
- package/dist/main-CQ9ZQG7n.js +73607 -0
- package/dist/{proj-DsetBcW7.js → proj-CsNo9yH1.js} +532 -512
- package/dist/{tilecoord-Db24Px13.js → tilecoord-A0fLnBZr.js} +28 -28
- package/dist/{vue3-xml-viewer.common-CWer_T5-.js → vue3-xml-viewer.common-B9qp90K_.js} +1 -1
- package/package.json +25 -11
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +3 -2
- package/src/components/Chart/ChartViewer.vue +226 -0
- package/src/components/Chart/ChartViewerWrapper.vue +170 -0
- package/src/components/DataserviceCard.vue +3 -0
- package/src/components/DatasetCard.vue +9 -4
- package/src/components/Form/Listbox.vue +101 -0
- package/src/components/Form/SearchableSelect.vue +2 -1
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/ObjectCardHeader.vue +11 -4
- package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
- package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
- package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
- package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
- package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
- package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
- package/src/components/OpenApiViewer/openapi.ts +150 -0
- package/src/components/OrganizationNameWithCertificate.vue +3 -2
- package/src/components/Pagination.vue +8 -5
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ReadMore.vue +1 -1
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Downloads.vue +160 -0
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -3
- package/src/components/ResourceAccordion/Metadata.vue +1 -2
- package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -87
- package/src/components/ResourceAccordion/Preview.vue +11 -11
- package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +11 -110
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
- package/src/components/ResourceExplorer/ResourceExplorer.vue +14 -10
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +50 -148
- package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
- package/src/components/ReuseCard.vue +12 -4
- package/src/components/Search/GlobalSearch.vue +201 -113
- package/src/components/Search/SearchInput.vue +5 -4
- package/src/components/TabularExplorer/TabularCell.vue +51 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +973 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
- package/src/components/TabularExplorer/types.ts +83 -0
- package/src/composables/useHasTabularData.ts +13 -0
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useResourceCapabilities.ts +1 -1
- package/src/composables/useSearchFilter.ts +118 -0
- package/src/composables/useStableQueryParams.ts +38 -6
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/config.ts +20 -3
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +9 -37
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +68 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/metrics.ts +6 -4
- package/src/functions/resources.ts +56 -1
- package/src/functions/tabular.ts +60 -0
- package/src/functions/tabularApi.ts +138 -11
- package/src/main.ts +90 -9
- package/src/types/dataservices.ts +2 -0
- package/src/types/pages.ts +0 -5
- package/src/types/posts.ts +2 -2
- package/src/types/reports.ts +5 -1
- package/src/types/search.ts +63 -1
- package/src/types/site.ts +5 -3
- package/src/types/ui.ts +2 -0
- package/src/types/users.ts +2 -1
- package/src/types/visualizations.ts +89 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/JsonPreview.client-D53pj9Cw.js +0 -72
- package/dist/Swagger.client-DPBmsH9q.js +0 -4
- package/dist/XmlPreview.client-XElkoA4F.js +0 -64
- package/dist/main-BbT-LUXy.js +0 -105854
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- 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
|
-
|
|
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(
|
|
60
|
-
params.sort =
|
|
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
|
-
|
|
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é
|
|
15
|
-
'dataset:resource:updated': t('a mis à jour
|
|
16
|
-
'dataset:resource:deleted': t('a supprimé
|
|
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'),
|
package/src/functions/api.ts
CHANGED
|
@@ -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 =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
94
|
-
execute()
|
|
95
|
-
},
|
|
67
|
+
refresh: () => execute(),
|
|
96
68
|
execute,
|
|
97
69
|
clear: () => {
|
|
98
70
|
data.value = null
|
|
@@ -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
|
-
}
|
package/src/functions/metrics.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|