@datagouv/components-next 1.0.2-dev.18 → 1.0.2-dev.181

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 (151) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-CaxMyNif.js} +9 -9
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-CPmCyisP.js +36 -0
  8. package/dist/JsonPreview.client-CKOU-LBs.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-IGYJWP5m.js} +41 -42
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-csUebU3A.js} +835 -856
  13. package/dist/Pmtiles.client-CLq7UHBg.js +26333 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-DeoHZXW1.js +60 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-C1toAbQ0.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +193 -149
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-CaUlhl3k.js} +21842 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-ByYGrf8H.js} +185 -187
  26. package/dist/{main-vqhkVhVS.js → main-DD4ieUAo.js} +41500 -73223
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-B1KPK4L4.js} +16 -16
  32. package/package.json +47 -38
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +3 -2
  35. package/src/components/AppLink.vue +3 -0
  36. package/src/components/BannerAction.vue +6 -2
  37. package/src/components/Chart/ChartViewer.vue +320 -0
  38. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  39. package/src/components/CopyButton.vue +6 -2
  40. package/src/components/DataserviceCard.vue +9 -0
  41. package/src/components/DataserviceQuality.vue +82 -0
  42. package/src/components/DataserviceQualityInline.vue +31 -0
  43. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  44. package/src/components/DatasetCard.vue +9 -4
  45. package/src/components/DatasetQuality.vue +11 -11
  46. package/src/components/DatasetQualityInline.vue +2 -2
  47. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  48. package/src/components/Form/Listbox.vue +101 -0
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/InfiniteLoader.vue +53 -0
  51. package/src/components/ObjectCardHeader.vue +11 -4
  52. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  53. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  54. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  55. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  56. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  57. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  58. package/src/components/OpenApiViewer/openapi.ts +150 -0
  59. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  60. package/src/components/Pagination.vue +8 -5
  61. package/src/components/RadioInput.vue +7 -2
  62. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  63. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  64. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  65. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  66. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  67. package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
  68. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  69. package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
  70. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  71. package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
  72. package/src/components/ResourceAccordion/Preview.vue +11 -11
  73. package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
  74. package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
  75. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  76. package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
  77. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
  78. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
  79. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
  80. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  81. package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
  82. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
  83. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  84. package/src/components/ResourceListItem.vue +154 -0
  85. package/src/components/ReuseCard.vue +14 -6
  86. package/src/components/ReuseDetails.vue +2 -2
  87. package/src/components/ReuseHorizontalCard.vue +2 -2
  88. package/src/components/SchemaCard.vue +10 -1
  89. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  90. package/src/components/Search/GlobalSearch.vue +219 -117
  91. package/src/components/Search/SearchInput.vue +5 -4
  92. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  93. package/src/components/TabularExplorer/TabularCell.vue +50 -0
  94. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  95. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  96. package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
  97. package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
  98. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  99. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  100. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  101. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  102. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  103. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  104. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  105. package/src/components/TabularExplorer/types.ts +81 -0
  106. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  107. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  108. package/src/composables/useActiveDescendant.ts +5 -0
  109. package/src/composables/useDatasetResources.ts +164 -0
  110. package/src/composables/useHasTabularData.ts +15 -1
  111. package/src/composables/useMetrics.ts +1 -1
  112. package/src/composables/useResizable.ts +46 -0
  113. package/src/composables/useResourceCapabilities.ts +47 -21
  114. package/src/composables/useSearchFilter.ts +120 -0
  115. package/src/composables/useStableQueryParams.ts +38 -6
  116. package/src/composables/useTabularProfile.ts +70 -0
  117. package/src/config.ts +21 -3
  118. package/src/functions/activities.ts +3 -3
  119. package/src/functions/api.ts +20 -42
  120. package/src/functions/api.types.ts +1 -0
  121. package/src/functions/charts.ts +99 -0
  122. package/src/functions/datasets.ts +0 -17
  123. package/src/functions/dates.ts +3 -2
  124. package/src/functions/metrics.ts +6 -4
  125. package/src/functions/resources.ts +136 -1
  126. package/src/functions/schemas.ts +27 -0
  127. package/src/functions/tabular.ts +214 -0
  128. package/src/functions/tabularApi.ts +96 -11
  129. package/src/main.ts +117 -14
  130. package/src/types/dataservices.ts +17 -0
  131. package/src/types/organizations.ts +10 -0
  132. package/src/types/pages.ts +0 -5
  133. package/src/types/posts.ts +2 -2
  134. package/src/types/reports.ts +5 -1
  135. package/src/types/resources.ts +0 -1
  136. package/src/types/reuses.ts +12 -1
  137. package/src/types/search.ts +63 -1
  138. package/src/types/site.ts +5 -3
  139. package/src/types/ui.ts +2 -0
  140. package/src/types/users.ts +2 -1
  141. package/src/types/visualizations.ts +93 -0
  142. package/assets/swagger-themes/newspaper.css +0 -1670
  143. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  144. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  145. package/dist/Swagger.client-B4y0hXME.js +0 -4
  146. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  147. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  148. package/src/functions/pagination.ts +0 -9
  149. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  150. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  151. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -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 {
@@ -82,6 +84,25 @@ export function getResourceFormatIcon(format: string): Component | null {
82
84
  }
83
85
  }
