@datagouv/components-next 1.0.2-dev.20 → 1.0.2-dev.200
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-CFTmBFoY.js} +9 -9
- package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
- package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
- package/dist/ImagePreview.client-BvDM3qjE.js +36 -0
- package/dist/JsonPreview.client-BP6gPRxf.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
- package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-D9N3664r.js} +41 -42
- package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-C6A6ZiKl.js} +835 -856
- package/dist/Pmtiles.client-B_sDZoqu.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-DGLXBys_.js +60 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
- package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
- package/dist/XmlPreview.client-DFQ1T-LN.js +34 -0
- package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +196 -150
- package/dist/components.css +2 -2
- package/dist/{index-BFQX7Ccz.js → index-B_tPYi5Z.js} +34094 -27864
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-PeylHk0o.js} +162 -164
- package/dist/{main-vqhkVhVS.js → main-CVOZp_Nx.js} +41939 -73132
- package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-CcF3Xd23.js} +1 -1
- package/package.json +47 -38
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +24 -14
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +320 -0
- package/src/components/Chart/ChartViewerWrapper.vue +182 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +24 -5
- package/src/components/DataserviceQuality.vue +82 -0
- package/src/components/DataserviceQualityInline.vue +31 -0
- package/src/components/DataserviceQualityTooltipContent.vue +79 -0
- package/src/components/DatasetCard.vue +24 -9
- package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
- package/src/components/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- package/src/components/DiscussionMessageCard.vue +14 -15
- package/src/components/Form/Listbox.vue +101 -0
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/FormattedDate.vue +81 -0
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/LoadingBlock.vue +4 -1
- 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 +25 -62
- package/src/components/PostCard.vue +13 -15
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +172 -0
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
- package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
- package/src/components/ResourceAccordion/Metadata.vue +4 -6
- package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
- package/src/components/ResourceAccordion/Preview.vue +19 -15
- package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +36 -117
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
- package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +122 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
- package/src/components/ResourceListItem.vue +168 -0
- package/src/components/ReuseCard.vue +28 -9
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +2 -2
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +229 -123
- package/src/components/Search/SearchInput.vue +5 -4
- package/src/components/Search/Sidemenu.vue +4 -3
- package/src/components/SmallChart.vue +11 -10
- package/src/components/StatBox.vue +13 -5
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +50 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
- package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
- package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
- package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
- package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
- package/src/components/TabularExplorer/TabularTable.vue +481 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +84 -0
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/components/TopicCard.vue +13 -14
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +15 -1
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +48 -40
- package/src/composables/useSearchFilter.ts +120 -0
- package/src/composables/useStableQueryParams.ts +38 -6
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +21 -3
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +20 -42
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +99 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/dates.ts +6 -7
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +63 -60
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +136 -1
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +214 -0
- package/src/functions/tabularApi.ts +96 -11
- package/src/main.ts +119 -14
- package/src/types/dataservices.ts +17 -0
- package/src/types/organizations.ts +10 -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/resources.ts +0 -1
- package/src/types/reuses.ts +12 -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 +93 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
- package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
- package/dist/Swagger.client-B4y0hXME.js +0 -4
- package/dist/XmlPreview.client-LETEfNBH.js +0 -64
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
- /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
- /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
// Type-scoped filters (typeKeys set) are cleared by GlobalSearch's
|
|
98
|
+
// watch(currentType) instead, so their URL update batches with the other
|
|
99
|
+
// type-change resets via useRouteQuery rather than racing them with the
|
|
100
|
+
// separate router.replace below. Here we only clear filters that apply to
|
|
101
|
+
// all types: their unmount is a consumer signal unrelated to a type switch,
|
|
102
|
+
// which GlobalSearch has no way to detect.
|
|
103
|
+
//
|
|
104
|
+
// We cannot use `value.value = defaultValue` here because VueUse's
|
|
105
|
+
// useRouteQuery registers its own tryOnScopeDispose cleanup that zeroes
|
|
106
|
+
// the internal `query` variable to undefined (FIFO order, it runs first).
|
|
107
|
+
// The setter then sees `query === v` and early-returns without ever
|
|
108
|
+
// calling router.replace(). Instead we read the live route.query directly
|
|
109
|
+
// (which is router state, not affected by that cleanup) and push the update.
|
|
110
|
+
if (normalizedTypeKeys === undefined && route.query[urlParam] !== undefined) {
|
|
111
|
+
const { [urlParam]: _removed, ...restQuery } = route.query
|
|
112
|
+
router.replace({
|
|
113
|
+
query: defaultValue === undefined ? restQuery : { ...restQuery, [urlParam]: String(defaultValue) },
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
context.unregister(urlParam)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return value
|
|
120
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
21
|
-
|
|
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'
|
|
34
|
-
|
|
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
|
-
|
|
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é
|
|
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,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 =
|
|
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, 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(
|
|
81
|
-
|
|
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(
|
|
87
|
-
|
|
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:
|
|
94
|
-
execute()
|
|
95
|
-
},
|
|
73
|
+
refresh: () => execute(),
|
|
96
74
|
execute,
|
|
97
75
|
clear: () => {
|
|
98
76
|
data.value = null
|
|
@@ -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) ?? false
|
|
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
|
-
}
|
package/src/functions/dates.ts
CHANGED
|
@@ -9,11 +9,9 @@ export function useFormatDate() {
|
|
|
9
9
|
if (!date) {
|
|
10
10
|
return ''
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
return new Intl.DateTimeFormat(locale, options).format(date)
|
|
12
|
+
// An explicit `dateStyle: undefined` overrides the default, which is how callers ask
|
|
13
|
+
// for `year`/`month` components — `dateStyle` and those cannot be combined.
|
|
14
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', ...options }).format(new Date(date))
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
/**
|
|
@@ -77,8 +75,9 @@ export function useFormatDate() {
|
|
|
77
75
|
dateWithoutTime.setHours(0)
|
|
78
76
|
dateWithoutTime.setMinutes(0)
|
|
79
77
|
dateWithoutTime.setSeconds(0)
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// `getTime()` returns milliseconds, `SECONDS_IN_A_DAY` is in seconds
|
|
79
|
+
const diffInSeconds = Math.abs(dateWithoutTime.getTime() - today.getTime()) / 1000
|
|
80
|
+
if (diffInSeconds >= SECONDS_IN_A_DAY * 30) {
|
|
82
81
|
return t('le {date}', { date: formatDate(date, options) })
|
|
83
82
|
}
|
|
84
83
|
return formatFromNow(date)
|