@@ -68,16 +68,26 @@
:label="$t('Traductions')"
to="/design/translations"
/>
+
+
-
diff --git a/pages/design/listbox.vue b/pages/design/listbox.vue
new file mode 100644
index 000000000..e648cf738
--- /dev/null
+++ b/pages/design/listbox.vue
@@ -0,0 +1,176 @@
+
+
+
+
+ {{ $t('Système de design') }}
+
+
+ {{ $t('Listbox') }}
+
+
+
+ Listbox
+
+
+
+
+
+ Basic usage
+
+
+ Simple listbox with string options.
+
+
+
+
+
+
+
+
+ With object options
+
+
+ Listbox with object options using custom display value.
+
+
+
+
+
+
+
+
+ With custom option slot
+
+
+ Listbox with custom option rendering using the #option slot.
+
+
+
+
+
+ {{ option.label }}
+ (active)
+
+
+
+
+
+
+
+ With button and option slots
+
+
+ Listbox using both #button and #option slots for full customization.
+
+
+
+
+
+
+ {{ selectedPriority?.label || 'Select priority' }}
+
+
+
+
+ {{ option.label }}
+ (active)
+
+
+
+
+
+
+
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e3dbd202e..1b224acc4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -242,6 +242,9 @@ importers:
dompurify:
specifier: ^3.2.5
version: 3.2.7
+ echarts:
+ specifier: ^6.0.0
+ version: 6.0.0
geopf-extensions-openlayers:
specifier: ^1.0.0-beta.5
version: 1.0.0-beta.6
@@ -5157,6 +5160,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+ echarts@6.0.0:
+ resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
+
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -8481,6 +8487,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+ tslib@2.3.0:
+ resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
+
tslib@2.4.0:
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
@@ -9306,6 +9315,9 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
+ zrender@6.0.0:
+ resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
+
zstddec@0.1.0:
resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==}
@@ -11105,7 +11117,7 @@ snapshots:
'@nuxt/devtools-wizard': 3.2.4
'@nuxt/kit': 4.4.2(magicast@0.5.2)
'@vue/devtools-core': 8.1.1(vue@3.5.31(typescript@5.9.2))
- '@vue/devtools-kit': 8.1.0
+ '@vue/devtools-kit': 8.1.1
birpc: 4.0.0
consola: 3.4.2
destr: 2.0.5
@@ -12591,7 +12603,7 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
- picomatch: 4.0.3
+ picomatch: 4.0.4
optionalDependencies:
rollup: 4.60.0
@@ -14185,12 +14197,12 @@ snapshots:
'@vue/language-core@3.2.6':
dependencies:
'@volar/language-core': 2.4.28
- '@vue/compiler-dom': 3.5.30
+ '@vue/compiler-dom': 3.5.31
'@vue/shared': 3.5.31
alien-signals: 3.1.2
muggle-string: 0.4.1
path-browserify: 1.0.1
- picomatch: 4.0.3
+ picomatch: 4.0.4
'@vue/reactivity@3.5.31':
dependencies:
@@ -15024,6 +15036,11 @@ snapshots:
eastasianwidth@0.2.0: {}
+ echarts@6.0.0:
+ dependencies:
+ tslib: 2.3.0
+ zrender: 6.0.0
+
ee-first@1.1.1: {}
electron-to-chromium@1.5.328: {}
@@ -19373,6 +19390,8 @@ snapshots:
tslib@1.14.1: {}
+ tslib@2.3.0: {}
+
tslib@2.4.0: {}
tslib@2.8.1: {}
@@ -20356,6 +20375,10 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
+ zrender@6.0.0:
+ dependencies:
+ tslib: 2.3.0
+
zstddec@0.1.0: {}
zwitch@2.0.4: {}
diff --git a/tests/visualizations/chart-configurator.spec.ts b/tests/visualizations/chart-configurator.spec.ts
new file mode 100644
index 000000000..15f66714c
--- /dev/null
+++ b/tests/visualizations/chart-configurator.spec.ts
@@ -0,0 +1,162 @@
+import type { Chart } from '@datagouv/components-next'
+import { test, expect } from '../base'
+
+test('dataset selector shows default dataset', async ({ page }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ const datasetSelect = page.getByPlaceholder('Recherchez un jeu de données...')
+ // SearchableSelect displays the selected value in a different way
+ const displayedValue = await datasetSelect.inputValue()
+ await expect(datasetSelect).toBeVisible()
+ expect(displayedValue).toContain('Logements et logements sociaux')
+})
+
+test('resource selector shows available resources', async ({ page }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ const resourceOptions = await page.getByLabel('Choix de la ressource').locator('option').all()
+
+ expect(resourceOptions.length).toBeGreaterThan(1)
+})
+
+test('x-axis column selector shows available columns', async ({ page }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ const columnOptions = await page.getByLabel('Choisir quoi afficher').locator('option').allTextContents()
+
+ expect(columnOptions).toContain('libellé_EPCI')
+ expect(columnOptions).toContain('nom_region')
+})
+
+test('series column y selector shows available columns', async ({ page }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ const columnYOptions = await page.getByLabel('Colonne Y').locator('option').allTextContents()
+
+ expect(columnYOptions).toContain('Nombre de logements')
+ expect(columnYOptions).toContain('libellé_EPCI')
+})
+
+test('sort select displays dynamic column names', async ({ page }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ // Check that the sort select now shows dynamic column names instead of static "Axe X - Ascendant"
+ const sortOptions = await page.getByLabel('Trier par').locator('option').allTextContents()
+
+ expect(sortOptions[0]).toBe('Aucun')
+
+ // Second option should show the dynamic column name, not "Axe X - Ascendant"
+ expect(sortOptions[1]).toContain('libellé_EPCI')
+ expect(sortOptions[1]).not.toContain('Axe X - Ascendant')
+
+ await page.getByLabel('Trier par').selectOption({ label: 'libellé_EPCI - Descendant' })
+
+ const selectedValue = await page.getByLabel('Trier par').inputValue()
+ expect(selectedValue).toBe('axis_x-desc')
+})
+
+test('sort select updates reactively when column changes', async ({ page }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ const firstOptions = await page.getByLabel('Trier par').locator('option').allTextContents()
+ expect(firstOptions[1]).toContain('libellé_EPCI')
+
+ await page.getByLabel('Choisir quoi afficher').selectOption('nom_region')
+
+ await page.waitForTimeout(500)
+
+ const updatedOptions = await page.getByLabel('Trier par').locator('option').allTextContents()
+ expect(updatedOptions[1]).toContain('nom_region')
+ expect(updatedOptions[1]).not.toContain('libellé_EPCI')
+})
+
+test('Y axis sort options show dynamic series column name too', async ({ page }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ await page.waitForTimeout(1000)
+
+ await page.getByLabel('Colonne Y').selectOption('Nombre de logements')
+
+ const sortOptions = await page.getByLabel('Trier par').locator('option').allTextContents()
+
+ // Y axis ascending option should show dynamic column name
+ expect(sortOptions[3]).toContain('Nombre de logements')
+ expect(sortOptions[3]).not.toContain('Axe Y - Ascendant')
+})
+
+test('saving chart sends correct data to API', async ({ page, baseURL }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ await page.getByLabel('Titre').fill('Test Chart')
+ await page.getByLabel('Description').fill('Test Description')
+ await page.waitForTimeout(500)
+
+ const responsePromise = page.waitForResponse(response => response.url().includes('/api/1/visualizations/') && response.request().method() === 'POST')
+ const getPromise = page.waitForResponse(response => response.url().includes('/api/1/visualizations/') && response.request().method() === 'GET')
+
+ await page.getByRole('button', { name: 'Sauvegarder le graphique' }).click()
+ const response = await responsePromise
+ const responseBody = (await response.json()) as Chart
+ await getPromise
+
+ expect(responseBody!.title).toBe('Test Chart')
+ expect(await page.getByLabel('Graphiques existants').inputValue()).toBe(responseBody!.id)
+ await page.request.delete(`${baseURL}/api/1/visualizations/${responseBody!.id}/`)
+})
+
+test('complete chart configuration flow', async ({ page, baseURL }) => {
+ await page.goto('/design/chart')
+ await page.waitForLoadState('networkidle')
+
+ await page.getByLabel('Titre').fill('Graphique complet')
+ await page.getByLabel('Description').fill('Test complet de configuration')
+ await page.waitForTimeout(500)
+
+ await page.getByLabel('Type de graphique').selectOption('line')
+
+ await page.getByLabel('Choisir quoi afficher').selectOption('code_region')
+ await page.getByLabel('Type', { exact: true }).first().selectOption('continuous')
+
+ await page.getByLabel('Colonne Y').selectOption('Parc social - Taux de logements vacants* (en %)')
+ await page.getByLabel('Agrégation').selectOption('avg')
+
+ await page.getByLabel('Label').fill('Taux (%)')
+ await page.getByLabel('Min').fill('0')
+ await page.getByLabel('Max').fill('100')
+ await page.locator('#y-axis-unit').fill('%')
+ await page.getByLabel('Position unité').selectOption('prefix')
+
+ const responsePromise = page.waitForResponse(response => response.url().includes('/api/1/visualizations/') && response.request().method() === 'POST')
+ const getPromise = page.waitForResponse(response => response.url().includes('/api/1/visualizations/') && response.request().method() === 'GET')
+
+ await page.getByRole('button', { name: 'Sauvegarder le graphique' }).click()
+ const response = await responsePromise
+ const responseBody = (await response.json()) as Chart
+ await getPromise
+
+ expect(responseBody!.title).toBe('Graphique complet')
+ expect(await page.getByLabel('Graphiques existants').inputValue()).toBe(responseBody!.id)
+
+ expect(await page.getByLabel('Titre').inputValue()).toBe('Graphique complet')
+ expect(await page.getByLabel('Description').inputValue()).toBe('Test complet de configuration')
+ expect(await page.getByLabel('Type de graphique').inputValue()).toBe('line')
+ expect(await page.getByLabel('Choisir quoi afficher').inputValue()).toBe('code_region')
+ expect(await page.getByLabel('Type', { exact: true }).first().inputValue()).toBe('continuous')
+ expect(await page.getByLabel('Colonne Y').inputValue()).toBe('Parc social - Taux de logements vacants* (en %)')
+ expect(await page.getByLabel('Agrégation').inputValue()).toBe('avg')
+ expect(await page.getByLabel('Label').inputValue()).toBe('Taux (%)')
+ expect(await page.getByLabel('Min').inputValue()).toBe('0')
+ expect(await page.getByLabel('Max').inputValue()).toBe('100')
+ expect(await page.getByLabel('Unité', { exact: true }).inputValue()).toBe('%')
+ expect(await page.getByLabel('Position unité').inputValue()).toBe('prefix')
+
+ await page.request.delete(`${baseURL}/api/1/visualizations/${responseBody!.id}/`)
+})