84
86
 
87
+ // Badge colors for the resource icon, keyed on the same format categorization as
88
+ // getResourceFormatIcon so tabular/geo/code/documentation stay consistent with the icon.
89
+ export function getResourceIconColor(format?: string | null): string {
90
+ const icon = format ? getResourceFormatIcon(format) : null
91
+ switch (icon) {
92
+ case Table:
93
+ return 'bg-[#c3fad5] text-[#18753c]'
94
+ case RiMap2Line:
95
+ case RiEarthLine:
96
+ return 'bg-[#e6eefe] text-[#0063cb]'
97
+ case Code:
98
+ return 'bg-[#fce164] text-[#716043]'
99
+ case Documentation:
100
+ return 'bg-[#fee7fc] text-[#6e445a]'
101
+ default:
102
+ return 'bg-gray-100 text-gray-plain'
103
+ }
104
+ }
105
+
85
106
  export function getResourceTitleId(resource: Resource) {
86
107
  return 'resource-' + resource.id + '-title'
87
108
  }
@@ -89,6 +110,11 @@ export function getResourceTitleId(resource: Resource) {
89
110
  export const RESOURCE_TYPE = readonly(['main', 'documentation', 'update', 'api', 'code', 'other'] as const)
90
111
  export type ResourceType = typeof RESOURCE_TYPE[number]
91
112
 
113
+ // Page size used by ResourceExplorer for its per-type resource fetches.
114
+ // Exported so consumers (e.g. the dataset page's `main` prefetch) can build a
115
+ // byte-for-byte identical cache key and let Nuxt dedupe the request.
116
+ export const RESOURCE_EXPLORER_PAGE_SIZE = 10
117
+
92
118
  export const getResourceLabel = (type: ResourceType, count?: number) => {
93
119
  const { t } = useTranslation()
94
120
  switch (type) {
@@ -98,14 +124,29 @@ export const getResourceLabel = (type: ResourceType, count?: number) => {
98
124
  }
99
125
  return t('Fichiers principaux')
100
126
  case 'documentation':
127
+ if (typeof count === 'number') {
128
+ return t('Aucun fichier de documentation | 1 fichier de documentation | {n} fichiers de documentation', count)
129
+ }
101
130
  return t('Documentation')
102
131
  case 'update':
132
+ if (typeof count === 'number') {
133
+ return t('Aucune mise à jour | 1 mise à jour | {n} mises à jour', count)
134
+ }
103
135
  return t('Mise à jour')
104
136
  case 'api':
137
+ if (typeof count === 'number') {
138
+ return t('Aucune API | 1 API | {n} API', count)
139
+ }
105
140
  return t('API')
106
141
  case 'code':
142
+ if (typeof count === 'number') {
143
+ return t('Aucun fichier de code source | 1 fichier de code source | {n} fichiers de code source', count)
144
+ }
107
145
  return t('Code source')
108
146
  case 'other':
147
+ if (typeof count === 'number') {
148
+ return t('Aucun autre fichier | 1 autre fichier | {n} autres fichiers', count)
149
+ }
109
150
  return t('Autre')
110
151
  }
111
152
  }
@@ -129,3 +170,97 @@ export const detectOgcService = (resource: Resource) => {
129
170
  }
130
171
  return false
131
172
  }
