@datagouv/components-next 1.0.2-dev.51 → 1.0.2-dev.52

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.
@@ -1,4 +1,4 @@
1
- import { c as Ke } from "./main-7DRSPyNj.js";
1
+ import { c as Ke } from "./main-DaWGX8hL.js";
2
2
  import We from "vue";
3
3
  function Fe(I, K) {
4
4
  for (var V = 0; V < K.length; V++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datagouv/components-next",
3
- "version": "1.0.2-dev.51",
3
+ "version": "1.0.2-dev.52",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -25,6 +25,7 @@
25
25
  "@vueuse/router": "^14.2.1",
26
26
  "chart.js": "^4.4.8",
27
27
  "dompurify": "^3.2.5",
28
+ "echarts": "^6.0.0",
28
29
  "geopf-extensions-openlayers": "^1.0.0-beta.5",
29
30
  "leaflet": "^1.9.4",
30
31
  "maplibre-gl": "^5.6.2",
@@ -48,6 +49,7 @@
48
49
  "unified": "^11.0.5",
49
50
  "unist-util-visit": "^5.0.0",
50
51
  "vue-content-loader": "^2.0.1",
52
+ "vue-echarts": "^8.0.1",
51
53
  "vue-sonner": "^2.0.9",
52
54
  "vue3-json-viewer": "^2.4.1",
53
55
  "vue3-text-clamp": "^0.1.2",
@@ -0,0 +1,147 @@
1
+ <template>
2
+ <VChart
3
+ class="w-full min-h-96"
4
+ :option="echartsOption"
5
+ autoresize
6
+ />
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ import { format, use, type ComposeOption } from 'echarts/core'
11
+ import { CanvasRenderer } from 'echarts/renderers'
12
+ import { LineChart, BarChart, type BarSeriesOption, type LineSeriesOption } from 'echarts/charts'
13
+ import { TitleComponent, TooltipComponent, LegendComponent, GridComponent, DatasetComponent } from 'echarts/components'
14
+ import VChart from 'vue-echarts'
15
+ import { computed } from 'vue'
16
+ import { summarize } from '../../functions/helpers'
17
+ import type { Chart, XAxis, YAxis, XAxisForm, ChartForApi } from '../../types/visualizations'
18
+
19
+ use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, DatasetComponent])
20
+
21
+ const props = defineProps<{
22
+ chart: Chart | ChartForApi
23
+ series: {
24
+ data: Record<string, Array<Record<string, unknown>>>
25
+ columns: Record<string, Array<string>>
26
+ }
27
+ }>()
28
+
29
+ function mapXAxisType(xAxis: XAxis | XAxisForm): 'category' | 'value' {
30
+ if (!xAxis) return 'category'
31
+ return (xAxis.type ?? 'discrete') === 'continuous' ? 'value' : 'category'
32
+ }
33
+
34
+ function buildYAxisFormatter(yAxis: YAxis): ((value: number) => string) | undefined {
35
+ return (value: number) => {
36
+ const v = summarize(value)
37
+ if (!yAxis.unit) return v
38
+ if (yAxis.unit_position === 'prefix') return `${yAxis.unit} ${v}`
39
+ return `${v} ${yAxis.unit}`
40
+ }
41
+ }
42
+
43
+ const echartsOption = computed(() => {
44
+ const seriesCount = props.chart.series.length
45
+ if (!props.chart.series || seriesCount === 0) return
46
+
47
+ const seriesData = props.chart.series.map((s) => {
48
+ const xColumn = s.column_x_name_override ?? props.chart.x_axis.column_x
49
+ const yColumn = s.aggregate_y ? `${s.column_y}__${s.aggregate_y}` : s.column_y
50
+ const resourceId = s.resource_id
51
+
52
+ if (!xColumn || !yColumn || !resourceId || !s.type || !props.series.data[resourceId] || !props.series.columns[resourceId]) {
53
+ return null
54
+ }
55
+
56
+ const sortedData = [...props.series.data[resourceId]]
57
+ let sortBy: string | null = null
58
+ let sortDirection: 'asc' | 'desc' = 'asc'
59
+ sortBy = props.chart.x_axis.sort_x_by
60
+ sortDirection = props.chart.x_axis.sort_x_direction ?? 'asc'
61
+
62
+ if (sortBy && sortDirection && props.chart.x_axis.column_x) {
63
+ const sortKey = sortBy === 'axis_x' ? xColumn : yColumn
64
+ sortedData.sort((a, b) => {
65
+ const valA = a[sortKey] as number
66
+ const valB = b[sortKey] as number
67
+ if (valA < valB) return sortDirection === 'asc' ? -1 : 1
68
+ if (valA > valB) return sortDirection === 'asc' ? 1 : -1
69
+ return 0
70
+ })
71
+ }
72
+
73
+ return {
74
+ series: {
75
+ type: s.type === 'histogram' ? 'bar' : 'line',
76
+ dimensions: s.aggregate_y ? [xColumn, yColumn] : props.series.columns[resourceId],
77
+ name: s.column_y,
78
+ encode: {
79
+ x: xColumn,
80
+ y: yColumn,
81
+ },
82
+ } as LineSeriesOption | BarSeriesOption,
83
+ data: {
84
+ source: sortedData,
85
+ dimensions: s.aggregate_y ? [xColumn, yColumn] : props.series.columns[resourceId],
86
+ },
87
+ }
88
+ }).filter(Boolean).reduce((acc: { series: Array<LineSeriesOption | BarSeriesOption>, data: Array<Record<string, unknown>> }, curr) => {
89
+ if (curr) {
90
+ acc.series.push(curr.series)
91
+ acc.data.push(curr.data)
92
+ }
93
+ return acc
94
+ }, {
95
+ series: [],
96
+ data: [],
97
+ })
98
+
99
+ return {
100
+ dataset: [...seriesData.data],
101
+ title: {
102
+ text: props.chart.title,
103
+ left: 'center',
104
+ },
105
+ tooltip: {
106
+ trigger: 'axis' as const,
107
+ formatter: (params: Array<{ value: Record<string, unknown>, axisValueLabel: string, seriesName: string }>) => {
108
+ let tooltip = ''
109
+ for (const param of params) {
110
+ const keys = Object.keys(param.value)
111
+ const col = keys.find(key => key.startsWith(param.seriesName))!
112
+ const formatter = new Intl.NumberFormat('fr-FR')
113
+ tooltip += `${format.encodeHTML(param.axisValueLabel)}: <strong>${formatter.format(Number(param.value[col]))}</strong><br>`
114
+ }
115
+ return tooltip
116
+ },
117
+ },
118
+ legend: {
119
+ bottom: 0,
120
+ },
121
+ grid: {
122
+ top: 60,
123
+ bottom: 40,
124
+ left: 20,
125
+ right: 20,
126
+ containLabel: true,
127
+ },
128
+ xAxis: {
129
+ type: mapXAxisType(props.chart.x_axis),
130
+ name: (props.chart.x_axis as XAxis).column_x,
131
+ },
132
+ yAxis: {
133
+ type: 'value' as const,
134
+ name: props.chart.y_axis.label ?? undefined,
135
+ min: props.chart.y_axis.min ?? undefined,
136
+ max: props.chart.y_axis.max ?? undefined,
137
+ axisLabel: {
138
+ formatter: buildYAxisFormatter(props.chart.y_axis),
139
+ },
140
+ },
141
+ series: seriesData.series,
142
+ } satisfies ComposeOption<
143
+ | BarSeriesOption
144
+ | LineSeriesOption
145
+ >
146
+ })
147
+ </script>
@@ -0,0 +1,224 @@
1
+ <template>
2
+ <LoadingBlock
3
+ :status="status"
4
+ :data="series"
5
+ >
6
+ <template #default="{ data }">
7
+ <ChartViewer
8
+ :chart="chart"
9
+ :series="data"
10
+ />
11
+ </template>
12
+ <template #error>
13
+ <div class="text-center py-8 text-gray-500">
14
+ Une erreur est survenue lors du chargement des données du graphique.
15
+ </div>
16
+ </template>
17
+ </LoadingBlock>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { reactive, ref, watch } from 'vue'
22
+ import ChartViewer from './ChartViewer.vue'
23
+ import LoadingBlock from '../../components/LoadingBlock.vue'
24
+ import { useComponentsConfig } from '../../config'
25
+ import { fetchTabularData, useGetProfile, type TabularDataResponse, type TabularProfileResponse } from '../../functions/tabularApi'
26
+ import type { Chart, ChartForApi } from '../../types/visualizations'
27
+
28
+ const props = defineProps<{
29
+ chart: Chart | ChartForApi
30
+ loadAllPages?: boolean
31
+ }>()
32
+
33
+ const emit = defineEmits<{
34
+ columns: [columns: Record<string, Array<string>>]
35
+ }>()
36
+
37
+ const config = useComponentsConfig()
38
+ const getProfile = useGetProfile()
39
+
40
+ // Loading and error states
41
+ const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle')
42
+ const error = ref<Error | null>(null)
43
+
44
+ // Dataset source for the chart
45
+ const series = reactive<{
46
+ data: Record<string, Array<Record<string, unknown>>>
47
+ columns: Record<string, Array<string>>
48
+ page: Record<string, number>
49
+ hasNextPage: Record<string, boolean>
50
+ }>({
51
+ data: {},
52
+ columns: {},
53
+ page: {},
54
+ hasNextPage: {},
55
+ })
56
+
57
+ async function fetchSeriesProfile() {
58
+ status.value = 'pending'
59
+ error.value = null
60
+
61
+ try {
62
+ if (props.chart.series.length === 0) {
63
+ status.value = 'success'
64
+ series.data = {}
65
+ series.columns = {}
66
+ return
67
+ }
68
+
69
+ // Fetch data for all series in parallel
70
+ const fetchPromises = props.chart.series.map(async (serie) => {
71
+ if (!serie.resource_id) return
72
+ return {
73
+ id: serie.resource_id,
74
+ profile: await getProfile(serie.resource_id),
75
+ }
76
+ }).filter(Boolean) as Array<Promise<{
77
+ id: string
78
+ profile: TabularProfileResponse
79
+ }>>
80
+
81
+ const results = (await Promise.allSettled(fetchPromises))
82
+ .filter(r => r.status === 'fulfilled')
83
+ .map(r => r.value)
84
+ series.columns = Object.fromEntries(results.map(result => [
85
+ result.id,
86
+ result.profile.profile.header,
87
+ ]))
88
+ status.value = 'success'
89
+ }
90
+ catch (err) {
91
+ error.value = err instanceof Error ? err : new Error('Failed to fetch series profile')
92
+ status.value = 'error'
93
+ console.log(err)
94
+ series.columns = {}
95
+ }
96
+ }
97
+
98
+ async function loadMorePages() {
99
+ for (const serie of props.chart.series) {
100
+ const xColumn = serie.column_x_name_override ?? props.chart.x_axis.column_x
101
+ const resourceId = serie.resource_id
102
+ if (!xColumn || !resourceId || !serie.column_y) continue
103
+
104
+ // Check if there's more data to load for this series
105
+ if (!series.hasNextPage[resourceId]) {
106
+ continue
107
+ }
108
+
109
+ // Calculate the next page to fetch
110
+ const nextPage = (series.page[resourceId] || 0) + 1
111
+
112
+ const response = await fetchTabularData(config, {
113
+ columns: serie.aggregate_y ? undefined : [xColumn, serie.column_y],
114
+ resourceId,
115
+ page: nextPage,
116
+ pageSize: 100,
117
+ groupBy: xColumn,
118
+ aggregation: serie.column_y && serie.aggregate_y
119
+ ? {
120
+ column: serie.column_y,
121
+ type: serie.aggregate_y,
122
+ }
123
+ : undefined,
124
+ filters: serie.filters ?? undefined,
125
+ })
126
+
127
+ // Update the page tracker
128
+ series.page[resourceId] = nextPage
129
+
130
+ if (!series.data[resourceId]) {
131
+ series.data[resourceId] = []
132
+ }
133
+ series.data[resourceId] = [...series.data[resourceId], ...response.data]
134
+
135
+ // Update hasNextPage based on the API response
136
+ series.hasNextPage[resourceId] = !!response.links.next
137
+ }
138
+ }
139
+
140
+ async function fetchSeriesData() {
141
+ status.value = 'pending'
142
+ error.value = null
143
+
144
+ try {
145
+ if (props.chart.series.length === 0 || !props.chart.x_axis.column_x) {
146
+ status.value = 'success'
147
+ series.data = {}
148
+ return
149
+ }
150
+
151
+ const fetchPromises = props.chart.series.map(async (serie) => {
152
+ const xColumn = serie.column_x_name_override ?? props.chart.x_axis.column_x
153
+
154
+ if (!xColumn || !serie.resource_id || !serie.column_y) return
155
+ return {
156
+ id: serie.resource_id,
157
+ data: await fetchTabularData(config, {
158
+ columns: serie.aggregate_y ? undefined : [xColumn, serie.column_y],
159
+ resourceId: serie.resource_id,
160
+ page: 1,
161
+ pageSize: 100,
162
+ groupBy: xColumn,
163
+ aggregation: serie.column_y && serie.aggregate_y
164
+ ? {
165
+ column: serie.column_y,
166
+ type: serie.aggregate_y,
167
+ }
168
+ : undefined,
169
+ filters: serie.filters ?? undefined,
170
+ }),
171
+ }
172
+ }).filter(Boolean) as Array<Promise<{
173
+ id: string
174
+ data: TabularDataResponse
175
+ }>>
176
+
177
+ const results = (await Promise.allSettled(fetchPromises))
178
+ .filter(r => r.status === 'fulfilled')
179
+ .map(r => r.value)
180
+ // Transform data into echarts format
181
+ series.data = Object.fromEntries(results.map(result => [
182
+ result.id,
183
+ result.data.data,
184
+ ]))
185
+
186
+ // Reset page tracking for each series to 0 (page 1 has been loaded)
187
+ // Also initialize hasNextPage based on the response
188
+ for (const result of results) {
189
+ const serie = props.chart.series.find(s => s.resource_id === result.id)
190
+ if (serie) {
191
+ series.page[result.id] = 0
192
+ series.hasNextPage[result.id] = !!result.data.links.next
193
+ }
194
+ }
195
+
196
+ // If loadAllPages is true, fetch the next page (loadMorePages can be called again for more)
197
+ // This allows progressive loading - first page loads quickly, then more can be loaded on demand
198
+ if (props.loadAllPages) {
199
+ await loadMorePages()
200
+ }
201
+
202
+ status.value = 'success'
203
+ }
204
+ catch (err) {
205
+ error.value = err instanceof Error ? err : new Error('Failed to fetch series data')
206
+ status.value = 'error'
207
+ series.data = {}
208
+ }
209
+ }
210
+
211
+ // Watch for changes in the chart or its series
212
+ watch(() => props.chart.series, async () => {
213
+ await fetchSeriesProfile()
214
+ }, { immediate: true, deep: true })
215
+
216
+ // Watch for changes in the chart or its series
217
+ watch([() => props.chart.series, () => props.chart.x_axis.column_x], async () => {
218
+ await fetchSeriesData()
219
+ }, { immediate: true, deep: true })
220
+
221
+ watch(() => series.columns, () => {
222
+ emit('columns', series.columns)
223
+ })
224
+ </script>
@@ -0,0 +1,101 @@
1
+ <template>
2
+ <Listbox v-model="model">
3
+ <div class="relative min-w-0">
4
+ <div
5
+ ref="floatingReference"
6
+ class="relative w-full cursor-default overflow-hidden bg-white text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-300 sm:text-sm"
7
+ >
8
+ <ListboxButton class="input shadow-input text-sm flex items-center gap-2">
9
+ <slot name="button">
10
+ <div class="w-full flex items-center justify-between gap-2">
11
+ <div
12
+ class="truncate"
13
+ :class="{ 'text-new-disabled-text': isDisabled(model) }"
14
+ >
15
+ {{ displayValue(model) }}
16
+ </div>
17
+ <RiArrowDownSLine class="flex-none size-4 justify-self-end" />
18
+ </div>
19
+ </slot>
20
+ </ListboxButton>
21
+ </div>
22
+
23
+ <ListboxOptions
24
+ ref="popover"
25
+ :style="floatingStyles"
26
+ class="z-10 mt-1 absolute max-h-60 min-w-80 w-full overflow-auto rounded-md bg-white text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm pl-0"
27
+ >
28
+ <ListboxOption
29
+ v-for="option in options"
30
+ :key="getOptionId(toValue(option))"
31
+ v-slot="{ active, selected }"
32
+ as="template"
33
+ :value="option"
34
+ >
35
+ <li
36
+ class="relative cursor-default select-none py-2 pr-4 list-none flex items-center gap-2 text-gray-900"
37
+ :class="{
38
+ 'bg-gray-lower': active && !isDisabled(toValue(option)),
39
+ 'text-new-disabled-text': isDisabled(toValue(option)),
40
+ 'pl-2': selected,
41
+ 'pl-6': !selected,
42
+ }"
43
+ >
44
+ <div class="flex items-center justify-center aspect-square">
45
+ <RiCheckLine
46
+ v-if="selected"
47
+ class="size-4"
48
+ :class="isDisabled(toValue(option)) ?' text-new-disabled-text' : 'text-new-primary'"
49
+ />
50
+ </div>
51
+ <slot
52
+ name="option"
53
+ v-bind="{ option, active }"
54
+ >
55
+ {{ displayValue(option) }}
56
+ </slot>
57
+ </li>
58
+ </ListboxOption>
59
+ </ListboxOptions>
60
+ </div>
61
+ </Listbox>
62
+ </template>
63
+
64
+ <script setup lang="ts" generic="T extends string | number | object">
65
+ import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/vue'
66
+ import { useFloating, autoUpdate, autoPlacement } from '@floating-ui/vue'
67
+ import { toValue, useTemplateRef } from 'vue'
68
+ import { RiArrowDownSLine, RiCheckLine } from '@remixicon/vue'
69
+
70
+ withDefaults(defineProps<{
71
+ options?: Array<T>
72
+ getOptionId?: (option: T) => string | number
73
+ displayValue: (option: T | null) => string
74
+ isDisabled?: (option: T | null) => boolean
75
+ }>(), {
76
+ getOptionId: (option: T): string | number => {
77
+ if (typeof option === 'string') return option
78
+ if (typeof option === 'number') return option
79
+ if (typeof option === 'object' && 'id' in option) return option.id as string
80
+
81
+ throw new Error('Please set getOptionId()')
82
+ },
83
+ isDisabled: (option: T | null): boolean => {
84
+ if (option && typeof option === 'object' && 'disabled' in option) return option.disabled as boolean
85
+
86
+ return false
87
+ },
88
+ })
89
+
90
+ const model = defineModel<T | null>({ required: true })
91
+
92
+ const referenceRef = useTemplateRef('floatingReference')
93
+ const floatingRef = useTemplateRef<InstanceType<typeof ListboxOptions>>('popover')
94
+ const { floatingStyles } = useFloating(referenceRef, floatingRef, {
95
+ middleware: [autoPlacement({
96
+ allowedPlacements: ['bottom-start', 'bottom', 'bottom-end'],
97
+ crossAxis: true,
98
+ })],
99
+ whileElementsMounted: autoUpdate,
100
+ })
101
+ </script>
@@ -142,7 +142,7 @@ async function getTableInfos(page: number, sortConfig?: SortConfig) {
142
142
  try {
143
143
  // Check that this function return wanted data
144
144
  const response = await getData(config, props.resource.id, page, sortConfig)
145
- if ('data' in response && response.data && response.data.length > 0) {
145
+ if ('data' in response && response.data && 0 in response.data) {
146
146
  // Update existing rows
147
147
  rows.value = response.data
148
148
  columns.value = Object.keys(response.data[0]).filter(item => item !== '__id')
@@ -1,10 +1,16 @@
1
1
  import { useComponentsConfig } from '../config'
2
2
  import type { Resource } from '../types/resources'
3
3
 
4
+ /**
5
+ * Composable to determine if a resource has tabular data.
6
+ * This is used to show the "Données" tab for tabular files AND the "Structure des données" tab (for tabular data structure).
7
+ *
8
+ * @returns A function to check if a resource has tabular data.
9
+ */
4
10
  export const useHasTabularData = () => {
5
11
  const config = useComponentsConfig()
6
12
 
7
- return (resource: Resource) => {
13
+ const hasTabularData = (resource: Resource) => {
8
14
  return (
9
15
  config.tabularApiUrl
10
16
  && resource.extras['analysis:parsing:parsing_table']
@@ -12,4 +18,6 @@ export const useHasTabularData = () => {
12
18
  && (config.tabularAllowRemote || resource.filetype === 'file')
13
19
  )
14
20
  }
21
+
22
+ return hasTabularData
15
23
  }
@@ -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
+ }