import axios from '@/axios-client.js'; import router from '../../router'; import { objIsEmpty, findXformValue, activateFieldsNforceValues } from'@/utils'; const feature = { namespaced: true, state: { attachmentFormset: [], attachmentsToDelete: [], checkedFeatures: [], clickedFeatures: [], extra_forms: [], features: [], features_count: 0, currentFeature: null, form: null, linkedFormset: [], //* used to edit in feature_edit linked_features: [], //* used to display in feature_detail massMode: 'edit-status', }, mutations: { SET_FEATURES(state, features) { state.features = features.sort((a, b) => { return new Date(b.created_on) - new Date(a.created_on); // sort features chronologically }); }, SET_FEATURES_COUNT(state, features_count) { state.features_count = features_count; }, SET_CURRENT_FEATURE(state, feature) { state.currentFeature = feature; }, UPDATE_FORM(state, payload) { state.form = payload; }, INIT_FORM(state) { state.form = { title: state.currentFeature.properties.title, description: { value: state.currentFeature.properties.description }, status: { value: state.currentFeature.properties.status }, assigned_member: { value: state.currentFeature.properties.assigned_member }, }; }, UPDATE_FORM_FIELD(state, field) { if (state.form[field.name].value !== undefined) { state.form[field.name].value = field.value; } else { state.form[field.name] = field.value; } }, UPDATE_EXTRA_FORM(state, extra_form) { const updatedExtraForms = state.extra_forms.map((field) => field.name === extra_form.name ? extra_form : field); state.extra_forms = activateFieldsNforceValues(updatedExtraForms); }, SET_EXTRA_FORMS(state, extra_forms) { state.extra_forms = extra_forms; }, CLEAR_EXTRA_FORM(state) { state.extra_forms = []; }, ADD_ATTACHMENT_FORM(state, attachmentFormset) { state.attachmentFormset = [...state.attachmentFormset, attachmentFormset]; }, UPDATE_ATTACHMENT_FORM(state, payload) { const index = state.attachmentFormset.findIndex((el) => el.dataKey === payload.dataKey); if (index !== -1) { state.attachmentFormset[index] = payload; } }, REMOVE_ATTACHMENT_FORM(state, payload) { state.attachmentFormset = state.attachmentFormset.filter(form => form.dataKey !== payload); }, CLEAR_ATTACHMENT_FORM(state) { state.attachmentFormset = []; }, ADD_LINKED_FORM(state, linkedFormset) { state.linkedFormset = [...state.linkedFormset, linkedFormset]; }, UPDATE_LINKED_FORM(state, payload) { const index = state.linkedFormset.findIndex((el) => el.dataKey === payload.dataKey); if (index !== -1) { state.linkedFormset[index] = payload; } }, REMOVE_LINKED_FORM(state, payload) { state.linkedFormset = state.linkedFormset.filter(form => form.dataKey !== payload); }, SET_LINKED_FEATURES(state, payload) { state.linked_features = payload; }, CLEAR_LINKED_FORM(state) { state.linkedFormset = []; }, ADD_ATTACHMENT_TO_DELETE(state, attachementId) { state.attachmentsToDelete.push(attachementId); }, REMOVE_ATTACHMENTS_ID_TO_DELETE(state, attachementId) { state.attachmentsToDelete = state.attachmentsToDelete.filter(el => el !== attachementId); }, UPDATE_CHECKED_FEATURES(state, checkedFeatures) { state.checkedFeatures = checkedFeatures; }, UPDATE_CLICKED_FEATURES(state, clickedFeatures) { state.clickedFeatures = clickedFeatures; }, TOGGLE_MASS_MODE(state, payload) { state.massMode = payload; }, }, actions: { async GET_PROJECT_FEATURES({ commit, dispatch, rootState }, { project_slug, feature_type__slug, ordering, search, limit, geojson = false }) { dispatch('CANCEL_CURRENT_SEARCH_REQUEST', null, { root: true }); const cancelToken = axios.CancelToken.source(); commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true }); commit('SET_FEATURES', []); commit('SET_FEATURES_COUNT', 0); let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/features/?project__slug=${project_slug}`; if (feature_type__slug) { url = url.concat('', `${url.includes('?') ? '&' : '?'}feature_type__slug=${feature_type__slug}`); } if (ordering) { url = url.concat('', `${url.includes('?') ? '&' : '?'}ordering=${ordering}`); } if (search) { url = url.concat('', `${url.includes('?') ? '&' : '?'}title__icontains=${search}`); } if (limit) { url = url.concat('', `${url.includes('?') ? '&' : '?'}limit=${limit}`); } if (geojson) { url = url.concat('', '&output=geojson'); } try { const response = await axios.get(url, { cancelToken: cancelToken.token }); if (response.status === 200 && response.data) { const features = response.data.features; commit('SET_FEATURES', features); const features_count = response.data.count; commit('SET_FEATURES_COUNT', features_count); } return response; } catch (error) { if (error.message) { console.error(error); } throw error; // 'throw' instead of 'return', in order to pass inside the 'catch' error instead of 'then', to avoid initiating map in another component after navigation } }, GET_PROJECT_FEATURE({ commit, dispatch, rootState }, { project_slug, feature_id }) { dispatch('CANCEL_CURRENT_SEARCH_REQUEST', null, { root: true }); const cancelToken = axios.CancelToken.source(); commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true }); const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/features/${feature_id}/?project__slug=${project_slug}`; return axios .get(url, { cancelToken: cancelToken.token }) .then((response) => { if (response.status === 200 && response.data) { commit('SET_CURRENT_FEATURE', response.data); } return response; }) .catch((error) => { console.error('Error while getting feature for id = ', feature_id, error); throw error; }); }, /** * Handles the entire lifecycle of a feature submission, from sending data to handling additional forms * and managing redirections based on the operation performed (create or update). * @param {Object} context - Vuex action context, including state and dispatch functions. * @param {Object} payload - Contains parameters like routeName, query, and extraForms for form handling. */ SEND_FEATURE({ state, rootState, rootGetters, commit, dispatch }, { routeName, query, extraForms }) { /** * Handles redirection after a feature operation, updating URL queries or navigating to new routes. * @param {string} featureName - The name of the feature being handled. * @param {Object} response - The server response object. * @return {Object} - Either the server response or a string to trigger page reload. */ function redirect(featureName, response) { // when modifying more than 2 features, exit this function (to avoid conflict with next feature call to GET_PROJECT_FEATURE) if (routeName === 'editer-attribut-signalement') return response; let newQuery = { ...query }; // create a copy of query from the route to avoid redundant navigation error since the router object would be modified // Display a success message in the UI. commit( 'DISPLAY_MESSAGE', { comment: routeName === 'ajouter-signalement' ? 'Le signalement a été crée' : `Le signalement ${featureName} a été mis à jour`, level: 'positive' }, { root: true }, ); // Construct the query for navigation based on the current state and feature details. const slug_type_signal = rootState['feature-type'].current_feature_type_slug; const project = rootState.projects.project; if (routeName === 'ajouter-signalement' && !query.ordering) { newQuery = { ordering: project.feature_browsing_default_sort, offset: 0,// if feature was just created, in both ordering it would be the first in project features list }; if (project.feature_browsing_default_filter === 'feature_type_slug') { newQuery['feature_type_slug'] = slug_type_signal; } } if (query && query.ordering === '-updated_on') { // if the list is ordered by update time newQuery.offset = 0;// it would be in first position (else, if ordered by creation, the position won't change anyway) } // in fast edition avoid redundant navigation if query didn't change if (routeName === 'details-signalement-filtre' && parseInt(query.offset) === parseInt(newQuery.offset)) { return 'reloadPage'; } // Perform the actual route navigation if needed. if (!objIsEmpty(newQuery)) { router.push({ name: 'details-signalement-filtre', params: { slug_type_signal }, query: newQuery, }); } else { router.push({ name: 'details-signalement', params: { slug_type_signal }, }); } return response; } /** * Manages the uploading of attachments and linked features after the main feature submission. * @param {number} featureId - The ID of the feature to which attachments and linked features relate. * @param {string} featureName - The name of the feature for messaging purposes. * @param {Object} response - The server response from the main feature submission. * @return {Object} - Redirect response or a string to trigger page reload. */ async function handleOtherForms(featureId, featureName, response) { await dispatch('SEND_ATTACHMENTS', featureId); await dispatch('PUT_LINKED_FEATURES', featureId); return redirect(featureName, response); } /** * Prepares a GeoJSON object from the current state and extra forms provided in the payload. * @return {Object} - A GeoJSON object representing the feature with additional properties. */ function createGeojson() { //* prepare feature data to send const extraFormObject = {}; //* prepare an object to be flatten in properties of geojson // use extraForms from argument if defined, overiding data from the store, in order to not use mutation (in case of multiple features) for (const field of extraForms || state.extra_forms) { // send extra form only if there is a value defined or if no value, if there was a value before, in order to avoid sending empty value when user didn't touch the extraform if (field.value !== null || (state.currentFeature.properties && state.currentFeature.properties[field.name])) { extraFormObject[field.name] = field.value; } } let geojson = { id: state.form.feature_id || state.currentFeature.id, type: 'Feature', properties: { title: state.form.title, description: state.form.description.value, status: state.form.status.value.value || state.form.status.value, project: rootState.projects.project.slug, feature_type: rootState['feature-type'].current_feature_type_slug, // if is undefined using null to allow sending empty field in order to delete previous assigned_member assigned_member: state.form.assigned_member.value ? state.form.assigned_member.value : null, ...extraFormObject } }; // if not in the case of a non geographical feature type, add geometry to geojson, else send without geometry if (rootGetters['feature-type/feature_type'].geom_type !== 'none') { geojson['geometry'] = state.form.geometry || state.form.geom || state.currentFeature.geometry || state.currentFeature.properties.geom; } return geojson; } const geojson = createGeojson(); // Construct the GeoJSON from current state. let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/features/`; if (routeName !== 'ajouter-signalement') { url += `${geojson.id}/? feature_type__slug=${rootState['feature-type'].current_feature_type_slug} &project__slug=${rootState.projects.project.slug}`; } //* postOrPutFeature function from service featureAPI could be used here, but because configuration is in store, //* projectBase would need to be sent with each function which imply to modify all function from this service, //* which could create regression return axios({ url, method: routeName === 'ajouter-signalement' ? 'POST' : 'PUT', data: geojson }).then((response) => { if ((response.status === 200 || response.status === 201) && response.data) { const featureId = response.data.id; const featureName = response.data.properties.title; // Handle additional forms if needed. if (state.attachmentFormset.length > 0 || state.linkedFormset.length > 0 || state.attachmentsToDelete.length > 0) { return handleOtherForms(featureId, featureName, response); } else { return redirect(featureName, response); } } }).catch((error) => { // If offline, store the edited feature in localeStorage to send them when back online if (error.message === 'Network Error' || !rootState.isOnline) { let arraysOffline = []; const localStorageArray = localStorage.getItem('geocontrib_offline'); if (localStorageArray) { arraysOffline = JSON.parse(localStorageArray); } const updateMsg = { project: rootState.projects.project.slug, type: routeName === 'ajouter-signalement' ? 'post' : 'put', featureId: geojson.id, geojson: geojson }; arraysOffline.push(updateMsg); localStorage.setItem('geocontrib_offline', JSON.stringify(arraysOffline)); router.push({ name: 'offline-signalement', params: { slug_type_signal: rootState['feature-type'].current_feature_type_slug }, }); } else { console.error('Error while sending feature', error); throw error; // Re-throw the error for further handling. } throw error; // Ensure any error is thrown to be handled by calling code. }); }, async SEND_ATTACHMENTS({ state, rootState, dispatch }, featureId) { const DJANGO_API_BASE = rootState.configuration.VUE_APP_DJANGO_API_BASE; /** * Adds a file to an existing attachment by uploading it to the server. * @param {Object} attachment - The attachment object containing the file and other details. * @param {number} attchmtId - The ID of the attachment to which the file is being added. * @return {Promise<Object>} - The server's response to the file upload. */ function addFileToRequest(attachment, attchmtId) { const formdata = new FormData(); formdata.append('file', attachment.fileToImport, attachment.fileToImport.name); return axios .put(`${DJANGO_API_BASE}features/${featureId}/attachments/${attchmtId}/upload-file/`, formdata) .then((response) => { return response; }) .catch((error) => { console.error(error); return error; }); } /** * Handles creating or updating an attachment, optionally uploading a file if included. * @param {Object} attachment - The attachment data, including title, info, and optional file. * @return {Promise<Object>} - The server response, either from creating/updating the attachment or from file upload. */ function putOrPostAttachement(attachment) { const formdata = new FormData(); formdata.append('title', attachment.title); formdata.append('info', attachment.info); formdata.append('is_key_document', attachment.is_key_document); let url = `${DJANGO_API_BASE}features/${featureId}/attachments/`; if (attachment.id) { url += `${attachment.id}/`; } return axios({ url, method: attachment.id ? 'PUT' : 'POST', data: formdata }).then((response) => { if (response && (response.status === 200 || response.status === 201) && attachment.fileToImport) { return addFileToRequest(attachment, response.data.id); } return response; }).catch((error) => { console.error(error); return error; }); } /** * Deletes attachments by dispatching a Vuex action. * @param {number[]} attachmentsId - The IDs of the attachments to be deleted. * @param {number} featureId - The ID of the feature related to the attachments. * @return {Promise<Object>} - The server response to the deletion request. */ function deleteAttachement(attachmentsId, featureId) { const payload = { attachmentsId: attachmentsId, featureId: featureId }; return dispatch('DELETE_ATTACHMENTS', payload) .then((response) => response); } const promisesResult = await Promise.all([ ...state.attachmentFormset.map((attachment) => putOrPostAttachement(attachment)), ...state.attachmentsToDelete.map((attachmentsId) => deleteAttachement(attachmentsId, featureId)) ]); state.attachmentsToDelete = []; return promisesResult; }, DELETE_ATTACHMENTS({ commit }, payload) { const url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}features/${payload.featureId}/attachments/${payload.attachmentsId}/`; return axios .delete(url) .then((response) => { if (response && response.status === 204) { commit('REMOVE_ATTACHMENTS_ID_TO_DELETE', payload.attachmentsId); return response; } }) .catch((error) => { console.error(error); return error; }); }, PUT_LINKED_FEATURES({ state, rootState }, featureId) { return axios .put(`${rootState.configuration.VUE_APP_DJANGO_API_BASE}features/${featureId}/feature-links/`, state.linkedFormset) .then((response) => { if (response.status === 200 && response.data) { return 'La relation a bien été ajouté'; } }) .catch((error) => { throw error; }); }, DELETE_FEATURE({ rootState }, payload) { const { feature_id, noFeatureType } = payload; let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/features/${feature_id}/?` + `project__slug=${rootState.projects.project.slug}`; if (!noFeatureType) { url +=`&feature_type__slug=${rootState['feature-type'].current_feature_type_slug}`; } return axios .delete(url) .then((response) => response) .catch(() => { return false; }); }, /** * Initializes extra forms based on the current feature type and its custom fields. * This function retrieves custom fields for the current feature type, assigns values to them based on the current feature's properties, * and commits them to the store to be displayed in the form. * * @param {Object} context - The Vuex action context, including state, rootGetters, and commit function. */ INIT_EXTRA_FORMS({ state, rootGetters, commit }) { const feature = state.currentFeature; // Current feature being edited or viewed. const featureType = rootGetters['feature-type/feature_type']; // Retrieves the feature type from root getters. const customFields = featureType.customfield_set; // Custom fields defined for the feature type. if (customFields) { commit('SET_EXTRA_FORMS', activateFieldsNforceValues( // A hypothetical function to activate fields and enforce their values. customFields.map((field) => { // Determines the initial value for the field let value = feature.properties ? feature.properties[field.name] : findXformValue(feature, field); // If the field is a boolean and the value is null, sets it to false if (field.field_type === 'boolean' && value === null) { value = false; } // Returns a new object with the updated value and the rest of the field's properties return { ...field, value }; }) ).sort((a, b) => a.position - b.position) // Sorts fields by their user-defined position. ); } }, }, }; export default feature;