173
+
174
+ export function isCommunityResource(resource: Resource | CommunityResource): boolean {
175
+ return 'organization' in resource || 'owner' in resource
176
+ }
177
+
178
+ export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>, resource: Resource | CommunityResource): string {
179
+ return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
180
+ }
181
+
182
+ export function getResourceFilesize(resource: Resource): null | number {
183
+ if (resource.filesize) return resource.filesize
184
+ if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number
185
+
186
+ return null
187
+ }
188
+
189
+ const IMAGE_PREVIEW_FORMATS = ['png', 'jpg', 'jpeg', 'jpe', 'gif', 'webp'] as const
190
+
191
+ export function isImagePreviewFormat(format: string | undefined | null): boolean {
192
+ if (!format) return false
193
+ return IMAGE_PREVIEW_FORMATS.includes(format.toLowerCase() as typeof IMAGE_PREVIEW_FORMATS[number])
194
+ }
195
+
196
+ // Parsing errors are stored as `<step>:<message>`, where `<step>` names the
197
+ // analysis step that failed (e.g. `pmtiles_export`, `geojson_export`).
198
+
199
+ /** The analysis step that failed, or null when there is no parsing error. */
200
+ export function getParsingErrorStep(resource: Resource): string | null {
201
+ const error = resource.extras['analysis:parsing:error']
202
+ if (typeof error !== 'string' || !error) return null
203
+ const [step] = error.split(':', 1)
204
+ return step ?? null
205
+ }
206
+
207
+ /** The parsing error message without its `<step>:` prefix, or null when there is none. */
208
+ export function getParsingErrorMessage(resource: Resource): string | null {
209
+ const error = resource.extras['analysis:parsing:error']
210
+ if (typeof error !== 'string' || !error) return null
211
+ const colon = error.indexOf(':')
212
+ return colon === -1 ? error : error.slice(colon + 1)
213
+ }
214
+
215
+ // Geo/format export steps run *after* the tabular table has been built, so a
216
+ // failure here (e.g. a huge geometry timing out the pmtiles export) leaves the
217
+ // tabular data fully usable and must not disable the tabular explorer.
218
+ const NON_TABULAR_PARSING_ERROR_STEPS = ['pmtiles_export', 'geojson_export']
219
+
220
+ /**
221
+ * Whether the resource's parsing error prevents reading its tabular data.
222
+ * Only errors from the tabular parsing itself are fatal; downstream geo/format
223
+ * export failures are not.
224
+ */
225
+ export function hasTabularParsingError(resource: Resource): boolean {
226
+ const step = getParsingErrorStep(resource)
227
+ return step !== null && !NON_TABULAR_PARSING_ERROR_STEPS.includes(step)
228
+ }
229
+
230
+ type CorsStatus = 'allowed' | 'blocked' | 'unknown'
231
+
232
+ export const getResourceCorsStatus = (resource: Resource): CorsStatus => {
233
+ const extras = resource.extras
234
+ if (!extras || !('check:cors:allow-origin' in extras)) {
235
+ return 'unknown'
236
+ }
237
+
238
+ const allowOrigin = extras['check:cors:allow-origin'] as string | undefined
239
+ const rawMethods = extras['check:cors:allow-methods'] as string | undefined
240
+
241
+ // Check if allow-origin is '*' or contains one of our trusted domains
242
+ const config = useComponentsConfig()
243
+ const trustedDomains = config.trustedDomains ?? []
244
+ const hasPublicCors = allowOrigin === '*'
245
+ const hasSpecificCors = allowOrigin
246
+ ? trustedDomains.some((domain) => {
247
+ try {
248
+ const hostname = new URL(allowOrigin).hostname
249
+ return hostname === domain || hostname.endsWith(`.${domain}`)
250
+ }
251
+ catch {
252
+ return false
253
+ }
254
+ })
255
+ : false
256
+
257
+ const isOriginAllowed = hasPublicCors || hasSpecificCors
258
+
259
+ // Ensure GET method is allowed
260
+ const allowedMethods = rawMethods
261
+ ? rawMethods.split(',').map(m => m.trim().toUpperCase())
262
+ : []
263
+ const supportsGet = allowedMethods.length === 0 || allowedMethods.includes('GET')
264
+
265
+ return isOriginAllowed && supportsGet ? 'allowed' : 'blocked'
266
+ }
@@ -31,6 +31,33 @@ export function getSchemaVersion(schema: RegisteredSchema | null) {
31
31
  }
32
32
  }
33
33
 
