@datagouv/components-next 1.0.2-dev.9 → 1.0.2-dev.91

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 (96) 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-CyZRNADr.js +30 -0
  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-C9iaPSmQ.js +40 -0
  7. package/dist/{Map-BjUnLyj8.js → Map-BUtPf5GN.js} +756 -756
  8. package/dist/MapContainer.client-BuoZ69XO.js +101 -0
  9. package/dist/{OSM-s40W6sQ2.js → OSM-D4MTdBtk.js} +2 -2
  10. package/dist/{PdfPreview.client-BVjPxlPu.js → PdfPreview.client-MI0bDghc.js} +822 -865
  11. package/dist/{Pmtiles.client-CRJ56yX2.js → Pmtiles.client-CaKEYQBc.js} +574 -579
  12. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BKqb6TMw.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-BVAeNK4n.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 +166 -148
  21. package/dist/components.css +1 -1
  22. package/dist/{index-BZsAZ7iw.js → index-BBdS8QKx.js} +32886 -27183
  23. package/dist/{main-qc4CO9Kn.js → main-Dk_66g-3.js} +91331 -75844
  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-CCOV_ohP.js → vue3-xml-viewer.common-B8dNNkOU.js} +1 -1
  27. package/package.json +18 -11
  28. package/src/components/ActivityList/ActivityList.vue +0 -2
  29. package/src/components/Chart/ChartViewer.vue +226 -0
  30. package/src/components/Chart/ChartViewerWrapper.vue +170 -0
  31. package/src/components/Form/Listbox.vue +101 -0
  32. package/src/components/Form/SearchableSelect.vue +2 -1
  33. package/src/components/InfiniteLoader.vue +53 -0
  34. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  35. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  36. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  37. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  38. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  39. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  40. package/src/components/OpenApiViewer/openapi.ts +150 -0
  41. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  42. package/src/components/Pagination.vue +8 -5
  43. package/src/components/ReadMore.vue +1 -1
  44. package/src/components/ResourceAccordion/Datafair.client.vue +4 -10
  45. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -121
  46. package/src/components/ResourceAccordion/MapContainer.client.vue +5 -14
  47. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  48. package/src/components/ResourceAccordion/PdfPreview.client.vue +24 -103
  49. package/src/components/ResourceAccordion/Pmtiles.client.vue +5 -10
  50. package/src/components/ResourceAccordion/Preview.vue +16 -21
  51. package/src/components/ResourceAccordion/PreviewLoader.vue +1 -2
  52. package/src/components/ResourceAccordion/PreviewUnavailable.vue +22 -0
  53. package/src/components/ResourceAccordion/PreviewWrapper.vue +82 -0
  54. package/src/components/ResourceAccordion/ResourceAccordion.vue +5 -7
  55. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -115
  56. package/src/components/ResourceExplorer/ResourceExplorer.vue +81 -13
  57. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  58. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -11
  59. package/src/components/Search/GlobalSearch.vue +191 -110
  60. package/src/components/Search/SearchInput.vue +5 -4
  61. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  62. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  63. package/src/components/TabularExplorer/TabularExplorer.vue +870 -0
  64. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  65. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  66. package/src/components/TabularExplorer/types.ts +83 -0
  67. package/src/composables/useHasTabularData.ts +6 -0
  68. package/src/composables/useResourceCapabilities.ts +1 -1
  69. package/src/composables/useSearchFilter.ts +118 -0
  70. package/src/composables/useStableQueryParams.ts +31 -3
  71. package/src/config.ts +3 -0
  72. package/src/functions/api.ts +34 -33
  73. package/src/functions/api.types.ts +1 -0
  74. package/src/functions/charts.ts +68 -0
  75. package/src/functions/datasets.ts +0 -17
  76. package/src/functions/resources.ts +56 -1
  77. package/src/functions/tabular.ts +60 -0
  78. package/src/functions/tabularApi.ts +138 -11
  79. package/src/main.ts +55 -7
  80. package/src/types/dataservices.ts +2 -0
  81. package/src/types/pages.ts +0 -5
  82. package/src/types/posts.ts +2 -2
  83. package/src/types/reports.ts +5 -1
  84. package/src/types/search.ts +52 -1
  85. package/src/types/site.ts +5 -3
  86. package/src/types/users.ts +2 -1
  87. package/src/types/visualizations.ts +89 -0
  88. package/assets/swagger-themes/newspaper.css +0 -1670
  89. package/dist/Datafair.client-0UYUu5yf.js +0 -35
  90. package/dist/JsonPreview.client-BrTMBWHZ.js +0 -87
  91. package/dist/MapContainer.client-CUmKyByc.js +0 -107
  92. package/dist/Swagger.client-2Yn7iF0A.js +0 -4
  93. package/dist/XmlPreview.client-DxqlVnKu.js +0 -79
  94. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  95. package/src/functions/pagination.ts +0 -9
  96. /package/assets/illustrations/{_microscope.svg → microscope.svg} +0 -0
