diff --git a/src/components/Pagination.vue b/src/components/Pagination.vue index 7764f116a41c61b8f25df5a22e20d3354ca97fe9..0318e51f46dbe54e242cdce4ce57e6eb9cebdaac 100644 --- a/src/components/Pagination.vue +++ b/src/components/Pagination.vue @@ -4,12 +4,12 @@ <ul class="custom-pagination"> <li class="page-item" - :class="{ disabled: page === 1 }" + :class="{ disabled: currentPage === 1 }" > <a class="page-link" :href="currentLocation" - @click="page -= 1" + @click="currentPage -= 1" > <i class="ui icon big angle left" @@ -22,10 +22,10 @@ style="display: contents;" > <li - v-for="index in pagination(page, nbPages)" + v-for="index in pagination(currentPage, nbPages)" :key="index" class="page-item" - :class="{ active: page === index }" + :class="{ active: currentPage === index }" > <a class="page-link" @@ -44,12 +44,12 @@ v-for="index in nbPages" :key="index" class="page-item" - :class="{ active: page === index }" + :class="{ active: currentPage === index }" > <a class="page-link" :href="currentLocation" - @click="page = index" + @click="changePage(index)" > {{ index }} </a> @@ -57,12 +57,12 @@ </div> <li class="page-item" - :class="{ disabled: page === nbPages }" + :class="{ disabled: currentPage === nbPages }" > <a class="page-link" :href="currentLocation" - @click="page += 1" + @click="currentPage += 1" > <i class="ui icon big angle right" @@ -76,6 +76,8 @@ </template> <script> +import { mapState, mapMutations } from 'vuex'; + export default { name: 'Pagination', @@ -93,15 +95,19 @@ export default { } }, + computed: { + ...mapState('projects', ['currentPage']), + }, + data() { return { - page: 1, + // TODO: Refactor by using native scroll to top instead of this currentLocation: `${window.location.origin}${window.location.pathname}#`, }; }, watch: { - page: function(newValue, oldValue) { + currentPage: function(newValue, oldValue) { if (newValue !== oldValue) { this.onPageChange(newValue); this.$emit('change-page', newValue); @@ -110,6 +116,11 @@ export default { }, methods: { + ...mapMutations('projects', [ + 'SET_CURRENT_PAGE', + 'SET_PROJECTS_FILTER' + ]), + pagination(c, m) { const current = c, last = m, @@ -140,9 +151,9 @@ export default { return rangeWithDots; }, - changePage(num) { - if (typeof num === 'number') { - this.page = num; + changePage(pageNumber) { + if (typeof pageNumber === 'number') { + this.SET_CURRENT_PAGE(pageNumber); } } } diff --git a/src/components/Projects/DropdownMenuItem.vue b/src/components/Projects/DropdownMenuItem.vue index a50e4cada8937a26ec07a4aec34ccb43c76afddc..358b3c8ac2725cd2c64b82b778135f70db3e79e3 100644 --- a/src/components/Projects/DropdownMenuItem.vue +++ b/src/components/Projects/DropdownMenuItem.vue @@ -13,9 +13,18 @@ :placeholder="placeholder" :clear-on-select="false" :preserve-search="true" + :multiple="multiple" @select="select" + @remove="remove" @close="close" - /> + > + <template v-if="multiple" slot="selection" slot-scope="{ values }"> + <span class="multiselect__single" v-if="values && values.length > 1"> + {{ values.length }} options sélectionnées + </span> + <span class="multiselect__single" v-else>{{ currentSelection || selection.label }}</span> + </template> + </Multiselect> </template> <script> @@ -38,6 +47,18 @@ export default { default: () => { return []; } + }, + multiple: { + type: Boolean, + default: false + }, + currentSelection: { + type: [String, Array, Boolean], + default: null, + }, + defaultFilter: { + type: [String, Array, Boolean], + default: null, } }, @@ -56,19 +77,72 @@ export default { this.$emit('filter', this.selection); } } - } + }, + + currentSelection(newValue) { + this.updateSelection(newValue); + }, }, created() { this.selection = this.options[0]; + if (this.defaultFilter) { + const selectFilter = (filter) => this.select(this.options.find(option => option.value === filter)); + // Specific process if multiple values type and has more than one values + if (this.multiple && this.defaultFilter.includes(',')) { + // make an array from the string + const filtersArray = this.defaultFilter.split(','); + // for each value update the filter + filtersArray.forEach(val => selectFilter(val)); + } else { // Process for single value + selectFilter(this.defaultFilter); + } + } }, methods: { select(e) { this.$emit('filter', e); }, + remove(e) { + this.$emit('remove', e); + }, close() { this.$emit('close', this.selection); + }, + /** + * Normalizes the input value(s) to an array of strings. + * This handles both single string inputs and comma-separated strings, converting them into an array. + * + * @param {String|Array} value - The input value to normalize, can be a string or an array of strings. + * @return {Array} An array of strings representing the input values. + */ + normalizeValues(value) { + // If the value is a string and contains commas, split it into an array; otherwise, wrap it in an array. + return typeof value === 'string' ? (value.includes(',') ? value.split(',') : [value]) : value; + }, + + /** + * Updates the current selection based on new value, ensuring compatibility with multiselect. + * This method processes the new selection value, accommodating both single and multiple selections, + * and updates the internal `selection` state with the corresponding option objects from `options`. + * + * @param {String|Array} value - The new selection value(s), can be a string or an array of strings. + */ + // Check if the component is in multiple selection mode and the new value is provided. + updateSelection(value) { + if (this.multiple && value) { + // Normalize the value to an array format, accommodating both single and comma-separated values. + const normalizedValues = this.normalizeValues(value); + + // Map each value to its corresponding option object based on the 'value' field. + this.selection = normalizedValues.map(value => + this.options.find(option => option.value === value) + ); + } else { + // For single selection mode or null value, find the option object that matches the value. + this.selection = this.options.find(option => option.value === value); + } } } }; diff --git a/src/components/Projects/ProjectsMenu.vue b/src/components/Projects/ProjectsMenu.vue index 223f172a11ea3aed8559b917585157410076940f..da4bc6f2bd3d231c23a3b14950e40e4bfbe7544e 100644 --- a/src/components/Projects/ProjectsMenu.vue +++ b/src/components/Projects/ProjectsMenu.vue @@ -1,5 +1,8 @@ <template> - <div id="filters-container"> + <div + id="filters-container" + class="margin-bottom" + > <div class="ui styled accordion" @click="displayFilters = !displayFilters" @@ -15,8 +18,8 @@ /> </div> </div> - <div :class="['full-width', { 'hidden': displayFilters }]"> - <div class="ui menu filters"> + <div :class="['full-width', 'filters', { 'hidden': displayFilters }]"> + <div class="ui menu filter-row"> <div class="item"> <label> Niveau d'autorisation requis @@ -49,32 +52,32 @@ Recherche par nom </label> <search-projects - :search-function="SEARCH_PROJECTS" v-on="$listeners" /> </div> </div> - <div class="ui menu filters"> + <!-- Display several rows if more than 4 project attributes --> + <div + v-for="(groupedAttributes, index) in displayedAttributeFilters" + :key="index" + class="ui menu filter-row" + > <div - v-for="projectAttribute in displayedProjectAttributes" - :key="projectAttribute.id" + v-for="attribute in groupedAttributes" + :key="attribute.id" class="item" > <label> - {{ projectAttribute.label }} + {{ attribute.label }} </label> - <!-- <FeatureExtraForm - :options="projectAttribute.options" - v-on="$listeners" - /> --> - <FeatureExtraForm - :id="`attribute-value-for-${projectAttribute.name}`" - ref="extraForm" - name="attribute-value" - :field="{ ...projectAttribute, value: projectAttribute.default_filter_value }" - :use-value-only="true" - @update:value="updateValue($event.toString(), projectAttribute.id)" - /> + <DropdownMenuItem + :options="attribute.options" + :multiple="attribute.field_type === 'multi_choices_list'" + :current-selection="attributesFilter[attribute.id]" + :default-filter="attribute.default_filter_enabled ? attribute.default_filter_value : null" + @filter="updateAttributeFilter" + @remove="removeAttributeFilter" + /> </div> </div> </div> @@ -82,11 +85,10 @@ </template> <script> -import { mapState, mapActions } from 'vuex'; +import { mapState } from 'vuex'; import DropdownMenuItem from '@/components/Projects/DropdownMenuItem.vue'; import SearchProjects from '@/components/Projects/SearchProjects.vue'; -import FeatureExtraForm from '@/components/Feature/Edit/FeatureExtraForm'; export default { name: 'ProjectsMenu', @@ -94,7 +96,6 @@ export default { components: { DropdownMenuItem, SearchProjects, - FeatureExtraForm, }, data() { @@ -171,15 +172,35 @@ export default { value: '5' }, ], - projectAttributesFilter: {}, + attributesFilter: {}, }; }, computed: { ...mapState(['user', 'projectAttributes']), + /** + * 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 and process attributes + const processedAttributes = this.projectAttributes.filter(attribute => attribute.display_filter) + .map(attribute => { + const options = this.generateOptionsForAttribute(attribute); + options.unshift({ + label: 'Tous', + filter: attribute.id, + value: null, + }); + return { ...attribute, options }; + }); - displayedProjectAttributes() { - return this.projectAttributes.filter(el => el.display_filter); + // Chunk the processed attributes into arrays of up to 4 elements + return this.chunkArray(processedAttributes, 4); }, }, @@ -194,15 +215,126 @@ export default { }, methods: { - ...mapActions('projects', [ - 'SEARCH_PROJECTS' - ]), - updateValue(value, id) { - // update the project attributes key/value object - this.projectAttributesFilter[id] = value; - // emit the new project attributes object to be set as a project filter in query - this.$emit('filter', { filter: 'attributes', value: JSON.stringify(this.projectAttributesFilter) }); - } + /** + * 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 attribute based on its field type. + * 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. + */ + generateOptionsForAttribute(attribute) { + // Handle boolean attributes specially by creating true/false options + if (attribute.field_type === 'boolean') { + return [ + { filter: attribute.id, label: 'Oui', value: 'true' }, + { filter: attribute.id, label: 'Non', value: 'false' }, + ]; + } + + // For other attribute types, map each option to the expected format + return attribute.options.map(option => ({ + filter: attribute.id, + label: option, + value: option, + })); + }, + + /** + * 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({ value, filter }) { + // 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 === 'multi_choices_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]; + } + + // 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({ value, filter }) { + // Retrieve attribute information to determine if it's a multi-choice attribute + const attribute = this.getProjectAttribute(filter); + const isMultiChoice = attribute.field_type === 'multi_choices_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(); + }, } }; </script> @@ -248,18 +380,24 @@ export default { .filters { width: 100%; height:auto; - min-height: 0; - max-height:75px; + max-height:100vh; opacity: 1; - margin: 0 0 1em 0; - border: none; - box-shadow: none; - .transition-properties(all 0.2s ease-out;); + .transition-properties(all 0.2s ease;); + .filter-row { + border: none; + box-shadow: none; + } .item { - display: flex; + display: flex; flex-direction: column; align-items: flex-start !important; - padding: 0.5em; + padding: 0.5em; + &:first-child { + padding-left: 0; + } + &:last-child { + padding-right: 0; + } label { margin-bottom: 0.2em; @@ -298,7 +436,7 @@ export default { @media screen and (max-width: 700px) { #filters-container { - .filters { + .filter-row { display: flex; flex-direction: column; max-height: 275px; diff --git a/src/components/Projects/SearchProjects.vue b/src/components/Projects/SearchProjects.vue index 2fa23fdfd34d7b6e597eb7a1ede0444f162fbd2e..e87de1e59b6d81bbcbba0436942c967c8b6bc826 100644 --- a/src/components/Projects/SearchProjects.vue +++ b/src/components/Projects/SearchProjects.vue @@ -1,53 +1,42 @@ <template> <div id="search-projects"> <input - v-model="text" type="text" placeholder="Rechercher un projet ..." + @input="searchProjects" > </div> </template> <script> -import _ from 'lodash'; -import { mapMutations } from 'vuex'; +import { debounce } from 'lodash'; +import { mapActions, mapMutations } from 'vuex'; export default { name: 'SearchProjects', - props: { - searchFunction: { - type: Function, - default: () => { return {}; } - } - }, - - data() { - return { - text: null - }; - }, - - watch: { - text: _.debounce(function(newValue) { - this.$emit('loading', true); - this.SET_CURRENT_PAGE(1); - this.searchFunction(newValue) - .then(() => { - this.$emit('loading', false); - }) - .catch((err) => { - if (err.message) { - this.$emit('loading', false); - } - }); - }, 100) - }, - methods: { ...mapMutations('projects', [ 'SET_CURRENT_PAGE' - ]) + ]), + ...mapActions('projects', [ + 'FILTER_PROJECTS' + ]), + + searchProjects: + debounce(function(e) { + this.$emit('loading', true); + this.SET_CURRENT_PAGE(1); + this.FILTER_PROJECTS({ text: e.target.value }) + .then(() => { + this.$emit('loading', false); + }) + .catch((err) => { + if (err.message) { + this.$emit('loading', false); + } + }); + }, 100) } }; </script> diff --git a/src/store/modules/projects.store.js b/src/store/modules/projects.store.js index e5810c11ee23dacda97c3e234d4333ea03b7a760..186caada9766ef464f8a09ed7320362558745102 100644 --- a/src/store/modules/projects.store.js +++ b/src/store/modules/projects.store.js @@ -8,6 +8,48 @@ const initialFilters = { accessible: null }; + +/** + * Cancels the most recent search request if it exists. + * + * @param {Object} rootState - The root state of the Vuex store to access global states. + */ +function cancelPreviousSearchRequest(rootState) { + if (rootState.cancellableSearchRequest.length > 0) { + const lastRequestToken = rootState.cancellableSearchRequest.pop(); + lastRequestToken.cancel('New search initiated.'); + } +} + +/** + * Constructs the URL for the search request, appending search text and any active filters. + * + * @param {Object} rootState - The root state to access global configuration settings. + * @param {Object} filters - The current state of filters applied to the search. + * @param {String} text - The current search text. + * @returns {String} The fully constructed URL for the search request. + */ +function constructSearchUrl({ baseUrl, filters, text, page }) { + let url = `${baseUrl}v2/projects/?`; + // Append page number if provided. + if (page) { + url += `page=${page}`; + } + // Append search text if provided. + if (text) { + url += `&search=${encodeURIComponent(text)}`; + } + + // Append each active filter to the URL. + Object.entries(filters).forEach(([key, value]) => { + if (value) { + url += `&${key}=${encodeURIComponent(value)}`; + } + }); + + return url; +} + const projectsStore = { namespaced: true, @@ -16,7 +58,6 @@ const projectsStore = { count: 0, currentPage: 1, filters: { ...initialFilters }, - isProjectsListSearched: null, last_comments: [], projects: [], project: null, @@ -50,9 +91,8 @@ const projectsStore = { state.filters[payload.filter] = payload.value; }, - SET_PROJECTS_SEARCH_STATE(state, payload) { - state.isProjectsListSearched = payload.isSearched; - state.searchProjectsFilter = payload.text; + SET_PROJECTS_SEARCH_STATE(state, text) { + state.searchProjectsFilter = text; }, SET_PROJECT_COMMENTS(state, last_comments) { @@ -78,16 +118,18 @@ const projectsStore = { return; }, - async SEARCH_PROJECTS({ commit, dispatch }, text) { - if (text) { - await dispatch('HANDLE_PROJECTS_SEARCH_REQUEST', text); - } else { - commit('SET_PROJECTS_SEARCH_STATE', { - isSearched: false, - text: null - }); - await dispatch('GET_PROJECTS'); + async FILTER_PROJECTS({ state, commit, dispatch }, { page, text }) { + if (!page) { + page = state.currentPage; + } + if (text === undefined) { + // if text is undefined it means that user didn't specify the text, we can use the text stored previously + text = state.searchProjectsFilter; + // this allows to replace search by empty string when the user emptied the input field, then we can empty stored text + } else if (text !== state.searchProjectsFilter) { + commit('SET_PROJECTS_SEARCH_STATE', text); } + await dispatch('HANDLE_PROJECTS_SEARCH_REQUEST', { page, text }); }, async GET_PROJECT({ rootState, commit }, slug) { // todo : use GET_PROJECTS instead, with slug @@ -122,47 +164,45 @@ const projectsStore = { }); }, - async HANDLE_PROJECTS_SEARCH_REQUEST({ state, rootState, commit }, text) { - - if (rootState.cancellableSearchRequest.length > 0) { - const currentRequestCancelToken = - rootState.cancellableSearchRequest[rootState.cancellableSearchRequest.length - 1]; - currentRequestCancelToken.cancel(); - } - + /** + * Asynchronously handles the search request for projects, incorporating search text and applied filters. + * Cancels any ongoing search request to ensure that only the latest request is processed, + * which enhances the responsiveness of search functionality. + * + * @param {Object} context - Destructured to gain access to Vuex state, rootState, and commit function. + * @param {String} text - The search text used for filtering projects. + */ + async HANDLE_PROJECTS_SEARCH_REQUEST({ state, rootState, commit }, { page, text }) { + // Cancel any ongoing search request. + cancelPreviousSearchRequest(rootState); + + // Prepare the cancel token for the new request and store it. const cancelToken = axios.CancelToken.source(); commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true }); - const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/?search=${text}`; - let filteredUrl; - if (Object.values(state.filters).some(el => el && el.length > 0)) { - filteredUrl = url; - for (const filter in state.filters) { - if (state.filters[filter]) { - filteredUrl = filteredUrl.concat('', `&${filter}=${state.filters[filter]}`); - } - } - } + // Construct the search URL with any applied filters. + // TODO: Reduce code duplication by merging into GET_PROJECTS/projectAPI.getProjects + const searchUrl = constructSearchUrl({ + baseUrl: rootState.configuration.VUE_APP_DJANGO_API_BASE, + filters: state.filters, + text, + page + }); - const response = await axios.get( - filteredUrl ? filteredUrl : url, - { - cancelToken: cancelToken.token, - } - ); - if (response.status === 200) { - const projects = response.data; - if (projects) { - commit('SET_PROJECTS', projects); - commit('SET_PROJECTS_SEARCH_STATE', { - isSearched: true, - text: text - }); + try { + // Perform the search request. + const response = await axios.get(searchUrl, { cancelToken: cancelToken.token }); + + // Process successful response. + if (response.status === 200 && response.data) { + commit('SET_PROJECTS', response.data); } + } catch (error) { + // Handle potential errors, such as request cancellation. + console.error('Search request canceled or failed', error); } - }, + } } - }; export default projectsStore; diff --git a/src/views/Projects/ProjectsList.vue b/src/views/Projects/ProjectsList.vue index 618350fe496bd26015e23694615e888e54453b9c..9682c5814278b5510053cdce53a4b3f51560ea51 100644 --- a/src/views/Projects/ProjectsList.vue +++ b/src/views/Projects/ProjectsList.vue @@ -145,12 +145,12 @@ export default { 'SET_PROJECTS_FILTER' ]), ...mapActions('projects', [ - 'GET_PROJECTS' + 'FILTER_PROJECTS' ]), getData(page) { this.loading = true; - this.GET_PROJECTS({ page }) + this.FILTER_PROJECTS({ page }) .then(() => { this.loading = false; })