34
+ // Accents and separators must not stand between a query and a schema: people type
35
+ // "lave linge" for « Lave-linge », or « reparabilite » without its accents
36
+ function normalizeForSearch(value: string): string {
37
+ return value
38
+ .toLowerCase()
39
+ .normalize('NFD')
40
+ .replace(/\p{Diacritic}/gu, '')
41
+ .replace(/[^a-z0-9]+/g, ' ')
42
+ .trim()
43
+ }
44
+
45
+ // The name is searched along with the title and the description: it is the identifier
46
+ // people know and paste, such as `etalab/indice-durabilite-lave-linge`
47
+ const searchableText = (schema: RegisteredSchema) => normalizeForSearch(`${schema.name} ${schema.title ?? ''} ${schema.description ?? ''}`)
48
+
49
+ const queryTokens = (query: string) => normalizeForSearch(query).split(' ').filter(Boolean)
50
+
51
+ export function schemaMatchesQuery(schema: RegisteredSchema, query: string): boolean {
52
+ // A query made only of punctuation holds no word to match. Callers short-circuit the
53
+ // empty query, so matching everything here would only ever answer that case.
54
+ const tokens = queryTokens(query)
55
+ if (!tokens.length) return false
56
+
57
+ const haystack = searchableText(schema)
58
+ return tokens.every(token => haystack.includes(token))
59
+ }
60
+
34
61
  export function findSchemaInCatalog(catalog: Array<RegisteredSchema>, schema: Schema | null): RegisteredSchema | null {
35
62
  if (!schema) return null
36
63
  return catalog.find(registeredSchema => schema.name === registeredSchema.name) || null
@@ -0,0 +1,214 @@
1
+ import type { Component } from 'vue'
2
+ import {
3
+ RiHashtag,
4
+ RiPriceTag3Line,
5
+ RiText,
6
+ RiCalendarLine,
7
+ RiCalendarEventLine,
8
+ RiTimeLine,
9
+ RiCheckboxLine,
10
+ RiMapPin2Line,
11
+ RiGlobalLine,
12
+ RiFocus3Line,
13
+ RiFingerprintLine,
14
+ RiMailLine,
15
+ RiLinksLine,
16
+ RiPhoneLine,
17
+ RiUserLine,
18
+ RiMoneyEuroCircleLine,
19
+ RiPercentLine,
20
+ RiBracesLine,
21
+ RiCodeLine,
22
+ } from '@remixicon/vue'
23
+ import { useTranslation } from '../composables/useTranslation'
24
+ import type { TranslationFunction } from '../composables/useTranslation'
25
+ import type { ColumnFilters, ColumnType } from '../components/TabularExplorer/types'
26
+
27
+ export function hasFilterForColumn(filters: Record<string, ColumnFilters>, column: string): boolean {
28
+ const f = filters[column]
29
+ if (!f) return false
30
+ return !!(f.in?.length || f.exact != null || f.contains || f.null || f.min != null || f.max != null)
31
+ }
32
+
33
+ export type TypeDisplay = {
34
+ icon: Component
35
+ label: string
36
+ }
37
+
38
+ export function buildTypeConfig(t: TranslationFunction): Record<ColumnType, TypeDisplay> {
39
+ return {
40
+ number: { icon: RiHashtag, label: t('Nombre') },
41
+ categorical: { icon: RiPriceTag3Line, label: t('Catégoriel') },
42
+ text: { icon: RiText, label: t('Texte') },
43
+ date: { icon: RiCalendarLine, label: t('Date') },
44
+ boolean: { icon: RiCheckboxLine, label: t('Booléen') },
45
+ year: { icon: RiCalendarLine, label: t('Année') },
46
+ }
47
+ }
48
+
49
+ export function resolveColumnType(colInfo: { python_type: string, format?: string }, isCategorical: boolean): ColumnType {
50
+ if (colInfo.format === 'year') return 'year'
51
+ if (['int', 'float'].includes(colInfo.python_type)) return 'number'
52
+ if (['date', 'datetime'].includes(colInfo.python_type)) return 'date'
53
+ if (colInfo.python_type === 'bool') return 'boolean'
54
+ if (isCategorical) return 'categorical'
55
+ return 'text'
56
+ }
57
+
58
+ // csv-detective formats that carry no semantic meaning beyond the generic
59
+ // display type already resolved by `ColumnType` — these fall back to
60
+ // `buildTypeConfig` (Texte / Nombre / Date / Booléen) in the header.
61
+ export const GENERIC_FORMATS = new Set(['string', 'int', 'float', 'date', 'bool', 'booleen'])
62
+
63
+ // Semantic types detected by csv-detective (see ../../../csv-detective/csv_detective/formats).
64
+ // The list is intentionally exhaustive, but unknown formats are tolerated:
65
+ // `getColumnDisplay` humanizes any format missing from this map.
66
+ export function buildFormatConfig(t: TranslationFunction): Record<string, TypeDisplay> {
67
+ return {
68
+ // Geo — territories & addresses
69
+ adresse: { icon: RiMapPin2Line, label: t('Adresse') },
70
+ commune: { icon: RiMapPin2Line, label: t('Commune') },
71
+ code_commune: { icon: RiMapPin2Line, label: t('Code INSEE commune') },
72
+ code_commune_insee: { icon: RiMapPin2Line, label: t('Code INSEE commune') },
73
+ departement: { icon: RiMapPin2Line, label: t('Département') },
74
+ code_departement: { icon: RiMapPin2Line, label: t('Code département') },
75
+ region: { icon: RiMapPin2Line, label: t('Région') },
76
+ code_region: { icon: RiMapPin2Line, label: t('Code région') },
77
+ code_epci: { icon: RiMapPin2Line, label: t('Code EPCI') },
78
+ code_postal: { icon: RiMapPin2Line, label: t('Code postal') },
79
+ insee_canton: { icon: RiMapPin2Line, label: t('Canton') },
80
+ code_fantoir: { icon: RiMapPin2Line, label: t('Code FANTOIR') },
81
+ id_rnb: { icon: RiMapPin2Line, label: t('Identifiant RNB') },
82
+ // Geo — countries
83
+ pays: { icon: RiGlobalLine, label: t('Pays') },
84
+ iso_country_code_alpha2: { icon: RiGlobalLine, label: t('Code pays (ISO α-2)') },
85
+ iso_country_code_alpha3: { icon: RiGlobalLine, label: t('Code pays (ISO α-3)') },
86
+ iso_country_code_numeric: { icon: RiGlobalLine, label: t('Code pays (ISO num.)') },
87
+ // Geo — coordinates
88
+ latitude_wgs: { icon: RiFocus3Line, label: t('Latitude') },
89
+ latitude_wgs_fr_metropole: { icon: RiFocus3Line, label: t('Latitude') },
90
+ longitude_wgs: { icon: RiFocus3Line, label: t('Longitude') },
91
+ longitude_wgs_fr_metropole: { icon: RiFocus3Line, label: t('Longitude') },
92
+ latitude_l93: { icon: RiFocus3Line, label: t('Latitude (Lambert 93)') },
93
+ longitude_l93: { icon: RiFocus3Line, label: t('Longitude (Lambert 93)') },
94
+ latlon_wgs: { icon: RiFocus3Line, label: t('Latitude / longitude') },
95
+ lonlat_wgs: { icon: RiFocus3Line, label: t('Longitude / latitude') },
96
+ // Identifiers
97
+ siren: { icon: RiFingerprintLine, label: t('SIREN') },
98
+ siret: { icon: RiFingerprintLine, label: t('SIRET') },
99
+ code_rna: { icon: RiFingerprintLine, label: t('Identifiant RNA') },
100
+ code_waldec: { icon: RiFingerprintLine, label: t('Identifiant WALDEC') },
101
+ code_import: { icon: RiFingerprintLine, label: t('Code Import') },
102
+ uai: { icon: RiFingerprintLine, label: t('UAI') },
103
+ insee_ape700: { icon: RiFingerprintLine, label: t('Code APE') },
104
+ code_csp_insee: { icon: RiFingerprintLine, label: t('Code CSP') },
105
+ uuid: { icon: RiFingerprintLine, label: t('UUID') },
106
+ mongo_object_id: { icon: RiFingerprintLine, label: t('Identifiant Mongo') },
107
+ // Contact
108
+ email: { icon: RiMailLine, label: t('Email') },
109
+ url: { icon: RiLinksLine, label: t('URL') },
110
+ tel_fr: { icon: RiPhoneLine, label: t('Téléphone') },
111
+ username: { icon: RiUserLine, label: t('Nom d\'utilisateur') },
112
+ // People
113
+ sexe: { icon: RiUserLine, label: t('Sexe') },
114
+ csp_insee: { icon: RiUserLine, label: t('Catégorie socio-pro.') },
115
+ // Quantities
116
+ money: { icon: RiMoneyEuroCircleLine, label: t('Montant') },
117
+ percent: { icon: RiPercentLine, label: t('Pourcentage') },
118
+ // Temporal
119
+ year: { icon: RiCalendarLine, label: t('Année') },
120
+ date_fr: { icon: RiCalendarLine, label: t('Date') },
121
+ jour_de_la_semaine: { icon: RiCalendarEventLine, label: t('Jour de la semaine') },
122
+ mois_de_lannee: { icon: RiCalendarEventLine, label: t('Mois de l\'année') },
123
+ datetime_naive: { icon: RiTimeLine, label: t('Date et heure') },
124
+ datetime_aware: { icon: RiTimeLine, label: t('Date et heure') },
125
+ datetime_rfc822: { icon: RiTimeLine, label: t('Date et heure') },
126
+ // Structured
127
+ json: { icon: RiBracesLine, label: t('JSON') },
128
+ geojson: { icon: RiBracesLine, label: t('GeoJSON') },
129
+ binary: { icon: RiCodeLine, label: t('Binaire') },
130
+ }
131
+ }
132
+
133
+ // Tolerant fallback for a semantic format we don't explicitly know about:
134
+ // "code_truc_machin" -> "Code truc machin".
135
+ export function humanizeFormat(format: string): string {
136
+ const spaced = format.replace(/_/g, ' ').trim()
137
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1)
138
+ }
139
+
140
+ export function useFormatTabular() {
141
+ const { locale } = useTranslation()
142
+
143
+ function formatNumber(value: unknown): string {
144
+ const num = Number(value)
145
+ if (Number.isNaN(num)) return String(value)
146
+ return num.toLocaleString(locale)
147
+ }
148
+
149
+ function formatCellDate(value: unknown): string {
150
+ if (value == null || value === '') return '–'
151
+ const d = new Date(String(value))
152
+ if (Number.isNaN(d.getTime())) return String(value)
153
+ return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d)
154
+ }
155
+
156
+ return { formatNumber, formatCellDate }
157
+ }
158
+
159
+ const TRUTHY_VALUES = ['true', '1', 'oui', 'yes']
160
+ const FALSY_VALUES = ['false', '0', 'non', 'no']
161
+
162
+ // `encodeURIComponent` leaves `.`, `(` and `)` as-is, but they are the operator
163
+ // separator and the delimiters of the API's `or(...)` grammar: a search value
164
+ // containing one makes the parser reject the whole query with a 400. Wrapping
165
+ // the value in double quotes (as the API README suggests) does not work here —
166
+ // the API keeps them as part of the searched string and returns no result. It
167
+ // does percent-decode the value after parsing, so encoding them is enough.
168
+ function encodeConditionValue(value: string): string {
169
+ return encodeURIComponent(value)
170
+ .replace(/\./g, '%2E')
171
+ .replace(/\(/g, '%28')
172
+ .replace(/\)/g, '%29')
173
+ }
174
+
175
+ // A decimal literal, not `Number()`: the API compares against a number column,
176
+ // and it answers an error for the whole `or(...)` when it is handed something it
177
+ // cannot parse as one — which `Number()` happily accepts (`0x10`, `1e5`, ` `).
178
+ const NUMBER_LITERAL_RE = /^-?\d+(\.\d+)?$/
179
+
180
+ /**
181
+ * Builds the OR conditions for global search across all columns.
182
+ * Text and categorical columns get a `__contains` filter; number columns
183
+ * get a `__exact` filter only when the search value is numeric.
184
+ * Year, date and boolean columns are excluded.
185
+ */
186
+ export function buildGlobalSearchConditions(
187
+ allColumns: string[],
188
+ getColumnType: (col: string) => ColumnType,
189
+ searchValue: string,
190
+ ): string[] {
191
+ const conditions: string[] = []
192
+ for (const col of allColumns) {
193
+ const type = getColumnType(col)
194
+ if (type === 'text' || type === 'categorical') {
195
+ conditions.push(col + '__contains.' + encodeConditionValue(searchValue))
196
+ }
197
+ else if (type === 'number' && NUMBER_LITERAL_RE.test(searchValue)) {
198
+ conditions.push(col + '__exact.' + encodeConditionValue(searchValue))
199
+ }
200
+ }
201
+ return conditions
202
+ }
203
+
204
+ export function isTruthy(value: unknown): boolean {
205
+ if (typeof value === 'boolean') return value
206
+ if (typeof value === 'string') return TRUTHY_VALUES.includes(value.toLowerCase())
207
+ return Boolean(value)
208
+ }
209
+
210
+ export function isFalsy(value: unknown): boolean {
211
+ if (typeof value === 'boolean') return !value
212
+ if (typeof value === 'string') return FALSY_VALUES.includes(value.toLowerCase())
213
+ return !value
214
+ }
@@ -1,20 +1,105 @@
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, TabularProfileResponse } 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
+ }
8
33
 