@@ -1,11 +1,13 @@
1
- import { ref, watch, type Ref } from 'vue'
1
+ import { computed, ref, watch, type Ref } from 'vue'
2
2
  import type { SearchTypeConfig } from '../types/search'
3
+ import { configKey, forEachActiveCustomFilter, type CustomFilterEntry } from './useSearchFilter'
3
4
 
4
5
  type FilterRefs = Record<string, Ref<unknown>>
5
6
 
6
7
  interface StableQueryParamsOptions {
7
8
  typeConfig: SearchTypeConfig | undefined
8
9
  allFilters: FilterRefs
10
+ customFilterRegistry: Map<string, CustomFilterEntry>
9
11
  q: Ref<string>
10
12
  sort: Ref<string | undefined>
11
13
  page: Ref<number>
@@ -17,7 +19,7 @@ interface StableQueryParamsOptions {
17
19
  * Applies hiddenFilters first, then user filters (which can override hiddenFilters).
18
20
  */
19
21
  export function useStableQueryParams(options: StableQueryParamsOptions) {
20
- const { typeConfig, allFilters, q, sort, page, pageSize } = options
22
+ const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options
21
23
  const stableParams = ref<Record<string, unknown>>({})
22
24
 
23
25
  const buildParams = () => {
@@ -50,6 +52,22 @@ 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
@@ -66,9 +84,19 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
66
84
  return params
67
85
  }
68
86
 
87
+ // Computed that reads all custom filter values, establishing reactive dependencies
88
+ // on both the Map mutations (shallowReactive) and each entry's ref.value.
89
+ const customFilterValues = computed(() => {
90
+ const snapshot: Record<string, unknown> = {}
91
+ for (const [urlParam, entry] of customFilterRegistry) {
92
+ snapshot[urlParam] = entry.ref.value
93
+ }
94
+ return snapshot
95
+ })
96
+
69
97
  // Watch all dependencies and update only if content changed
70
98
  watch(
71
- [q, sort, page, ...Object.values(allFilters)],
99
+ [q, sort, page, ...Object.values(allFilters), customFilterValues],
72
100
  () => {
73
101
  const newParams = buildParams()
74
102
  // JSON.stringify comparison is safe here because buildParams() builds the object deterministically
package/src/config.ts CHANGED
@@ -5,6 +5,8 @@ import type { FetchOptions } from 'ofetch'
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,6 +20,7 @@ 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,6 +24,7 @@ export async function useFetch<DataT, ErrorT = never>(
24
24
  return await config.customUseFetch(url, options)
25
25
  }
26
26
 
27
+ const isRaw = options?.raw
27
28
  const data: Ref<DataT | null> = ref(null)
28
29
  const error: Ref<ErrorT | null> = ref(null)
29
30
  const status = ref<AsyncDataRequestStatus>('idle')
@@ -35,37 +36,39 @@ export async function useFetch<DataT, ErrorT = never>(
35
36
  const query = deepToValue(options?.query)
36
37
  status.value = 'pending'
37
38
  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
- }
39
+ data.value = isRaw
40
+ ? await ofetch<DataT | null>(urlValue, { params: params ?? query })
41
+ : await ofetch<DataT | null>(urlValue, {
42
+ baseURL: config.apiBase,
43
+ params: params ?? query,
44
+ onRequest(param) {
45
+ if (config.onRequest) {
46
+ if (Array.isArray(config.onRequest)) {
47
+ config.onRequest.forEach(r => r(param))
48
+ }
49
+ else {
50
+ config.onRequest(param)
51
+ }
52
+ }
53
+ const { options } = param
54
+ options.headers.set('Content-Type', 'application/json')
55
+ options.headers.set('Accept', 'application/json')
56
+ options.credentials = 'include'
57
+ if (config.devApiKey) {
58
+ options.headers.set('X-API-KEY', config.devApiKey)
59
+ }
57
60
 
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
- })
61
+ if (locale) {
62
+ if (!options.params) {
63
+ options.params = {}
64
+ }
65
+ options.params['lang'] = locale
66
+ }
67
+ },
68
+ onRequestError: config.onRequestError,
69
+ onResponse: config.onResponse,
70
+ onResponseError: config.onResponseError,
71
+ })
69
72
  status.value = 'success'
70
73
  }
71
74
  catch (e) {
@@ -90,9 +93,7 @@ export async function useFetch<DataT, ErrorT = never>(
90
93
 
91
94
  return {
92
95
  data,
93
- refresh: async () => {
94
- execute()
95
- },
96
+ refresh: () => execute(),
96
97
  execute,
97
98
  clear: () => {
98
99
  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,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
+ }
@@ -0,0 +1,60 @@
1
+ import type { Component } from 'vue'
2
+ import {
3
+ RiHashtag,
4
+ RiPriceTag3Line,
5
+ RiText,
6
+ RiCalendarLine,
7
+ RiCheckboxLine,
8
+ } from '@remixicon/vue'
9
+ import { useTranslation } from '../composables/useTranslation'
10
+ import type { ColumnFilters, ColumnType } from '../components/TabularExplorer/types'
11
+
12
+ export function hasFilterForColumn(filters: Record<string, ColumnFilters>, column: string): boolean {
13
+ const f = filters[column]
14
+ if (!f) return false
15
+ return !!(f.in?.length || f.exact != null || f.contains || f.null || f.min != null || f.max != null)
16
+ }
17
+
18
+ export function buildTypeConfig(t: (s: string) => string): Record<ColumnType, { icon: Component, label: string }> {
19
+ return {
20
+ number: { icon: RiHashtag, label: t('Nombre') },
21
+ categorical: { icon: RiPriceTag3Line, label: t('Catégoriel') },
22
+ text: { icon: RiText, label: t('Texte') },
23
+ date: { icon: RiCalendarLine, label: t('Date') },
24
+ boolean: { icon: RiCheckboxLine, label: t('Booléen') },
25
+ }
26
+ }
27
+
28
+ export function useFormatTabular() {
29
+ const { locale } = useTranslation()
30
+
31
+ function formatNumber(value: unknown): string {
32
+ const num = Number(value)
33
+ if (Number.isNaN(num)) return String(value)
34
+ return num.toLocaleString(locale)
35
+ }
36
+
37
+ function formatCellDate(value: unknown): string {
38
+ if (value == null || value === '') return '–'
39
+ const d = new Date(String(value))
40
+ if (Number.isNaN(d.getTime())) return String(value)
41
+ return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d)
42
+ }
43
+
44
+ return { formatNumber, formatCellDate }
45
+ }
46
+
47
+ const TRUTHY_VALUES = ['true', '1', 'oui', 'yes']
48
+ const FALSY_VALUES = ['false', '0', 'non', 'no']
49
+
50
+ export function isTruthy(value: unknown): boolean {
51
+ if (typeof value === 'boolean') return value
52
+ if (typeof value === 'string') return TRUTHY_VALUES.includes(value.toLowerCase())
53
+ return Boolean(value)
54
+ }
55
+
56
+ export function isFalsy(value: unknown): boolean {
57
+ if (typeof value === 'boolean') return !value
58
+ if (typeof value === 'string') return FALSY_VALUES.includes(value.toLowerCase())
59
+ return !value
60
+ }
@@ -1,20 +1,147 @@
1
1
  import { ofetch } from 'ofetch'
2
2
  import { useComponentsConfig, type PluginConfig } from '../config'
3
+ import type { GenericFilter } from '../types/visualizations'
4
+ import type { SortConfig } from '../components/TabularExplorer/types'
3
5
 
4
- export type SortConfig = {
5
- column: string
6
- type: string
7
- } | null
6
+ export type { SortConfig }
7
+
8
+ export type TabularDataResponse = {
9
+ data: Array<Record<string, unknown>>
10
+ links: {
11
+ profile: string
12
+ swagger: string
13
+ next: string
14
+ }
15
+ meta: { total: number }
16
+ }
17
+
18
+ export type TabularAggregateType = 'avg' | 'sum' | 'count' | 'min' | 'max'
19
+
20
+ export type FetchTabularDataOptions = {
21
+ resourceId: string
22
+ page?: number
23
+ pageSize?: number
24
+ columns?: Array<string> | undefined
25
+ sort?: SortConfig
26
+ groupBy?: string | undefined
27
+ aggregation?: {
28
+ column: string
29
+ type: TabularAggregateType
30
+ } | undefined
31
+ filters?: GenericFilter | undefined
32
+ }
33
+
34
+ export type TabularProfileResponse = {
35
+ profile: {
36
+ header: Array<string>
37
+ columns: Record<string, {
38
+ score: number
39
+ format: string
40
+ python_type: string
41
+ }>
42
+ formats: Record<string, Array<string>>
43
+ profile: Record<string, {
44
+ tops: Array<{ count: number, value: string }>
45
+ nb_distinct: number
46
+ nb_missing_values: number
47
+ min?: number
48
+ max?: number
49
+ std?: number
50
+ mean?: number
51
+ }>
52
+ encoding: string
53
+ separator: string
54
+ categorical: Array<string>
55
+ total_lines: number
56
+ nb_duplicates: number
57
+ columns_fields: Record<string, {
58
+ score: number
59
+ format: string
60
+ python_type: string
61
+ }>
62
+ columns_labels: Record<string, {
63
+ score: number
64
+ format: string
65
+ python_type: string
66
+ }>
67
+ header_row_idx: number
68
+ heading_columns: number
69
+ trailing_columns: number
70
+ }
71
+ deleted_at: string | null
72
+ dataset_id: string
73
+ indexes: null
74
+ }
8
75
 
9
76
  /**
10
- * Call Tabular-api to get table content
77
+ * Call Tabular-api to get table content with options object
11
78
  */
12
- export async function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig) {
13
- let url = `${config.tabularApiUrl}/api/resources/${id}/data/?page=${page}&page_size=${config.tabularApiPageSize || 15}`
14
- if (sortConfig) {
15
- url = url + `&${sortConfig.column}__sort=${sortConfig.type}`
79
+ export async function fetchTabularData(config: PluginConfig, options: FetchTabularDataOptions): Promise<TabularDataResponse> {
80
+ const page = options.page ?? 1
81
+ const pageSize = options.pageSize ?? config.tabularApiPageSize ?? 15
82
+ let url = `${config.tabularApiUrl}/api/resources/${options.resourceId}/data/?page=${page}&page_size=${pageSize}`
83
+ if (options.columns) {
84
+ url += `&columns=${options.columns.map(col => encodeURIComponent(col)).join(',')}`
85
+ }
86
+ if (options.sort) {
87
+ url += `&${encodeURIComponent(options.sort.column)}__sort=${encodeURIComponent(options.sort.direction)}`
16
88
  }
17
- return await ofetch(url)
89
+ if (options.groupBy && options.aggregation?.type) {
90
+ url += `&${encodeURIComponent(options.groupBy)}__groupby&${encodeURIComponent(options.aggregation.column)}__${encodeURIComponent(options.aggregation.type)}`
91
+ }
92
+ if (options.filters) {
93
+ const filterQuery = buildFilterQuery(options.filters)
94
+ if (filterQuery) {
95
+ url += `&${filterQuery}`
96
+ }
97
+ }
98
+ return await ofetch<TabularDataResponse>(url)
99
+ }
100
+
101
+ function buildFilterQuery(filters: GenericFilter): string {
102
+ const params: Array<string> = []
103
+ if ('filters' in filters) {
104
+ for (const filter of filters.filters) {
105
+ if ('filters' in filter) {
106
+ params.push(buildFilterQuery(filter))
107
+ }
108
+ else {
109
+ if (filter.condition === 'is_null') {
110
+ params.push(`${encodeURIComponent(filter.column)}__isnull`)
111
+ }
112
+ else if (filter.condition === 'is_not_null') {
113
+ params.push(`${encodeURIComponent(filter.column)}__isnotnull`)
114
+ }
115
+ else if (filter.value !== null && filter.value !== undefined && filter.value !== '') {
116
+ params.push(`${encodeURIComponent(filter.column)}__${encodeURIComponent(filter.condition)}=${encodeURIComponent(filter.value)}`)
117
+ }
118
+ }
119
+ }
120
+ }
121
+ else {
122
+ const filter = filters
123
+ if (filter.condition === 'is_null') {
124
+ params.push(`${encodeURIComponent(filter.column)}__isnull`)
125
+ }
126
+ else if (filter.condition === 'is_not_null') {
127
+ params.push(`${encodeURIComponent(filter.column)}__isnotnull`)
128
+ }
129
+ else if (filter.value !== null && filter.value !== undefined && filter.value !== '') {
130
+ params.push(`${encodeURIComponent(filter.column)}__${encodeURIComponent(filter.condition)}=${encodeURIComponent(filter.value)}`)
131
+ }
132
+ }
133
+ return params.join('&')
134
+ }
135
+
136
+ /**
137
+ * Call Tabular-api to get table content
138
+ */
139
+ export async function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig | null): Promise<TabularDataResponse> {
140
+ return fetchTabularData(config, {
141
+ resourceId: id,
142
+ page,
143
+ sort: sortConfig ?? undefined,
144
+ })
18
145
  }
19
146
 
20
147
  /**
@@ -22,5 +149,5 @@ export async function getData(config: PluginConfig, id: string, page: number, so
22
149
  */
23
150
  export function useGetProfile() {
24
151
  const config = useComponentsConfig()
25
- return (id: string) => ofetch(`${config.tabularApiUrl}/api/resources/${id}/profile/`)
152
+ return (id: string) => ofetch<TabularProfileResponse>(`${config.tabularApiUrl}/api/resources/${id}/profile/`)
26
153
  }