Skip to content

Commit

Permalink
feat(indicators): add list view with filters (#605)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
abulte authored Dec 11, 2024
1 parent 15dec7b commit 6b33aa7
Show file tree
Hide file tree
Showing 16 changed files with 745 additions and 5 deletions.
28 changes: 28 additions & 0 deletions configs/ecospheres/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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é
2 changes: 1 addition & 1 deletion src/components/bouquets/BouquetSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ watchEffect(() => {
>Couverture territoriale</label
>
<SelectSpatialCoverage
v-model="selectedSpatialCoverage"
v-model:spatial-coverage-model="selectedSpatialCoverage"
:short="true"
@update:spatial-coverage-model="switchSpatialCoverage"
/>
Expand Down
4 changes: 0 additions & 4 deletions src/components/forms/SelectSpatialCoverage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ const selectedSpatialCoverage = defineModel('spatialCoverageModel', {
})
defineProps({
modelValue: {
type: Object as PropType<SpatialCoverage>,
default: undefined
},
short: {
type: Boolean,
default: false
Expand Down
47 changes: 47 additions & 0 deletions src/custom/ecospheres/components/SelectComponent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!-- a select component with a selectable null value (default) -->
<script setup lang="ts">
import { getRandomId } from '@gouvminint/vue-dsfr'

type SelectOption = {
id: string
name: string
}

const selectedOption = defineModel({
type: String as () => string | null,
default: null
})

defineProps({
options: {
type: Object as PropType<SelectOption[]>,
required: true
},
label: {
type: String,
required: true
},
labelClass: {
type: Array,
default: () => ['fr-label']
},
defaultOption: {
type: String,
default: 'Tous'
}
})

const id = getRandomId('select')
</script>

<template>
<label :class="labelClass" :for="id">{{ label }}</label>
<select :id="id" v-model="selectedOption" class="fr-select fr-col">
<option :value="null">
{{ defaultOption }}
</option>
<option v-for="option in options" :key="option.id" :value="option.id">
{{ option.name }}
</option>
</select>
</template>
43 changes: 43 additions & 0 deletions src/custom/ecospheres/components/indicators/IndicatorCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { stripFromMarkdown } from '@/utils'
import type { Indicator } from '../../model/indicator'
import { useTags } from '../../utils/indicator'
const props = defineProps({
indicator: {
type: Object as PropType<Indicator>,
required: true
}
})
const tags = useTags(props.indicator)
</script>

<template>
<div class="fr-card fr-enlarge-link">
<div class="fr-card__body">
<div class="fr-card__content">
<ul class="fr-badges-group fr-mb-1w">
<li v-for="tag in tags" :key="`${tag.type}-${tag.value}`">
<p :class="['fr-badge', `fr-badge--${tag.color}`]">
{{ tag.value }}
</p>
</li>
</ul>
<h3 class="fr-card__title fr-mb-1w">
<RouterLink
:to="{ name: 'dataset_detail', params: { did: indicator.id } }"
>{{ indicator.title }}</RouterLink
>
</h3>
<p class="fr-card__desc">
<text-clamp
:auto-resize="true"
:text="stripFromMarkdown(indicator.description)"
:max-lines="3"
/>
</p>
</div>
</div>
</div>
</template>
154 changes: 154 additions & 0 deletions src/custom/ecospheres/components/indicators/IndicatorList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, watch, type ComputedRef } from 'vue'
import { useLoading } from 'vue-loading-overlay'
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
import type { IndicatorFilters } from '../../model/indicator'
import { useIndicatorStore } from '../../store/IndicatorStore'
import SelectComponent from '../SelectComponent.vue'
import IndicatorCard from './IndicatorCard.vue'
const route = useRoute()
const router = useRouter()
const store = useIndicatorStore()
type Props = IndicatorFilters & {
query: string
page: number
sort: string | null
geozone: string | null
}
const props = withDefaults(defineProps<Props>(), { query: '' })
const emits = defineEmits(['clearFilters'])
const { indicators, pagination, total } = storeToRefs(store)
const numberOfResultMsg: ComputedRef<string> = computed(() => {
if (total.value === 1) {
return '1 indicateur disponible'
} else if (total.value > 1) {
return `${total.value} indicateurs disponibles`
} else {
return 'Aucun résultat ne correspond à votre recherche'
}
})
const clearFilters = () => {
const query: LocationQueryRaw = {}
router.push({ name: 'indicators', query }).then(() => {
emits('clearFilters')
})
}
const executeQuery = async (args: typeof props) => {
const loader = useLoading().show({ enforceFocus: false })
return store.query(args).finally(() => loader.hide())
}
const goToPage = (page: number) => {
router.push({
name: 'indicators',
query: { ...route.query, page: page + 1 },
hash: '#indicators-list'
})
}
const doSort = (value: string | null) => {
router.push({
name: 'indicators',
query: { ...route.query, sort: value },
hash: '#indicators-list'
})
}
// launch search on props (~route.query) changes
watch(props, () => executeQuery(props), { immediate: true, deep: true })
defineExpose({
numberOfResultMsg
})
</script>

<template>
<div
v-if="indicators.length > 0"
class="fr-grid-row fr-grid-row--gutters fr-grid-row--middle justify-between fr-pb-2w"
>
<h2 class="fr-col-auto fr-my-0 h4">{{ numberOfResultMsg }}</h2>
<div class="fr-col-auto fr-grid-row fr-grid-row--middle">
<SelectComponent
label="Trier par :"
default-option="Pertinence"
:label-class="['fr-col-auto', 'fr-text--sm', 'fr-m-0', 'fr-mr-1w']"
:options="[
{ id: '-created', name: 'Les plus récemment créés' },
{ id: '-last_update', name: 'Les plus récemment modifiés' }
]"
@update:model-value="doSort"
/>
</div>
</div>
<div
v-if="indicators.length === 0"
class="fr-mt-2w rounded-xxs fr-p-3w fr-grid-row flex-direction-column bg-contrast-blue-cumulus"
>
<div class="fr-col fr-grid-row fr-grid-row--gutters text-blue-400">
<div class="fr-col-auto">
<img
src="/search/france_with_magnifying_glass.svg"
alt=""
loading="lazy"
class="w-100"
height="134"
width="124"
/>
</div>
<div
class="fr-col-12 fr-col-sm fr-grid-row flex-direction-column justify-between"
>
<div class="fr-mb-1w">
<h2 class="fr-m-0 fr-mb-1w fr-text--bold fr-text--md">
Aucun résultat ne correspond à votre recherche
</h2>
<p class="fr-mt-1v fr-mb-3v">
Essayez de réinitialiser les filtres pour agrandir votre champ de
recherche.
</p>
</div>
<div class="fr-grid-row fr-grid-row--undefined">
<button class="fr-btn" @click.stop.prevent="clearFilters">
Réinitialiser les filtres
</button>
</div>
</div>
</div>
</div>
<div class="indicators-list-container fr-container fr-mb-4w border-top">
<ul class="fr-grid-row fr-grid-row--gutters fr-mb-1w fr-mt-2w" role="list">
<li
v-for="indicator in indicators"
:key="indicator.id"
class="fr-col-md-6 fr-col-md"
>
<IndicatorCard :indicator="indicator" />
</li>
</ul>
</div>
<DsfrPagination
v-if="pagination.length"
class="fr-container"
:current-page="page - 1"
:pages="pagination"
@update:current-page="goToPage"
/>
</template>

<style scoped>
/* "revert" gutters — simpler than w/o gutters */
.indicators-list-container {
padding-right: 0;
padding-left: 0;
}
</style>
90 changes: 90 additions & 0 deletions src/custom/ecospheres/components/indicators/IndicatorSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref, watchEffect, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SelectSpatialCoverage from '@/components/forms/SelectSpatialCoverage.vue'
import type { SpatialCoverage } from '@/model/spatial'
import SpatialAPI from '@/services/api/SpatialAPI'
import type { IndicatorFilters } from '../../model/indicator'
import { useFiltersConf } from '../../utils/config'
import SelectComponent from '../SelectComponent.vue'
const spatialAPI = new SpatialAPI()
type Props = IndicatorFilters & {
geozone: string | null
}
const props = defineProps<Props>()
const router = useRouter()
const route = useRoute()
const selectedGeozone: Ref<string | null> = ref(null)
const selectedSpatialCoverage: Ref<SpatialCoverage | undefined> = ref(undefined)
const filtersConf = useFiltersConf()
const navigate = (data?: Record<string, string | null>) => {
router.push({
name: 'indicators',
query: { ...route.query, ...data },
hash: '#indicators-list'
})
}
const switchFilter = (filter: string, value: string | null) => {
navigate({ [filter]: value })
}
const switchSpatialCoverage = (
spatialCoverage: SpatialCoverage | null | undefined
) => {
selectedGeozone.value = spatialCoverage != null ? spatialCoverage.id : null
navigate({ geozone: selectedGeozone.value })
}
watchEffect(() => {
if (props.geozone) {
selectedGeozone.value = props.geozone
spatialAPI
.getZone(props.geozone)
.then((zone) => (selectedSpatialCoverage.value = zone))
} else {
selectedSpatialCoverage.value = undefined
selectedGeozone.value = null
}
})
</script>

<template>
<div className="filterForm">
<div class="fr-select-group">
<SelectComponent
default-option="Toutes les thématiques"
:label="filtersConf.theme.name"
:options="filtersConf.theme.values"
:model-value="props.theme"
@update:model-value="(value) => switchFilter('theme', value)"
/>
</div>
<div class="fr-select-group">
<SelectComponent
default-option="Tous les enjeux"
:label="filtersConf.enjeu.name"
:options="filtersConf.enjeu.values"
:model-value="props.enjeu"
@update:model-value="(value) => switchFilter('enjeu', value)"
/>
</div>
<div class="fr-select-group">
<label class="fr-label" for="select-spatial-coverage"
>Couverture territoriale</label
>
<SelectSpatialCoverage
v-model:spatial-coverage-model="selectedSpatialCoverage"
:short="true"
@update:spatial-coverage-model="switchSpatialCoverage"
/>
</div>
</div>
</template>
Loading

0 comments on commit 6b33aa7

Please sign in to comment.