<template> <div v-if="chunkedNsortedFilters.length > 0" id="filters-container" > <div class="ui styled accordion" @click="displayFilters = !displayFilters" > <div id="filters" class="title collapsible-filters" > FILTRES <i :class="['ui icon customcaret', { 'collapsed': !displayFilters }]" aria-hidden="true" /> </div> </div> <div :class="['full-width', 'filters', { 'hidden': displayFilters }]"> <div v-for="(chunkedFilters, index) in chunkedNsortedFilters" :key="index" class="ui menu filter-row" > <div v-for="filter in chunkedFilters" :key="filter.name" class="item" > <label> {{ filter.label }} </label> <search-projects v-if="filter.name === 'search'" v-on="$listeners" /> <DropdownMenuItem v-else-if="!filter.id" :options="filter.options" :loading="loading" v-on="$listeners" /> <DropdownMenuItem v-else :options="filter.options" :loading="loading" :multiple="isMultiple(filter)" :current-selection="attributesFilter[filter.id]" :default-filter="filter.default_filter_enabled ? filter.default_filter_value : null" @filter="updateAttributeFilter" @remove="removeAttributeFilter" /> </div> </div> </div> </div> </template> <script> import { mapState, mapMutations } from 'vuex'; import DropdownMenuItem from '@/components/Projects/DropdownMenuItem.vue'; import SearchProjects from '@/components/Projects/SearchProjects.vue'; export default { name: 'ProjectsMenu', components: { DropdownMenuItem, SearchProjects, }, props: { loading: { type: Boolean, default: false }, }, data() { return { displayFilters: false, classicFilters: [ { name: 'access_level', label: 'Niveau d\'autorisation requis', options: [ { label: 'Utilisateur anonyme', value: 'anonymous' }, { label: 'Utilisateur connecté', value: 'logged_user' }, { label: 'Contributeur', value: 'contributor' }, ], }, { name: 'user_access_level', label: 'Mon niveau d\'autorisation', options: [ { label: 'Utilisateur connecté', value: '1' }, { label: 'Contributeur', value: '2' }, { label: 'Super contributeur', value: '3' }, { label: 'Modérateur', value: '4' }, { label: 'Administrateur projet', value: '5' }, ], }, { name: 'moderation', label: 'Modération', options: [ { label: 'Projet modéré', value: 'true' }, { label: 'Projet non modéré', value: 'false' }, ] }, { name: 'search', label: 'Recherche par nom', } ], attributesFilter: {}, }; }, computed: { ...mapState([ 'user', 'configuration', 'projectAttributes' ]), ...mapState('projects', [ 'filters', ]), /** * Processes project filters to prepare them for display. * It also adds a global 'Tous' (All) option to each attribute's options for filtering purposes. * * @returns {Array} An array of filter objects with modified options for display. */ displayedClassicFilters() { if (!this.configuration.VUE_APP_PROJECT_FILTERS) return []; const projectFilters = this.configuration.VUE_APP_PROJECT_FILTERS.split(','); // Filter filters to be displayed according to configuration and process filters return this.classicFilters.filter(filter => projectFilters.includes(filter.name)) .map(filter => { if (filter.options) { // if user is not connected display its user access level corresponding to anonymous user if (!this.user && filter.name ==='user_access_level') { filter.options.unshift({ label: 'Utilisateur anonyme', value: '0' }); } // Format the options to be displayed by dropdowns const options = this.generateFilterOptions(filter); // Add the global option at beginning options.unshift({ label: 'Tous', filter: filter.name, value: null, }); return { ...filter, options }; } else { // Search input field doesn't take options return filter; } }); }, /** * Processes project attributes to prepare them for display, adjusting the options based on the attribute type. * For boolean attributes, it creates specific options for true and false values. * It also adds a global 'Tous' (All) option to each attribute's options for filtering purposes. * Finally, it chunks the array of attributes into multiple arrays, each containing up to 4 elements. * * @returns {Array} An array of arrays, where each sub-array contains up to 4 project attributes with modified options for display. */ displayedAttributeFilters() { // Filter displayed filters & filter only attribute of boolean type (no need for option property) or list type with options return this.projectAttributes.filter(attribute => attribute.display_filter && (attribute.field_type === 'boolean' || attribute.options)) // Process attributes for display .map(attribute => { // Format the options to be displayed by dropdowns const options = this.generateFilterOptions(attribute); // Add the global option at beginning options.unshift({ label: 'Tous', filter: attribute.id, value: null, }); return { ...attribute, options }; }); }, /** * Merge all filters and place the search filters at the end of the array * Then chunks the array into rows of 4 filters to display each chunk in a row */ chunkedNsortedFilters() { const allFilters = [...this.displayedClassicFilters, ...this.displayedAttributeFilters]; const sortedFilters = [ ...allFilters.filter(el => el.name !== 'search'), ...allFilters.filter(el => el.name === 'search'), ]; // Chunk the filters into arrays of up to 4 elements return this.chunkArray(sortedFilters, 4); }, }, created() { // parse all project attributes to find default value and set filters in store before updating project list results for (const attribFilter of this.displayedAttributeFilters) { this.setDefaultFilters(attribFilter); } // When all the default filters are set, fetch projects list data this.$emit('getData'); }, methods: { ...mapMutations('projects', [ 'SET_PROJECTS_FILTER' ]), /** * Helper function to chunk an array into smaller arrays of a specified size. * * @param {Array} array - The original array to be chunked. * @param {Number} size - The maximum size of each chunk. * @returns {Array} An array of chunked arrays. */ chunkArray(array, size) { const chunkedArr = []; for (let i = 0; i < array.length; i += size) { chunkedArr.push(array.slice(i, i + size)); } return chunkedArr; }, /** * Generates options for a given filter. * It handles boolean attributes specially by creating explicit true/false options. * Other attribute types use their predefined options. * * @param {Object} attribute - The project attribute for which to generate options. * @returns {Array} An array of options for the given attribute. */ generateFilterOptions(filter) { // Handle boolean attributes specially by creating true/false options if (filter.field_type === 'boolean') { return [ { filter: filter.id, label: 'Oui', value: 'true' }, { filter: filter.id, label: 'Non', value: 'false' }, ]; } else if (filter.options) { // For other filter types, map each option to the expected format return filter.options.map(option => ({ filter: filter.id || filter.name, label: option.name || option.label || option, value: option.id || option.value || option, })); } return []; }, /** * Retrieves a project attribute by its ID. * Returns an empty object if not found to prevent errors from undefined access. * * @param {Number|String} id - The ID of the attribute to find. * @returns {Object} The found attribute or an empty object. */ getProjectAttribute(id) { // Search for the attribute by ID, default to an empty object if not found return this.projectAttributes.find(el => el.id === id) || {}; }, /** * Emits an updated filter event with the current state of attributesFilter. * This method serializes the attributesFilter object to a JSON string and emits it, * allowing the parent component to update the query parameters. */ emitUpdatedFilter() { // Emit an 'filter' event with the updated attributes filter as a JSON string this.$emit('filter', { filter: 'attributes', value: JSON.stringify(this.attributesFilter) }); }, /** * Updates or adds a new attribute value to the attributesFilter. * Handles both single-choice and multi-choice attribute types. * @param {Object} newFilter - The new filter to be added, containing the attribute key and value. */ updateAttributeFilter({ filter, value, noUpdate }) { // Retrieve the attribute type information to determine how to handle the update const attribute = this.getProjectAttribute(filter); // Check if the attribute allows multiple selections const isMultiChoice = attribute.field_type.includes('list'); if (isMultiChoice) { // For multi-choice attributes, manage the values as an array to allow multiple selections let arrayValue = this.attributesFilter[filter] ? this.attributesFilter[filter].split(',') : []; if (value) { // If a value is provided, add it to the array, ensuring no duplicates and removing null corresponding to "Tous" default option arrayValue.push(value); arrayValue = [...new Set(arrayValue)].filter(el => el !== null); // Convert the array back to a comma-separated string to store in the filter object this.attributesFilter[filter] = arrayValue.join(','); } else { // If null value is provided "Tous" is selected, it indicates removal of the attribute filter delete this.attributesFilter[filter]; } } else { // For single-choice attributes, directly set or delete the value value ? this.attributesFilter[filter] = value : delete this.attributesFilter[filter]; } if (noUpdate) { this.SET_PROJECTS_FILTER({ filter: 'attributes', value: JSON.stringify(this.attributesFilter) }); } else { // After updating the filter object, emit the updated filter for application-wide use this.emitUpdatedFilter(); } }, /** * Removes a specified value from a project attribute filter. * Particularly useful for multi-choice attributes where individual values can be deselected. * @param {Object} removedFilter - The filter to be removed, containing the attribute key and value. */ removeAttributeFilter({ filter, value }) { // Retrieve attribute information to determine if it's a multi-choice attribute const attribute = this.getProjectAttribute(filter); const isMultiChoice = attribute.field_type.includes('list'); if (isMultiChoice) { // For multi-choice attributes, convert the current filter value to an array for manipulation let arrayValue = this.attributesFilter[filter] ? this.attributesFilter[filter].split(',') : []; // Remove the specified value from the array arrayValue = arrayValue.filter(val => val !== value); // Update the attributesFilter with the new array, converted back to a string this.attributesFilter[filter] = arrayValue.join(','); } else { // For single-choice attributes, directly update the filter to remove the value delete this.attributesFilter[filter]; } // Emit the updated filter after removal this.emitUpdatedFilter(); }, isMultiple(filter) { return filter.field_type.includes('list'); }, setDefaultFilters(filter) { const defaultFilter = filter.default_filter_enabled ? filter.default_filter_value : null; if (defaultFilter) { // make an array from the string in case of a list const filtersArray = defaultFilter.split(','); // for each value update the filter filtersArray.forEach(defaultValue => { const defaultOption = filter.options.find(option => option.value === defaultValue); if (defaultOption) { this.updateAttributeFilter({ ...defaultOption, noUpdate: true }); } }); } }, } }; </script> <style lang="less" scoped> .transition-properties(...) { -webkit-transition: @arguments; -moz-transition: @arguments; -o-transition: @arguments; transition: @arguments; } #filters-container { width: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: flex-end; .accordion { width: fit-content; .collapsible-filters { font-size: 1.25em; padding-right: 0; .customcaret{ transition: transform .2s ease; &.collapsed { transform: rotate(180deg); } &::before{ position: relative; right: 0; top: 65%; color: #999; margin-top: 4px; border-color: #999 transparent transparent; border-style: solid; border-width: 5px 5px 0; content: ""; } } } } .filters { width: 100%; height:auto; max-height:100vh; opacity: 1; z-index: 1001; .transition-properties(all 0.2s ease;); .filter-row { border: none; box-shadow: none; } .item { display: flex; flex-direction: column; align-items: flex-start !important; padding: 0.5em; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } label { margin-bottom: 0.2em; font-size: 0.9em; font-weight: 600; } } .item { width: 25%; } .item::before { width: 0; } #search-projects { width: 100%; } } .filters.hidden { overflow: hidden; opacity: 0; max-height: 0; } } @media screen and (min-width: 701px) { .item { &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } } @media screen and (max-width: 700px) { #filters-container { .filter-row { display: flex; flex-direction: column; max-height: 275px; .transition-properties(all 0.2s ease-out;); .item { width: 100%; padding-right: 0; padding-left: 0; } } } } </style>