9
34
  /**
10
- * Call Tabular-api to get table content
35
+ * Call Tabular-api to get table content with options object
11
36
  */
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}`
37
+ export async function fetchTabularData(config: PluginConfig, options: FetchTabularDataOptions): Promise<TabularDataResponse> {
38
+ const page = options.page ?? 1
39
+ const pageSize = options.pageSize ?? config.tabularApiPageSize ?? 15
40
+ let url = `${config.tabularApiUrl}/api/resources/${options.resourceId}/data/?page=${page}&page_size=${pageSize}`
41
+ if (options.columns) {
42
+ url += `&columns=${options.columns.map(col => encodeURIComponent(col)).join(',')}`
43
+ }
44
+ if (options.sort) {
45
+ url += `&${encodeURIComponent(options.sort.column)}__sort=${encodeURIComponent(options.sort.direction)}`
46
+ }
47
+ if (options.groupBy && options.aggregation?.type) {
48
+ url += `&${encodeURIComponent(options.groupBy)}__groupby&${encodeURIComponent(options.aggregation.column)}__${encodeURIComponent(options.aggregation.type)}`
49
+ }
50
+ if (options.filters) {
51
+ const filterQuery = buildFilterQuery(options.filters)
52
+ if (filterQuery) {
53
+ url += `&${filterQuery}`
54
+ }
16
55
  }
17
- return await ofetch(url)
56
+ return await ofetch<TabularDataResponse>(url)
57
+ }
58
+
59
+ function buildFilterQuery(filters: GenericFilter): string {
60
+ const params: Array<string> = []
61
+ if ('filters' in filters) {
62
+ for (const filter of filters.filters) {
63
+ if ('filters' in filter) {
64
+ params.push(buildFilterQuery(filter))
65
+ }
66
+ else {
67
+ if (filter.condition === 'is_null') {
68
+ params.push(`${encodeURIComponent(filter.column)}__isnull`)
69
+ }
70
+ else if (filter.condition === 'is_not_null') {
71
+ params.push(`${encodeURIComponent(filter.column)}__isnotnull`)
72
+ }
73
+ else if (filter.value !== null && filter.value !== undefined && filter.value !== '') {
74
+ params.push(`${encodeURIComponent(filter.column)}__${encodeURIComponent(filter.condition)}=${encodeURIComponent(filter.value)}`)
75
+ }
76
+ }
77
+ }
78
+ }
79
+ else {
80
+ const filter = filters
81
+ if (filter.condition === 'is_null') {
82
+ params.push(`${encodeURIComponent(filter.column)}__isnull`)
83
+ }
84
+ else if (filter.condition === 'is_not_null') {
85
+ params.push(`${encodeURIComponent(filter.column)}__isnotnull`)
86
+ }
87
+ else if (filter.value !== null && filter.value !== undefined && filter.value !== '') {
88
+ params.push(`${encodeURIComponent(filter.column)}__${encodeURIComponent(filter.condition)}=${encodeURIComponent(filter.value)}`)
89
+ }
90
+ }
91
+ return params.join('&')
92
+ }
93
+
94
+ /**
95
+ * Call Tabular-api to get table content
96
+ */
97
+ export async function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig | null): Promise<TabularDataResponse> {
98
+ return fetchTabularData(config, {
99
+ resourceId: id,
100
+ page,
101
+ sort: sortConfig ?? undefined,
102
+ })
18
103
  }
19
104
 
20
105
  /**
@@ -22,5 +107,5 @@ export async function getData(config: PluginConfig, id: string, page: number, so
22
107
  */
23
108
  export function useGetProfile() {
24
109
  const config = useComponentsConfig()
25
- return (id: string) => ofetch(`${config.tabularApiUrl}/api/resources/${id}/profile/`)
110
+ return (id: string) => ofetch<TabularProfileResponse>(`${config.tabularApiUrl}/api/resources/${id}/profile/`)
26
111
  }