From 6b33aa7dd6b463847675f218d6c8e6a8d2c11bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bult=C3=A9?= Date: Wed, 11 Dec 2024 15:12:00 +0100 Subject: [PATCH] feat(indicators): add list view with filters (#605) * feat(indicators): add list * working sidebar filter * pagination * sort * fix lint * wip colors * indicator cards * add enjeu, factorize props * fix total count * extract useAccessibilityProperties * polishing up * a11y utils to custom * better list * fix spatialCoverage (for bouquets too) * dynamic props for child components * improve comment * review: SelectComponent * fix: select-enjeu id * add hero * fix label for * review: SelectComponent with label * use search component, scroll to list --- configs/ecospheres/config.yaml | 28 ++++ src/components/bouquets/BouquetSearch.vue | 2 +- .../forms/SelectSpatialCoverage.vue | 4 - .../ecospheres/components/SelectComponent.vue | 47 ++++++ .../components/indicators/IndicatorCard.vue | 43 +++++ .../components/indicators/IndicatorList.vue | 154 ++++++++++++++++++ .../components/indicators/IndicatorSearch.vue | 90 ++++++++++ src/custom/ecospheres/model/config.ts | 17 ++ src/custom/ecospheres/model/indicator.ts | 18 ++ src/custom/ecospheres/routes.ts | 28 ++++ src/custom/ecospheres/store/IndicatorStore.ts | 53 ++++++ src/custom/ecospheres/utils/a11y.ts | 33 ++++ src/custom/ecospheres/utils/config.ts | 22 +++ src/custom/ecospheres/utils/indicator.ts | 65 ++++++++ .../views/indicators/IndicatorsListView.vue | 141 ++++++++++++++++ src/services/api/DatagouvfrAPI.ts | 5 + 16 files changed, 745 insertions(+), 5 deletions(-) create mode 100644 src/custom/ecospheres/components/SelectComponent.vue create mode 100644 src/custom/ecospheres/components/indicators/IndicatorCard.vue create mode 100644 src/custom/ecospheres/components/indicators/IndicatorList.vue create mode 100644 src/custom/ecospheres/components/indicators/IndicatorSearch.vue create mode 100644 src/custom/ecospheres/model/config.ts create mode 100644 src/custom/ecospheres/model/indicator.ts create mode 100644 src/custom/ecospheres/store/IndicatorStore.ts create mode 100644 src/custom/ecospheres/utils/a11y.ts create mode 100644 src/custom/ecospheres/utils/config.ts create mode 100644 src/custom/ecospheres/utils/indicator.ts create mode 100644 src/custom/ecospheres/views/indicators/IndicatorsListView.vue diff --git a/configs/ecospheres/config.yaml b/configs/ecospheres/config.yaml index fd134f684..82d46c63c 100644 --- a/configs/ecospheres/config.yaml +++ b/configs/ecospheres/config.yaml @@ -97,6 +97,9 @@ website: to: /datasets - text: Bouquets to: /bouquets + # TODO: enable when ready + # - text: Indicateurs + # to: /indicators - text: Organisations to: /organizations - text: A propos @@ -234,3 +237,28 @@ themes: - name: La sobriété des usages et des ressources organizations: https://raw.githubusercontent.com/ecolabdata/ecospheres-universe/refs/heads/main/dist/organizations-demo.json + +indicators: + organization_id: 673dafb02cf54cfb2ccbd118 + global_tag_prefix: ecospheres-indicateurs + filters: + - name: Thématique + id: theme + color: green-menthe + values: + - id: mieux-se-deplacer + name: Mieux se déplacer + - id: autre + name: Autre + - id: mieux-produire + name: Mieux produire + - name: Enjeu + id: enjeu + color: blue-ecume + values: + - id: sante + name: Santé + - id: attenuation + name: Atténuation + - id: biodiversite + name: Biodiversité \ No newline at end of file diff --git a/src/components/bouquets/BouquetSearch.vue b/src/components/bouquets/BouquetSearch.vue index 74441bd20..f2c87ffe0 100644 --- a/src/components/bouquets/BouquetSearch.vue +++ b/src/components/bouquets/BouquetSearch.vue @@ -180,7 +180,7 @@ watchEffect(() => { >Couverture territoriale diff --git a/src/components/forms/SelectSpatialCoverage.vue b/src/components/forms/SelectSpatialCoverage.vue index 55370bda1..6651e9b87 100644 --- a/src/components/forms/SelectSpatialCoverage.vue +++ b/src/components/forms/SelectSpatialCoverage.vue @@ -14,10 +14,6 @@ const selectedSpatialCoverage = defineModel('spatialCoverageModel', { }) defineProps({ - modelValue: { - type: Object as PropType, - default: undefined - }, short: { type: Boolean, default: false diff --git a/src/custom/ecospheres/components/SelectComponent.vue b/src/custom/ecospheres/components/SelectComponent.vue new file mode 100644 index 000000000..9e53a3134 --- /dev/null +++ b/src/custom/ecospheres/components/SelectComponent.vue @@ -0,0 +1,47 @@ + + + + diff --git a/src/custom/ecospheres/components/indicators/IndicatorCard.vue b/src/custom/ecospheres/components/indicators/IndicatorCard.vue new file mode 100644 index 000000000..ff3ae5e33 --- /dev/null +++ b/src/custom/ecospheres/components/indicators/IndicatorCard.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/custom/ecospheres/components/indicators/IndicatorList.vue b/src/custom/ecospheres/components/indicators/IndicatorList.vue new file mode 100644 index 000000000..f15be9fe1 --- /dev/null +++ b/src/custom/ecospheres/components/indicators/IndicatorList.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/src/custom/ecospheres/components/indicators/IndicatorSearch.vue b/src/custom/ecospheres/components/indicators/IndicatorSearch.vue new file mode 100644 index 000000000..5fdaeb243 --- /dev/null +++ b/src/custom/ecospheres/components/indicators/IndicatorSearch.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/custom/ecospheres/model/config.ts b/src/custom/ecospheres/model/config.ts new file mode 100644 index 000000000..4431333c1 --- /dev/null +++ b/src/custom/ecospheres/model/config.ts @@ -0,0 +1,17 @@ +import { type IndicatorFilters } from './indicator' + +export interface FilterConf { + id: keyof IndicatorFilters + name: string + color: string + values: { + id: string + name: string + }[] +} + +export interface IndicatorsConf { + global_tag_prefix: string + organization_id: string + filters: FilterConf[] +} diff --git a/src/custom/ecospheres/model/indicator.ts b/src/custom/ecospheres/model/indicator.ts new file mode 100644 index 000000000..ef3a39956 --- /dev/null +++ b/src/custom/ecospheres/model/indicator.ts @@ -0,0 +1,18 @@ +import type { DatasetV2 } from '@datagouv/components' + +export type Indicator = DatasetV2 + +// repeating keys is necessary, type becomes too complex to be usable if we don't +// we're keeping values and type in the same place to facilitate maintenance +export const FILTER_KEYS = ['theme', 'enjeu'] as const + +export type IndicatorFilters = { + theme: string | null + enjeu: string | null +} + +export interface IndicatorTag { + color: string + value: string + type: string +} diff --git a/src/custom/ecospheres/routes.ts b/src/custom/ecospheres/routes.ts index 55672e28f..07396e2c4 100644 --- a/src/custom/ecospheres/routes.ts +++ b/src/custom/ecospheres/routes.ts @@ -1,3 +1,6 @@ +import { type RouteLocationNormalizedLoaded } from 'vue-router' +import { FILTER_KEYS } from './model/indicator' + export const routes = [ { path: '/', @@ -6,5 +9,30 @@ export const routes = [ title: 'Accueil' }, component: async () => await import('./views/HomeView.vue') + }, + { + path: '/indicators', + name: 'indicators', + meta: { + title: 'Indicateurs' + }, + props: (route: RouteLocationNormalizedLoaded) => { + const filterProps = FILTER_KEYS.reduce( + (acc, key) => ({ + ...acc, + [key]: route.query[key] || null + }), + {} + ) + return { + query: route.query.q, + geozone: route.query.geozone || null, + page: route.query.page || null, + sort: route.query.sort || null, + ...filterProps + } + }, + component: async () => + await import('./views/indicators/IndicatorsListView.vue') } ] diff --git a/src/custom/ecospheres/store/IndicatorStore.ts b/src/custom/ecospheres/store/IndicatorStore.ts new file mode 100644 index 000000000..0dd738315 --- /dev/null +++ b/src/custom/ecospheres/store/IndicatorStore.ts @@ -0,0 +1,53 @@ +import { defineStore } from 'pinia' + +import config from '@/config' +import SearchAPI from '@/services/api/SearchAPI' +import type { Indicator, IndicatorFilters } from '../model/indicator' +import { useTagsQuery } from '../utils/indicator' + +const searchApi = new SearchAPI() +const PAGE_SIZE = 20 + +export interface RootState { + indicators: Indicator[] + total: number +} + +interface QueryArgs extends IndicatorFilters { + query: string + geozone: string | null +} + +export const useIndicatorStore = defineStore('indicator', { + state: (): RootState => ({ + indicators: [], + total: 0 + }), + actions: { + async query(args: QueryArgs) { + const { query, ...queryArgs } = args + const { extraArgs, tag } = useTagsQuery(queryArgs) + const results = await searchApi.search(query, null, 1, { + organization: config.indicators.organization_id, + page_size: PAGE_SIZE, + tag, + ...extraArgs + }) + this.indicators = results.data + this.total = results.total + } + }, + getters: { + pagination() { + const nbPages = Math.ceil(this.total / PAGE_SIZE) + return [...Array(nbPages).keys()].map((page) => { + page += 1 + return { + label: page.toString(), + href: '#', + title: `Page ${page}` + } + }) + } + } +}) diff --git a/src/custom/ecospheres/utils/a11y.ts b/src/custom/ecospheres/utils/a11y.ts new file mode 100644 index 000000000..606f571f6 --- /dev/null +++ b/src/custom/ecospheres/utils/a11y.ts @@ -0,0 +1,33 @@ +import { + AccessibilityPropertiesKey, + type AccessibilityPropertiesType +} from '@/model/injectionKeys' +import { computed, watch, type Ref } from 'vue' +import { useRoute } from 'vue-router' + +// TODO: use this on BouquetsListView and DatasetsListView +export const useAccessibilityProperties = ( + query: Ref, + searchResultsMessage: ComputedRef +) => { + const route = useRoute() + + const setAccessibilityProperties = inject( + AccessibilityPropertiesKey + ) as AccessibilityPropertiesType + + const pageTitle = computed(() => { + if (query.value) { + return `${route.meta.title} pour "${query.value}"` + } + return route.meta.title + }) + + watch([pageTitle, searchResultsMessage], () => { + setAccessibilityProperties(pageTitle.value, false, [ + { + text: searchResultsMessage.value + } + ]) + }) +} diff --git a/src/custom/ecospheres/utils/config.ts b/src/custom/ecospheres/utils/config.ts new file mode 100644 index 000000000..a5fc89ad7 --- /dev/null +++ b/src/custom/ecospheres/utils/config.ts @@ -0,0 +1,22 @@ +import config from '@/config' +import type { FilterConf, IndicatorsConf } from '../model/config' +import { type IndicatorFilters, FILTER_KEYS } from '../model/indicator' + +export const useFiltersConf = () => { + return FILTER_KEYS.reduce( + (acc, key) => ({ + ...acc, + [key]: useFilterConf(key) + }), + {} as Record + ) +} + +export const useFilterConf = (filter: keyof IndicatorFilters): FilterConf => { + const indicatorsConf = config.indicators as IndicatorsConf + const filterConf = indicatorsConf.filters.find((f) => f.id === filter) + if (!filterConf) { + throw new Error(`Filter ${filter} not found`) + } + return filterConf +} diff --git a/src/custom/ecospheres/utils/indicator.ts b/src/custom/ecospheres/utils/indicator.ts new file mode 100644 index 000000000..40e25fc29 --- /dev/null +++ b/src/custom/ecospheres/utils/indicator.ts @@ -0,0 +1,65 @@ +import config from '@/config' +import type { ComputedRef } from 'vue' +import type { IndicatorsConf } from '../model/config' +import type { + Indicator, + IndicatorFilters, + IndicatorTag +} from '../model/indicator' + +const indicatorsConf = config.indicators as IndicatorsConf +const tagPrefix = indicatorsConf.global_tag_prefix +const filters = indicatorsConf.filters + +interface QueryArgs extends IndicatorFilters { + [key: string]: string | null +} + +/** + * Build an array of normalized tags from query components and clean the original QueryArgs + */ +export const useTagsQuery = ( + query: QueryArgs +): { tag: Array; extraArgs: QueryArgs } => { + const queryArray = [] + for (const filter of filters) { + if (query[filter.id] != null) { + queryArray.push(`${tagPrefix}-${filter.id}-${query[filter.id]}`) + } + delete query[filter.id] + } + return { + tag: queryArray, + extraArgs: query + } +} + +/** + * Extract and denormalize tags from an indicator + */ +export const useTags = (indicator: Indicator): ComputedRef => { + return computed(() => { + return ( + indicator.tags + ?.map((tag) => { + if (tag.startsWith(tagPrefix)) { + for (const filter of filters) { + const filterPrefix = `${tagPrefix}-${filter.id}-` + if (tag.startsWith(filterPrefix)) { + const value = tag.replace(filterPrefix, '') + const filterValue = filter.values.find((v) => v.id === value) + if (filterValue) { + return { + color: filter.color, + value: filterValue.name, + type: filter.id + } + } + } + } + } + }) + .filter((v) => !!v) || [] + ) + }) +} diff --git a/src/custom/ecospheres/views/indicators/IndicatorsListView.vue b/src/custom/ecospheres/views/indicators/IndicatorsListView.vue new file mode 100644 index 000000000..f3b74292b --- /dev/null +++ b/src/custom/ecospheres/views/indicators/IndicatorsListView.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/src/services/api/DatagouvfrAPI.ts b/src/services/api/DatagouvfrAPI.ts index 8f16f7a3a..5b1d03081 100644 --- a/src/services/api/DatagouvfrAPI.ts +++ b/src/services/api/DatagouvfrAPI.ts @@ -14,6 +14,11 @@ import type { } from '@/model/api' import { toastHttpError } from '@/utils/error' +// build queries like tag=1&tag=2 for arrays instead of tag[]=1&tag[]=2 +axios.defaults.paramsSerializer = { + indexes: null +} + /** * A wrapper around data.gouv.fr's API *