Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • geocontrib/geocontrib-frontend
  • ext_matthieu/geocontrib-frontend
  • fnecas/geocontrib-frontend
  • MatthieuE/geocontrib-frontend
4 results
Show changes
Showing
with 6187 additions and 577 deletions
import axios from '@/axios-client.js'; import axios from '@/axios-client.js';
import Vue from 'vue';
// axios.defaults.headers.common['X-CSRFToken'] = (name => { const getColorsStyles = (customForms) => customForms
// var re = new RegExp(name + "=([^;]+)"); .filter(customForm => customForm.options && customForm.options.length)
// var value = re.exec(document.cookie); .map(el => {
// return (value !== null) ? unescape(value[1]) : null;
// })('csrftoken');
const getColorsStyles = (customForms) => customForms.filter(customForm => customForm.options && customForm.options.length).map(el => {
//* in dropdown, value is the name and name is the label to be displayed, could be changed... //* in dropdown, value is the name and name is the label to be displayed, could be changed...
return { value: el.name, name: el.label, options: el.options } return { value: el.name, name: el.label, options: el.options };
}); });
const pending2draftFeatures = (features) => {
for (const el of features) {
if (el.properties && el.properties.status === 'pending') {
el.properties.status = 'draft';
} else if (el.status === 'pending') {
el.status = 'draft';
}
}
return features;
};
const feature_type = { const feature_type = {
namespaced: true, namespaced: true,
state: { state: {
form: null, form: null,
colorsStyleList: [], colorsStyleList: [],
...@@ -22,6 +31,14 @@ const feature_type = { ...@@ -22,6 +31,14 @@ const feature_type = {
feature_types: [], feature_types: [],
fileToImport: null, fileToImport: null,
importFeatureTypeData: [], importFeatureTypeData: [],
preRecordedLists: [],
selectedPrerecordedListValues: {}
},
getters: {
feature_type: state => state.feature_types.find(
(el) => el.slug === state.current_feature_type_slug
),
}, },
mutations: { mutations: {
...@@ -69,19 +86,21 @@ const feature_type = { ...@@ -69,19 +86,21 @@ const feature_type = {
SET_FILE_TO_IMPORT(state, payload) { SET_FILE_TO_IMPORT(state, payload) {
state.fileToImport = payload; state.fileToImport = payload;
}, },
SET_PRERECORDED_LISTS(state, payload) {
state.preRecordedLists = payload;
},
SET_SELECTED_PRERECORDED_LIST_VALUES(state, { name, values }) {
Vue.set(state.selectedPrerecordedListValues, name, values.slice(0, 10).map(el => { return { label: el };}));
}
}, },
getters: {
feature_type: state => state.feature_types.find(
(el) => el.slug === state.current_feature_type_slug
),
},
actions: { actions: {
GET_PROJECT_FEATURE_TYPES({ commit }, project_slug) { GET_PROJECT_FEATURE_TYPES({ commit }, project_slug) {
return axios return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${project_slug}/feature-types/`) .get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/feature-types/?project__slug=${project_slug}`)
.then((response) => { .then((response) => {
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
commit("SET_FEATURE_TYPES", response.data.feature_types) commit('SET_FEATURE_TYPES', response.data);
return response; return response;
} }
}) })
...@@ -90,63 +109,75 @@ const feature_type = { ...@@ -90,63 +109,75 @@ const feature_type = {
}); });
}, },
async SEND_FEATURE_TYPE({ state, getters, rootGetters }, requestType) { async GET_PRERECORDED_LISTS({ commit }) {
const data = { try {
'title': state.form.title.value, const response = await axios.get(
'title_optional': state.form.title_optional.value, `${this.state.configuration.VUE_APP_DJANGO_API_BASE}prerecorded-list-values/`
'geom_type': state.form.geom_type.value, );
'color': state.form.color.value, if (response.status === 200) {
'colors_style': state.form.colors_style.value, commit('SET_PRERECORDED_LISTS', response.data.map(el => el.name));
'project': rootGetters.project.slug, }
'customfield_set': state.customForms.map(el => { } catch (err) {
return { console.error(err);
'position': el.position,
'label': el.label,
'name': el.name,
'field_type': el.field_type,
'options': el.options,
}
}),
//'is_editable': true,
} }
},
if (requestType === "post") { async GET_SELECTED_PRERECORDED_LIST_VALUES({ commit }, { name, pattern }) {
return axios try {
.post(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}feature-types/`, data) let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}prerecorded-list-values/${name}/`;
.then((response) => { if (pattern) {
if (response) { url += `?pattern=${pattern}`;
const feature_type_slug = response.data.slug; }
const status = response.status; const response = await axios.get(url);
return { feature_type_slug, status }; if (response.status === 200) {
} commit('SET_SELECTED_PRERECORDED_LIST_VALUES', {
}) name,
.catch((error) => { values: response.data
throw (error);
});
} else if (requestType === "put") {
return axios
.put(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}feature-types/${getters.feature_type.slug}/`, data)
.then((response) => {
if (response) {
const feature_type_slug = response.data.slug;
const status = response.status;
return { feature_type_slug, status };
}
})
.catch((error) => {
throw (error);
}); });
}
} catch (err) {
console.error(err);
} }
return;
},
async SEND_FEATURE_TYPE({ state, getters, rootState }, requestType) {
const data = {
title: state.form.title.value,
title_optional: state.form.title_optional.value,
enable_key_doc_notif: state.form.enable_key_doc_notif.value,
disable_notification: state.form.disable_notification.value,
geom_type: state.form.geom_type.value,
color: state.form.color.value,
colors_style: state.form.colors_style.value,
project: rootState.projects.project.slug,
customfield_set: state.customForms
};
let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/feature-types/`;
if (requestType === 'put') url += `${getters.feature_type.slug}/`;
return axios({
url,
method: requestType,
data,
}).then((response) => {
if (response) {
const feature_type_slug = response.data.slug;
const status = response.status;
return { feature_type_slug, status };
}
})
.catch((error) => error.response);
}, },
async SEND_FEATURE_SYMBOLOGY({ getters, rootGetters }, symbology) { async SEND_FEATURE_DISPLAY_CONFIG({ getters, rootState }, displayConfig) {
const data = { const data = {
title: getters.feature_type.title, title: getters.feature_type.title,
project: rootGetters.project.slug, project: rootState.projects.project.slug,
...symbology ...displayConfig
}; };
return axios return axios
.put(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}feature-types/${getters.feature_type.slug}/`, data) .put(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/feature-types/${getters.feature_type.slug}/`, data)
.then((response) => { .then((response) => {
if (response) { if (response) {
const feature_type_slug = response.data.slug; const feature_type_slug = response.data.slug;
...@@ -159,51 +190,130 @@ const feature_type = { ...@@ -159,51 +190,130 @@ const feature_type = {
}); });
}, },
SEND_FEATURES_FROM_GEOJSON({ state, dispatch }, payload) { async SEND_FEATURES_FROM_GEOJSON({ state, dispatch, rootState }, payload) {
const { feature_type_slug } = payload let { feature_type_slug, geojson } = payload;
//* check if geojson then build a file
if (state.fileToImport.size > 0) { if(!geojson && !state.fileToImport && state.fileToImport.size === 0 ) {
let formData = new FormData(); return;
formData.append("json_file", state.fileToImport);
formData.append("feature_type_slug", feature_type_slug);
let url =
this.state.configuration.VUE_APP_DJANGO_API_BASE +
'import-tasks/'
return axios
.post(url, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
if (response && response.status === 200) {
dispatch("GET_IMPORTS", feature_type_slug);
}
return response
})
.catch((error) => {
throw (error);
});
} }
}, const formData = new FormData();
let { name, type } = geojson || state.fileToImport;
if (!name && state.fileToImport) {
name = state.fileToImport.name;
}
if (rootState.projects.project.moderation) {
if (state.fileToImport && state.fileToImport.size > 0) { //* if data in a binary file, read it as text
const textFile = await state.fileToImport.text();
geojson = JSON.parse(textFile);
}
const unmoderatedFeatures = pending2draftFeatures(geojson.features || geojson);
geojson = geojson.features ? {
type: 'FeatureCollection', features: unmoderatedFeatures
} : unmoderatedFeatures;
}
const fileToImport = new File([JSON.stringify(geojson)], name, { type });
GET_IMPORTS({ commit }, feature_type) { formData.append('json_file', geojson ? fileToImport : state.fileToImport);
let url = formData.append('feature_type_slug', feature_type_slug);
const url =
this.state.configuration.VUE_APP_DJANGO_API_BASE + this.state.configuration.VUE_APP_DJANGO_API_BASE +
"import-tasks?feature_type_slug=" + 'v2/import-tasks/';
feature_type; return axios
axios .post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => {
if (response && response.status === 200) {
return dispatch('GET_IMPORTS', {
feature_type: feature_type_slug
});
}
return response;
})
.catch((error) => {
throw (error);
});
},
async SEND_FEATURES_FROM_CSV({ state, dispatch }, payload) {
const { feature_type_slug, csv } = payload;
if (!csv && !state.fileToImport && state.fileToImport.size === 0 ) {
return;
}
const formData = new FormData();
formData.append('csv_file', state.fileToImport);
formData.append('feature_type_slug', feature_type_slug);
const url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/import-tasks/`;
return axios
.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => {
if (response && response.status === 200) {
return dispatch('GET_IMPORTS', {
feature_type: feature_type_slug
});
}
return response;
})
.catch((error) => {
throw (error);
});
},
GET_IMPORTS({ state, commit, dispatch }, { project_slug, feature_type }) {
let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/import-tasks/`;
if (project_slug) {
url = url.concat('', `${url.includes('?') ? '&' : '?'}project_slug=${project_slug}`);
}
if (feature_type) {
url = url.concat('', `${url.includes('?') ? '&' : '?'}feature_type_slug=${feature_type}`);
}
return axios
.get(url) .get(url)
.then((response) => { .then((response) => {
if (response) { if (response) {
commit("SET_IMPORT_FEATURE_TYPES_DATA", response.data); const diffStatus = [];
if (state.importFeatureTypeData) {
for (const data of response.data) {
const index =
state.importFeatureTypeData
.findIndex(el => el.geojson_file_name === data.geojson_file_name);
if (index !== -1 && state.importFeatureTypeData[index].status !== data.status && data.status === 'finished') {
diffStatus.push(data);
}
}
}
if (diffStatus.length > 0 && project_slug && feature_type) {
try {
dispatch(
'feature/GET_PROJECT_FEATURES',
{
project_slug: project_slug,
feature_type__slug: feature_type,
},
{ root: true }
);
} catch (err) {
console.error(err);
}
}
commit('SET_IMPORT_FEATURE_TYPES_DATA', response.data);
} }
return response;
}) })
.catch((error) => { .catch((error) => {
throw (error); throw (error);
}); });
} }
} }
} };
export default feature_type export default feature_type;
\ No newline at end of file
import axios from '@/axios-client.js';
import router from '../../router'
// axios.defaults.headers.common['X-CSRFToken'] = (name => {
// var re = new RegExp(name + "=([^;]+)");
// var value = re.exec(document.cookie);
// return (value !== null) ? unescape(value[1]) : null;
// })('csrftoken');
const feature = {
namespaced: true,
state: {
attachmentFormset: [],
attachmentsToDelete: [],
checkedFeatures: [],
extra_form: [],
features: [],
form: null,
linkedFormset: [],
linked_features: [],
statusChoices: [
{
name: "Brouillon",
value: "draft",
},
{
name: "En attente de publication",
value: "pending",
},
{
name: "Publié",
value: "published",
},
{
name: "Archivé",
value: "archived",
},
],
},
mutations: {
SET_FEATURES(state, features) {
state.features = features;
},
UPDATE_FORM(state, payload) {
state.form = payload;
},
UPDATE_EXTRA_FORM(state, extra_form) {
const index = state.extra_form.findIndex(el => el.label === extra_form.label);
if (index !== -1) {
state.extra_form[index] = extra_form;
}
},
SET_EXTRA_FORM(state, extra_form) {
state.extra_form = extra_form;
},
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.attachmentFormset = state.attachmentFormset.filter(el => el.id !== 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;
}
},
getters: {
},
actions: {
GET_PROJECT_FEATURES({ commit, rootState }, { project_slug, feature_type__slug, search, limit }) {
if (rootState.cancellableSearchRequest.length > 0) {
const currentRequestCancelToken =
rootState.cancellableSearchRequest[rootState.cancellableSearchRequest.length - 1];
currentRequestCancelToken.cancel();
}
const cancelToken = axios.CancelToken.source();
commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true });
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}projects/${project_slug}/feature/`;
if (feature_type__slug) {
url = url.concat('', `${url.includes('?') ? '&' : '?'}feature_type__slug=${feature_type__slug}`);
}
if (search) {
url = url.concat('', `${url.includes('?') ? '&' : '?'}title__contains=${search}`);
}
if (limit) {
url =url.concat('', `${url.includes('?') ? '&' : '?'}limit=${limit}`);
}
return axios
.get(url , { cancelToken: cancelToken.token })
.then((response) => {
if (response.status === 200 && response.data) {
const features = response.data.features;
commit("SET_FEATURES", features);
//dispatch("map/ADD_FEATURES", null, { root: true }); //todo: should check if map was initiated
}
return response;
})
.catch((error) => {
throw error;
});
},
SEND_FEATURE({ state, rootState, commit, dispatch }, routeName) {
commit("DISPLAY_LOADER", "Le signalement est en cours de création", { root: true })
const message = routeName === "editer-signalement" ? "Le signalement a été mis à jour" : "Le signalement a été crée";
function redirect(featureId) {
dispatch(
'GET_PROJECT_FEATURES',
{
project_slug: rootState.project_slug,
feature_type__slug: rootState.feature_type.current_feature_type_slug
}
)
.then(() => {
commit("DISCARD_LOADER", null, { root: true })
router.push({
name: "details-signalement",
params: {
slug_type_signal: rootState.feature_type.current_feature_type_slug,
slug_signal: featureId,
message,
},
});
});
}
async function handleOtherForms(featureId) {
await dispatch("SEND_ATTACHMENTS", featureId)
await dispatch("PUT_LINKED_FEATURES", featureId)
redirect(featureId);
}
//* prepare feature data to send
let extraFormObject = {}; //* prepare an object to be flatten in properties of geojson
for (const field of state.extra_form) {
extraFormObject[field.name] = field.value;
}
const geojson = {
"id": state.form.feature_id,
"type": "Feature",
"geometry": state.form.geometry,
"properties": {
"title": state.form.title,
"description": state.form.description.value,
"status": state.form.status.value,
"project": rootState.project_slug,
"feature_type": rootState.feature_type.current_feature_type_slug,
...extraFormObject
}
}
if (routeName === "editer-signalement") {
return axios
.put(`${rootState.configuration.VUE_APP_DJANGO_API_BASE}features/${state.form.feature_id}/?` +
`feature_type__slug=${rootState.feature_type.current_feature_type_slug}` +
`&project__slug=${rootState.project_slug}`
, geojson)
.then((response) => {
if (response.status === 200 && response.data) {
if (state.attachmentFormset.length > 0 || state.linkedFormset.length > 0) {
handleOtherForms(response.data.id)
} else {
redirect(response.data.id)
}
}
})
.catch((error) => {
commit("DISCARD_LOADER", null, { root: true })
if (error.message === "Network Error" || window.navigator.onLine === false) {
let arraysOffline = [];
let localStorageArray = localStorage.getItem("geocontrib_offline");
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
}
let updateMsg = {
project: rootState.project_slug,
type: 'put',
featureId: state.form.feature_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.log(error)
throw error;
}
throw error;
});
} else {
return axios
.post(`${rootState.configuration.VUE_APP_DJANGO_API_BASE}features/`, geojson)
.then((response) => {
if (response.status === 201 && response.data) {
if (state.attachmentFormset.length > 0 || state.linkedFormset.length > 0) {
handleOtherForms(response.data.id)
} else {
redirect(response.data.id)
}
}
})
.catch((error) => {
commit("DISCARD_LOADER", null, { root: true })
if (error.message === "Network Error" || window.navigator.onLine === false) {
let arraysOffline = [];
let localStorageArray = localStorage.getItem("geocontrib_offline");
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
}
let updateMsg = {
project: rootState.project_slug,
type: 'post',
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.log(error)
throw error;
}
});
}
},
async SEND_ATTACHMENTS({ state, rootState, dispatch }, featureId) {
const DJANGO_API_BASE = rootState.configuration.VUE_APP_DJANGO_API_BASE;
function addFile(attachment, attchmtId) {
let 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) => {
console.log(response)
if (response && response.status === 200) {
console.log(response.status)
}
return response;
})
.catch((error) => {
console.error(error);
return error
});
}
function putOrPostAttachement(attachment) {
let formdata = new FormData();
formdata.append("title", attachment.title);
formdata.append("info", attachment.info);
if (!attachment.id) { //* used to check if doesn't exist in DB and should be send through post (useless now)
return axios
.post(`${DJANGO_API_BASE}features/${featureId}/attachments/`, formdata)
.then((response) => {
console.log(response)
if (response && response.status === 201 && attachment.fileToImport) {
console.log(response.status)
return addFile(attachment, response.data.id);
}
return response
})
.catch((error) => {
console.error(error);
return error
});
} else {
return axios
.put(`${DJANGO_API_BASE}features/${featureId}/attachments/${attachment.id}/`, formdata)
.then((response) => {
console.log(response)
if (response && response.status === 200 && attachment.fileToImport) {
console.log(response.status)
return addFile(attachment, response.data.id);
}
})
.catch((error) => {
console.error(error);
return error
});
}
}
function deleteAttachement(attachmentsId, featureId) {
let 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) {
let 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) {
console.log(response)
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) {
console.log(response, response.data)
return "La relation a bien été ajouté"
}
})
.catch((error) => {
throw error;
});
},
DELETE_FEATURE({ state, rootState }, feature_id) {
console.log("Deleting feature:", feature_id, state)
const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}features/${feature_id}/?` +
`feature_type__slug=${rootState.feature_type.current_feature_type_slug}` +
`&project__slug=${rootState.project_slug}`;
return axios
.delete(url)
.then((response) => response)
.catch(() => {
return false;
});
},
},
}
export default feature
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,
assigned_member: state.form.assigned_member.value,
...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;
/**
* Automatically imports all the modules and exports as a single module object
**/
const requireModule = require.context('.', false, /\.store\.js$/);
const modules = {};
requireModule.keys().forEach(filename => {
// create the module name from fileName
// remove the store.js extension
const moduleName = filename.replace(/(\.\/|\.store\.js)/g, '');
modules[moduleName] = requireModule(filename).default || requireModule(filename);
});
export default modules;
\ No newline at end of file
import axios from '@/axios-client.js'; import axios from '@/axios-client.js';
import { mapUtil } from "@/assets/js/map-util.js"; import mapService from '@/services/map-service';
// axios.defaults.headers.common['X-CSRFToken'] = (name => {
// var re = new RegExp(name + "=([^;]+)");
// var value = re.exec(document.cookie);
// return (value !== null) ? unescape(value[1]) : null;
// })('csrftoken');
const map = { const map = {
namespaced: true, namespaced: true,
state: { state: {
map: null,
basemaps: null, basemaps: null,
basemapsToDelete: [], basemapsToDelete: [],
features: [], features: [],
...@@ -18,6 +13,9 @@ const map = { ...@@ -18,6 +13,9 @@ const map = {
availableLayers: null, availableLayers: null,
}, },
mutations: { mutations: {
SET_MAP(state, payload) {
state.map = payload;
},
SET_LAYERS(state, availableLayers) { SET_LAYERS(state, availableLayers) {
state.availableLayers = availableLayers; state.availableLayers = availableLayers;
}, },
...@@ -28,7 +26,7 @@ const map = { ...@@ -28,7 +26,7 @@ const map = {
state.basemaps = basemaps; state.basemaps = basemaps;
}, },
CREATE_BASEMAP(state, id) { CREATE_BASEMAP(state, id) {
state.basemaps = [...state.basemaps, { id, title: '', layers: [], errors: [] }] state.basemaps = [...state.basemaps, { id, title: '', layers: [], errors: [] }];
}, },
UPDATE_BASEMAPS(state, basemaps) { UPDATE_BASEMAPS(state, basemaps) {
state.basemaps = basemaps; state.basemaps = basemaps;
...@@ -36,14 +34,10 @@ const map = { ...@@ -36,14 +34,10 @@ const map = {
UPDATE_BASEMAP(state, { title, id, layers, errors }) { UPDATE_BASEMAP(state, { title, id, layers, errors }) {
const index = state.basemaps.findIndex((el) => el.id === id); const index = state.basemaps.findIndex((el) => el.id === id);
if (index !== -1) { if (index !== -1) {
if (title) { state.basemaps[index].title = title;
state.basemaps[index].title = title state.basemaps[index].errors = errors;
}
if (layers) { if (layers) {
state.basemaps[index].layers = layers state.basemaps[index].layers = layers;
}
if (errors) {
state.basemaps[index].errors = errors
} }
} }
}, },
...@@ -89,8 +83,8 @@ const map = { ...@@ -89,8 +83,8 @@ const map = {
actions: { actions: {
GET_AVAILABLE_LAYERS({ commit }) { GET_AVAILABLE_LAYERS({ commit }) {
return axios return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}layers/`) .get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/layers/`)
.then((response) => (commit("SET_LAYERS", response.data))) .then((response) => (commit('SET_LAYERS', response.data)))
.catch((error) => { .catch((error) => {
throw error; throw error;
}); });
...@@ -98,115 +92,91 @@ const map = { ...@@ -98,115 +92,91 @@ const map = {
GET_BASEMAPS({ commit }, project_slug) { GET_BASEMAPS({ commit }, project_slug) {
return axios return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}base-maps/?project__slug=${project_slug}`) .get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/base-maps/?project__slug=${project_slug}`)
.then((response) => { .then((response) => {
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
commit("SET_BASEMAPS", response.data) commit('SET_BASEMAPS', response.data);
} }
return response return response;
}) })
.catch((error) => { .catch((error) => {
throw error; throw error;
}); });
}, },
INITIATE_MAP({ state, rootGetters }, el) { INITIATE_MAP({ commit, rootState }, options) {
const project = rootGetters.project var mapDefaultViewCenter =
let mapDefaultViewCenter = [46, 2]; // defaultMapView.center; this.state.configuration.DEFAULT_MAP_VIEW.center;
let mapDefaultViewZoom = 5; // defaultMapView.zoom; var mapDefaultViewZoom =
mapUtil.createMap(el, { this.state.configuration.DEFAULT_MAP_VIEW.zoom;
mapDefaultViewCenter, mapService.createMap(options.el, {
mapDefaultViewZoom, mapDefaultViewZoom: options.zoom || mapDefaultViewZoom || 5,
mapDefaultViewCenter: options.center || mapDefaultViewCenter || [46.0, 2.0],
maxZoom: options.maxZoom || rootState.projects.project.map_max_zoom_level,
controls: options.controls,
zoomControl: options.zoomControl,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true , ...options.interactions },
geolocationControl: true,
}); });
const map = { ...mapService.getMap() };
// Load the layers. commit('SET_MAP', map);
// - if one basemap exists, check in the localstorage if one active basemap is set
// - if no current active basemap, get the first index
// - if not, load the default map and service options
// todo : create endpoints to get : 'baseMaps' ,'layers' ,'serviceMap' ,'optionsMap'
let layersToLoad = null;
if (state.baseMaps && state.baseMaps.length > 0) {
// Use active one if exists, otherwise index 0 (first basemap in the list)
const mapOptions =
JSON.parse(localStorage.getItem("geocontrib-map-options")) || {};
const basemapIndex =
mapOptions &&
mapOptions[project] &&
mapOptions[project]["current-basemap-index"]
? mapOptions[project]["current-basemap-index"]
: 0;
layersToLoad = state.baseMaps[basemapIndex].layers;
layersToLoad.forEach((layerToLoad) => {
state.availableLayers.forEach((layer) => {
if (layer.id === layerToLoad.id) {
layerToLoad = Object.assign(layerToLoad, layer);
}
});
});
layersToLoad.reverse();
}
mapUtil.addLayers(layersToLoad, this.state.configuration.DEFAULT_BASE_MAP.SERVICE, this.state.configuration.DEFAULT_BASE_MAP.OPTIONS);
// Remove multiple interactions with the map
//mapUtil.getMap().dragging.disable();
mapUtil.getMap().doubleClickZoom.disable();
mapUtil.getMap().scrollWheelZoom.disable();
}, },
async SAVE_BASEMAPS({ state, rootState, dispatch }, newBasemapIds) { async SAVE_BASEMAPS({ state, rootState, dispatch }, newBasemapIds) {
const DJANGO_API_BASE = this.state.configuration.VUE_APP_DJANGO_API_BASE; //* send new basemaps synchronously to create their ids in the order they were created in the form
function postOrPut(basemap) { let promisesResult = [];
basemap["project"] = rootState.project_slug
if (newBasemapIds.includes(basemap.id)) { function postOrPut(basemapsToSend) {
return axios if (basemapsToSend.length > 0) { //* execute the function recursively as long as there is still a basemap to send
.post(`${DJANGO_API_BASE}base-maps/`, basemap) let basemap = basemapsToSend.shift(); //* remove and return first item in array
.then((response) => response) basemap['project'] = rootState.projects.project.slug;
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/base-maps/`;
if (!newBasemapIds.includes(basemap.id)) url += `${basemap.id}/`;
axios({
url,
method: newBasemapIds.includes(basemap.id) ? 'POST' : 'PUT',
data: basemap,
})
.then((response) => {
postOrPut(basemapsToSend);
promisesResult.push(response);
})
.catch((error) => { .catch((error) => {
console.error(error); postOrPut(basemapsToSend);
return error promisesResult.push(error);
});
} else {
return axios
.put(`${DJANGO_API_BASE}base-maps/${basemap.id}/`, basemap)
.then((response) => response)
.catch((error) => {
console.error(error);
return error
}); });
} }
} }
function deleteBMap(basemapId) { function deleteBMap(basemapId) {
//* delete in the backend the basemaps that was rewoved from the front //* delete in the backend the basemaps that was rewoved from the front
return dispatch("DELETE_BASEMAP", basemapId) return dispatch('DELETE_BASEMAP', basemapId)
.then((response) => response); .then((response) => response);
} }
//* save new or modifed basemap
const promisesResult = await Promise.all( postOrPut([...state.basemaps]);
[...state.basemaps.map((basemap) => postOrPut(basemap)), ...state.basemapsToDelete.map((basemapId) => deleteBMap(basemapId))] //* delete basemaps
const deletedResult = await Promise.all(state.basemapsToDelete.map((basemapId) => deleteBMap(basemapId)));
); state.basemapsToDelete = [];
state.basemapsToDelete = [] //* return promises results
return promisesResult return [...promisesResult, ...deletedResult];
}, },
DELETE_BASEMAP({ commit }, basemapId) { DELETE_BASEMAP({ commit, rootState }, basemapId) {
let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}base-maps/` + basemapId const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/base-maps/${basemapId}/`;
return axios return axios
.delete(url) .delete(url)
.then((response) => { .then((response) => {
if (response && response.status === 204) { if (response && response.status === 204) {
commit("REMOVE_BASEMAP_ID_TO_DELETE", basemapId) commit('REMOVE_BASEMAP_ID_TO_DELETE', basemapId);
return response return response;
} }
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
return error return error;
}); });
} }
}, },
} };
export default map export default map;
\ No newline at end of file
const modals = {
namespaced: true,
state: {
isProjectModalOpen: false,
projectModalType: null
},
mutations: {
OPEN_PROJECT_MODAL(state, payload) {
state.isProjectModalOpen = true;
state.projectModalType = payload;
},
CLOSE_PROJECT_MODAL(state) {
state.isProjectModalOpen = false;
state.projectModalType = null;
}
},
actions: {
}
};
export default modals;
import axios from '@/axios-client.js';
import projectAPI from '@/services/project-api';
const initialFilters = {
moderation: null,
access_level: null,
user_access_level: null,
accessible: null
};
/**
* 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,
state: {
count: 0,
currentPage: 1,
filters: { ...initialFilters },
isProjectsListSearched: null,
last_comments: [],
projects: [],
project: null,
projectUsers: [],
searchProjectsFilter: null,
},
mutations: {
SET_CURRENT_PAGE (state, payload) {
state.currentPage = payload;
},
SET_PROJECTS(state, projects) {
if (projects.results) {
state.projects = projects.results;
state.count = projects.count;
} else {
state.projects = projects;
state.count = projects.length;
}
},
ADD_PROJECT(state, project) {
state.projects = [project, ...state.projects];
},
SET_PROJECT(state, project) {
state.project = project;
},
SET_PROJECT_USERS(state, users) {
state.projectUsers = users;
},
SET_PROJECTS_FILTER(state, payload) {
state.filters[payload.filter] = payload.value;
},
SET_PROJECTS_SEARCH_STATE(state, payload) {
state.isProjectsListSearched = payload.isSearched;
state.searchProjectsFilter = payload.text;
},
SET_PROJECT_COMMENTS(state, last_comments) {
state.last_comments = last_comments;
},
},
actions: {
async GET_PROJECTS({ state, rootState, commit }, payload) {
let { page, myaccount, projectSlug } = payload || {};
if (!page) {
page = state.currentPage;
}
const baseUrl = rootState.configuration.VUE_APP_DJANGO_API_BASE;
const projects = await projectAPI.getProjects({
baseUrl,
filters : state.filters,
page,
projectSlug,
myaccount,
text: state.searchProjectsFilter
});
commit('SET_PROJECTS', projects);
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 GET_PROJECT({ rootState, commit }, slug) { // todo : use GET_PROJECTS instead, with slug
const baseUrl = rootState.configuration.VUE_APP_DJANGO_API_BASE;
const project = await projectAPI.getProject(baseUrl, slug);
commit('SET_PROJECT', project);
return project;
},
async GET_PROJECT_USERS({ rootState, commit }, projectSlug) {
const baseUrl = rootState.configuration.VUE_APP_DJANGO_API_BASE;
const users = await projectAPI.getProjectUsers(baseUrl, projectSlug);
commit('SET_PROJECT_USERS', users);
return users;
},
async GET_PROJECT_INFO({ dispatch }, slug) {
const promises = [
dispatch('GET_PROJECT_LAST_MESSAGES', slug).then(response => response),
dispatch('feature-type/GET_PROJECT_FEATURE_TYPES', slug, { root: true }).then(response => response),
dispatch('map/GET_BASEMAPS', slug, { root: true }).then(response => response)
];
const promiseResult = await Promise.all(promises);
return promiseResult;
},
GET_PROJECT_LAST_MESSAGES({ commit }, project_slug) {
return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${project_slug}/comments/`)
.then((response) => {
if (response && response.status === 200) {
commit('SET_PROJECT_COMMENTS', response.data.last_comments);
}
return response;
})
.catch((error) => {
throw error;
});
},
/**
* 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.
if (rootState.cancellableSearchRequest.length > 0) {
const currentRequestCancelToken =
rootState.cancellableSearchRequest[rootState.cancellableSearchRequest.length - 1];
currentRequestCancelToken.cancel();
}
// Prepare the cancel token for the new request and store it.
const cancelToken = axios.CancelToken.source();
commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true });
// Construct the search URL with any applied filters.
const searchUrl = constructSearchUrl({
baseUrl: rootState.configuration.VUE_APP_DJANGO_API_BASE,
filters: state.filters,
text,
page
});
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);
commit('SET_PROJECTS_SEARCH_STATE', {
isSearched: true,
text: text
});
}
} catch (error) {
// Handle potential errors, such as request cancellation.
console.error('Search request canceled or failed', error);
}
}
}
};
export default projectsStore;
import featureAPI from '@/services/feature-api';
import { isEqual, isNil } from 'lodash';
export function formatStringDate(stringDate) {
const date = new Date(stringDate);
if (date instanceof Date && !isNaN(date.valueOf())) {
const formatted_date = date.getFullYear() + '/' + ('0' + (date.getMonth() + 1)).slice(-2) + '/' + ('0' + date.getDate()).slice(-2) + ' ' +
('0' + date.getHours()).slice(-2) + ':' + ('0' + date.getMinutes()).slice(-2);
return formatted_date;
}
return stringDate;
}
export const statusChoices = [
{
name: 'Brouillon',
value: 'draft',
},
{
name: 'En attente de publication',
value: 'pending',
},
{
name: 'Publié',
value: 'published',
},
{
name: 'Archivé',
value: 'archived',
},
];
export function allowedStatus2change(user, isModerate, userStatus, isOwnFeature, currentRouteName) {
if ( //* si 'super-admin'(superuser) admin, modérateur ou super contributeur, statuts toujours disponibles: Brouillon, Publié, Archivé
user.is_superuser ||
userStatus === 'Modérateur' ||
userStatus === 'Administrateur projet' ||
(userStatus === 'Super Contributeur' && !isModerate)
) {
return statusChoices.filter((el) => el.value !== 'pending');
} else if (userStatus === 'Super Contributeur' && isModerate) {
return statusChoices.filter(
(el) => el.value === 'draft' || el.value === 'pending'
);
} else if (userStatus === 'Contributeur') { //* cas particuliers du contributeur
if (currentRouteName === 'ajouter-signalement' || !isOwnFeature) {
//* même cas à l'ajout d'une feature ou si feature n'a pas été créé par le contributeur
//? Incohérence / Inutile ? Un contributeur ne peut pas modifier un signalement d'un autre utilisateur
return isModerate
? statusChoices.filter(
(el) => el.value === 'draft' || el.value === 'pending'
)
: statusChoices.filter(
(el) => el.value === 'draft' || el.value === 'published'
);
} else {
//* à l'édition d'une feature et si le contributeur est l'auteur de la feature
return isModerate
? statusChoices.filter(
(el) => el.value !== 'published' //* toutes sauf "Publié"
)
: statusChoices.filter(
(el) => el.value !== 'pending' //* toutes sauf "En cours de publication"
);
}
}
return [];
}
/**
* Determines the type of a property based on its value.
*
* This function inspects a given property and returns a string indicating its type,
* such as 'boolean', 'integer', 'decimal', 'date', 'text', or 'char'.
* It uses various checks to determine the appropriate type for different value formats.
*
* @param {any} prop - The property value to be evaluated.
* @returns {string} The determined type of the property ('boolean', 'integer', 'decimal', 'date', 'text', or 'char').
*/
export function transformProperties(prop) {
const type = typeof prop;
const regInteger = /^-?\d+$/; // Regular expression to match integer numbers
const regFloat = /^-?\d*\.\d+$/; // Regular expression to match decimal numbers
const regText = /[\r\n]/; // Regular expression to detect multiline text (newlines)
const regDate = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$/; // Regular expression to match common date formats
// Check if the property is a boolean or a string that represents a boolean
if (type === 'boolean' || (type === 'string' && (prop.toLowerCase() === 'true' || prop.toLowerCase() === 'false'))) {
return 'boolean';
} else if (regInteger.test(prop) || (type === 'number' && Number.isSafeInteger(prop))) {
// Check if the property is an integer or a string that represents an integer
return 'integer';
} else if (type === 'string' && regDate.test(prop.trim())) {
// More specific check for date strings using regular expressions
return 'date';
} else if (regFloat.test(prop) || (type === 'number' && !Number.isSafeInteger(prop))) {
// Check if the property is a decimal number or a string that represents a decimal
return 'decimal';
} else if (regText.test(prop) || (type === 'string' && prop.length > 255)) {
// Check if the property contains newline characters or is a long text
return 'text';
}
// Default case for all other types: assume it is a short text or character field
return 'char';
}
export function objIsEmpty(obj) {
for(const prop in obj) {
if(Object.hasOwn(obj, prop)) {
return false;
}
}
return true;
}
export const reservedKeywords = [
// todo : add keywords for mapstyle (strokewidth...)
'id',
'title',
'description',
'status',
'created_on',
'updated_on',
'archived_on',
'deletion_on',
'feature_type',
'feature_id',
'display_creator',
'display_last_editor',
'project',
'creator',
'lat',
'lon'
];
export const customFieldTypeChoices = [
{ name: 'Booléen', value: 'boolean' },
{ name: 'Chaîne de caractères', value: 'char' },
{ name: 'Date', value: 'date' },
{ name: 'Liste de valeurs', value: 'list' },
{ name: 'Liste de valeurs pré-enregistrées', value: 'pre_recorded_list' },
{ name: 'Liste à choix multiples', value: 'multi_choices_list' },
{ name: 'Nombre entier', value: 'integer' },
{ name: 'Nombre décimal', value: 'decimal' },
{ name: 'Texte multiligne', value: 'text' },
{ name: 'Notification à un groupe', value: 'notif_group' },
];
export const featureNativeFields = [
{ name: 'status', label: 'Statut', field_type: 'Champ GéoContrib' },
{ name: 'feature_type', label: 'Type', field_type: 'Champ GéoContrib' },
{ name: 'updated_on', label: 'Dernière mise à jour', field_type: 'Champ GéoContrib' },
{ name: 'created_on', label: 'Date de création', field_type: 'Champ GéoContrib' },
{ name: 'display_creator', label: 'Auteur', field_type: 'Champ GéoContrib' },
{ name: 'display_last_editor', label: 'Dernier éditeur', field_type: 'Champ GéoContrib' },
];
export const formatDate = (current_datetime) => {
let formatted_date = current_datetime.getFullYear() + '-' + ('0' + (current_datetime.getMonth() + 1)).slice(-2) + '-' + ('0' + current_datetime.getDate()).slice(-2) + '&nbsp;' +
('0' + current_datetime.getHours()).slice(-2) + ':' + ('0' + current_datetime.getMinutes()).slice(-2);
return formatted_date;
};
export const retrieveFeatureProperties = async (feature, featureTypes, projectSlug) => {
const properties = feature.getProperties();
let { feature_type, status, updated_on, created_on, creator, display_last_editor, index } = properties;
if (creator) {
creator = creator.full_name ? `${creator.first_name} ${creator.last_name}` : creator.username;
} else if (properties.feature_id) {
//* if *** MVT *** feature, retrieve display_creator and display_last_editor by fetching the feature details from API
const fetchedFeature = await featureAPI.getProjectFeature(projectSlug, properties.feature_id);
if (fetchedFeature) {
creator = fetchedFeature.properties.display_creator;
display_last_editor = fetchedFeature.properties.display_last_editor;
feature_type = fetchedFeature.properties.feature_type;
}
}
if (featureTypes && feature_type) {
feature_type = featureTypes.find((el) => el.slug === (feature_type.slug || feature_type));
}
if (updated_on && !isNaN(new Date(updated_on))) { //* check if date is already formatted
updated_on = formatDate(new Date(updated_on));
}
if (created_on && !isNaN(new Date(created_on))) { //* check if date is already formatted
created_on = formatDate(new Date(created_on));
}
if (status) {
if (status.label) { //* when the label is already in the feature
status = status.label;
} else if (featureTypes) { //* if not, retrieve the name/label from the list
status = statusChoices.find((el) => el.value === status).name;
}
}
return { feature_type, status, updated_on, created_on, creator, display_last_editor, index };
};
export function findXformValue(feature, customField) {
if (!feature) return null;
if (feature.properties) {
return feature.properties[customField.name] || null;
} else if (feature.feature_data) {
const field = feature.feature_data.find((el) => el.label === customField.label);
return field ? field.value : null;
}
return null;
}
export function isXtraFormActive(extraForms, config) { // return true if no config or if the condition is fullfilled
if (config) { // if conditional field configuration is not null
// get name and value in condition
const { conditionField, conditionValue } = config;
// get the customForm which activates conditional field
const conditioningXForm = extraForms.find((xForm) => xForm.name === conditionField);
// check if the conditioning extraform value match the condition value
if (conditioningXForm) {
// if the values to compare are null or undefined the field can't be activated
if (isNil(conditioningXForm.value) || isNil(conditionValue)) {
return false;
} else if (Array.isArray(conditionValue) && Array.isArray(conditioningXForm.value)) { // case of multiple list or prerecorded values list
return conditioningXForm.value.some((value) => conditionValue.includes(value));
} else if (typeof conditioningXForm.value === 'object' && conditioningXForm.value.label) { // case of simple list
return conditioningXForm.value.label === conditionValue.label;
} else {
return conditioningXForm.value === conditionValue; // more simple case of other fields
}
}
}
return true;
}
export function checkDeactivatedValues(extraForms) {
// if changes occured, update extraForms array with freshly checked active customForms
let newExtraForms = extraForms.map((xForm) => { // we use 'deactivate' instead of 'activate' because at initialization this property cannot be evaluated ...
const isDeactivated = !isXtraFormActive(extraForms, xForm.conditional_field_config); // ... if the component is not created to set this property, thus no extra form would appear at all
// toggle value to null to deactivate other fields conditioned by it
if (isDeactivated) {
xForm['value'] = null;
}
return { ...xForm, ['isDeactivated']: isDeactivated };
});
return newExtraForms;
}
export function checkFieldForcedValue(field, extraForms) {
field['disabled'] = false; //* create a property disabled and (re)set to false by default
if (field.forced_value_config) {
//* loop over each forced value config for this extraForm
for (const config of field.forced_value_config) {
//* find the extraForm field conditioning the forced value
const conditioningField = extraForms.find((xtraForm) => xtraForm.name === config.conditionField);
//* if found check that its value match the condtionValue
if (conditioningField && isEqual(conditioningField.value, config.conditionValue)) {
//* set this value with the forced value and disable the form field
field.value = config.forcedValue;
field.disabled = true;
}
}
}
return field;
}
export function activateFieldsNforceValues(extraForms) {
for (const [index, field] of extraForms.entries()) {
const checkedField = checkFieldForcedValue(field, extraForms);
//* each time a value changes, call this function recursively, until there is no more change
if (checkedField.value !== field.value) {
extraForms[index] = checkedField; //* update the value in extraForms
activateFieldsNforceValues(extraForms); //* call the function with new extraForms
}
}
//* when no more changes detected in the loop, check for deactivated extraForms
extraForms = checkDeactivatedValues(extraForms);
//* return extraForms from the lastly called function
return extraForms;
}
export function formatUserOption(user) {
let name = user.first_name || '';
if (user.last_name) {
name = name + ' ' + user.last_name;
}
return {
name: [name, user.username],
value: user.id,
};
}
\ No newline at end of file
<template>
<div id="account">
<h1>Mon compte</h1>
<div class="ui stackable grid">
<div class="five wide column">
<UserProfile />
<UserActivity />
</div>
<div class="eleven wide column">
<UserProjectsList />
</div>
</div>
</div>
</template>
<script>
import UserProfile from '@/components/Account/UserProfile.vue';
import UserProjectsList from '@/components/Account/UserProjectsList.vue';
import UserActivity from '@/components/Account/UserActivity.vue';
export default {
name: 'Account',
components: {
UserProfile,
UserProjectsList,
UserActivity
},
};
</script>
<style lang="less" scoped>
#account {
max-width: 1000px;
}
</style>
\ No newline at end of file
<template>
<div
v-if="(configuration.VUE_APP_SSO_LOGIN_URL_WITH_REDIRECT && !user) || !currentFeature"
class="no-access"
>
<h3>
🔒&nbsp;Vous n'avez pas accès à ce signalement
<span v-if="!user"> en tant qu'utilisateur anonyme&nbsp;🥸</span>
</h3>
<p v-if="!user">
Veuillez vous connectez afin de pouvoir visualiser le document
</p>
</div>
<div
v-else
:class="['preview', { is_pdf }]"
>
<embed
v-if="is_pdf"
:src="src"
type="application/pdf"
>
<div v-else>
<img
:src="src"
alt="Aperçu de l'image"
>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'AttachmentPreview',
computed: {
...mapState([
'configuration',
'user'
]),
...mapState('feature', [
'currentFeature'
]),
src() {
return this.$route.query.file;
},
is_pdf() {
return this.src && this.src.includes('pdf');
},
},
watch: {
user() {
/**
* Specific for platform with login by token
* When the user is setted, fetching again the feature with the cookies setted
* since automatic authentification can take time to return the response
* setting the cookies, while the app is loading already
*/
this.getFeature();
}
},
mounted() {
this.getFeature();
},
methods: {
getFeature() {
console.log('getFeature'); // Keeping for debugging after deployment
this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
project_slug: this.$route.params.slug,
feature_id: this.$route.params.slug_signal,
});
}
}
};
</script>
<style scoped lang="less">
.no-access {
> h1 {
margin: 2em 0;
}
height: 60vh;
display: flex;
justify-content: center;
flex-direction: column;
> * {
text-align: center;
}
}
.preview {
width: 100vw;
&.is_pdf {
padding: 0;
@media screen and (min-width: 726px) {
height: calc(100vh - 70px - 1em);
margin: .5em auto;
box-shadow: 1px 2px 10px grey;
}
@media screen and (max-width: 725px) {
height: calc(100vh - 110px);
margin: 0 auto;
}
}
> * {
height: 100%;
width: 100%;
}
> div {
display: flex;
justify-content: center;
img {
max-width: 100%;
}
}
}
</style>
\ No newline at end of file
<template>
<div class="to-left">
<h1 v-if="project">
Créer un nouveau type de signalement pour le projet «
{{ project.title }} » depuis le catalogue Datasud
</h1>
Liste des ressources géographiques publiées par vos organisations :
<div class="table">
<div class="row header">
<div>Organisation</div>
<div>Dataset</div>
<div>Ressource</div>
</div>
<div v-if="resources && resources.length > 0">
<div
v-for="(resource, index) in paginatedResources"
:key="`${resource.resource_name}-${index}`"
:class="[
'row',
{
selected:
selectedResource && resource.layer === selectedResource.layer,
},
]"
@click="selectResource(resource)"
>
<div>{{ resource.organization_name }}</div>
<div>{{ resource.dataset_name }}</div>
<div>{{ resource.resource_name }}</div>
</div>
</div>
<div
v-else
class="no-response"
>
Pas de données trouvées pour l'utilisateur {{ user.username }}
</div>
</div>
<div class="pagination_wrapper">
<div
v-if="nbPages.length > 1"
id="table-features_info"
class="dataTables_info"
>
Affichage de l'élément {{ pagination.start + 1 }} à
{{ displayedPageEnd }}
sur {{ resources.length }} éléments
</div>
<div
v-if="nbPages.length > 1"
id="table-features_paginate"
class="dataTables_paginate paging_simple_numbers"
>
<a
id="table-features_previous"
:class="[
'paginate_button previous',
{ disabled: pagination.currentPage === 1 },
]"
@click="toPreviousPage"
>Précédent</a>
<span>
<a
v-for="pageNumber in nbPages"
:key="'page' + pageNumber"
:class="[
'paginate_button',
{ current: pageNumber === pagination.currentPage },
]"
@click="toPage(pageNumber)"
>{{ pageNumber }}</a>
</span>
<!-- // TODO : <span v-if="nbPages > 4" class="ellipsis">...</span> -->
<a
id="table-features_next"
:class="[
'paginate_button next',
{ disabled: pagination.currentPage === nbPages.length },
]"
@click="toNextPage"
>Suivant</a>
</div>
</div>
<div class="import">
<button
:disabled="!selectedResource"
class="ui fluid teal icon button"
@click="launchImport"
>
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
<span v-if="selectedResource">
{{ selectedResource.resource }}
</span>
</button>
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from 'vuex';
import miscAPI from '@/services/misc-api';
export default {
name: 'Catalog',
data() {
return {
resources: [],
pagination: {
currentPage: 1,
pagesize: 15,
start: 0,
end: 15,
},
selectedResource: null,
};
},
computed: {
...mapState(['user']),
...mapGetters('projects', ['permissions']),
...mapState('projects', ['project']),
paginatedResources() {
return this.resources.slice(this.pagination.start, this.pagination.end);
},
nbPages() {
const N = Math.ceil(this.resources.length / this.pagination.pagesize);
const arr = [...Array(N).keys()].map(function (x) {
++x;
return x;
});
return arr;
},
displayedPageEnd() {
return this.resources.length <= this.pagination.end
? this.resources.length
: this.pagination.end;
},
},
mounted() {
this.$store.commit('DISPLAY_LOADER', 'Interrogation du catologue datasud.');
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug);
miscAPI.getIdgoCatalog(this.user.username).then((data) => {
if (data && data.layers) {
this.resources = data.layers;
}
this.$store.commit('DISCARD_LOADER');
});
},
methods: {
selectResource(resource) {
this.selectedResource = resource;
},
toPage(pageNumber) {
const toAddOrRemove =
(pageNumber - this.pagination.currentPage) * this.pagination.pagesize;
this.pagination.start += toAddOrRemove;
this.pagination.end += toAddOrRemove;
this.pagination.currentPage = pageNumber;
},
toPreviousPage() {
if (this.pagination.start > 0) {
this.pagination.start -= this.pagination.pagesize;
this.pagination.end -= this.pagination.pagesize;
this.pagination.currentPage -= 1;
}
},
toNextPage() {
if (this.pagination.end < this.resources.length) {
this.pagination.start += this.pagination.pagesize;
this.pagination.end += this.pagination.pagesize;
this.pagination.currentPage += 1;
}
},
redirect(geojson) {
const name =
this.$route.params.feature_type_slug === 'create'
? 'ajouter-type-signalement'
: 'details-type-signalement';
this.$router.push({
name: name,
params: {
geojson,
type: 'external-geojson',
},
});
},
launchImport() {
const queryParams = `typename=${this.selectedResource.layer}&organization_slug=${this.selectedResource.organization_slug}`;
miscAPI.getExternalGeojson(queryParams).then((data) => {
if (data) {
this.redirect(data);
}
});
},
},
};
</script>
<style scoped>
.to-left {
text-align: left;
}
h1 {
margin: 0.5em 0;
}
.table {
width: 100%;
border: 1px solid #c0c0c0;
margin: 2rem 0;
}
.table .row {
display: flex;
transition: all ease-out 0.2s;
}
.table .row:not(.header).selected {
background-color: #8bddd9;
}
.table .row:not(.header):hover {
background-color: #009c95;
color: #ffffff;
cursor: pointer;
}
.table .row:not(:last-child) {
border-bottom: 1px solid #cacaca;
}
.table .row > div {
width: 100%;
padding: 0.5rem;
}
.table .header {
background-color: #e0e0e0;
}
.no-response {
padding: 1rem;
text-align: center;
color: #585858;
}
.import {
display: flex;
align-items: center;
margin-top: 1em;
}
/* datatables */
.pagination_wrapper {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.pagination_wrapper > div {
margin: 0.25em 0;
}
@media only screen and (max-width: 767px) {
.pagination_wrapper {
justify-content: center;
}
}
.dataTables_length,
.dataTables_filter,
.dataTables_info,
.dataTables_processing,
.dataTables_paginate {
color: #333;
}
/* .dataTables_info {
clear: both;
float: left;
padding-top: 0.755em;
} */
/* .dataTables_paginate {
float: right;
text-align: right;
padding-top: 0.25em;
} */
.dataTables_paginate .paginate_button.current,
.dataTables_paginate .paginate_button.current:hover {
color: #333 !important;
border: 1px solid #979797;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #fff),
color-stop(100%, #dcdcdc)
);
background: -webkit-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -moz-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -ms-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -o-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: linear-gradient(to bottom, #fff 0%, #dcdcdc 100%);
}
.dataTables_paginate .paginate_button {
box-sizing: border-box;
display: inline-block;
min-width: 1.5em;
padding: 0.5em 1em;
margin-left: 2px;
text-align: center;
text-decoration: none !important;
cursor: pointer;
color: #333 !important;
border: 1px solid transparent;
border-radius: 2px;
}
.dataTables_paginate .paginate_button:hover {
color: white !important;
border: 1px solid #111;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #585858),
color-stop(100%, #111)
);
background: -webkit-linear-gradient(top, #585858 0%, #111 100%);
background: -moz-linear-gradient(top, #585858 0%, #111 100%);
background: -ms-linear-gradient(top, #585858 0%, #111 100%);
background: -o-linear-gradient(top, #585858 0%, #111 100%);
background: linear-gradient(to bottom, #585858 0%, #111 100%);
}
.dataTables_paginate .paginate_button.disabled,
.dataTables_paginate .paginate_button.disabled:hover,
.dataTables_paginate .paginate_button.disabled:active {
cursor: default;
color: #666 !important;
border: 1px solid transparent;
background: transparent;
box-shadow: none;
}
</style>
\ No newline at end of file
<template>
<div id="feature-detail">
<div
v-if="currentFeature"
class="ui grid stackable"
>
<div class="row">
<div class="sixteen wide column">
<FeatureHeader
v-if="project"
:features-count="featuresCount"
:slug-signal="slugSignal"
:feature-type="feature_type"
:fast-edition-mode="project.fast_edition_mode"
:is-feature-creator="isFeatureCreator"
:can-edit-feature="canEditFeature"
:can-delete-feature="canDeleteFeature"
@fastEditFeature="validateFastEdition"
@setIsDeleting="isDeleting = true"
@tofeature="pushNgo"
/>
</div>
</div>
<div class="row">
<div class="eight wide column">
<FeatureTable
v-if="project"
ref="featureTable"
:feature-type="feature_type"
:fast-edition-mode="project.fast_edition_mode"
:can-edit-feature="canEditFeature"
/>
</div>
<div
v-if="feature_type && feature_type.geom_type !== 'none'"
class="eight wide column"
>
<div class="map-container">
<div
id="map"
ref="map"
>
<SidebarLayers
v-if="basemaps && map"
ref="sidebar"
/>
<Geolocation />
</div>
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
</div>
</div>
<div class="row">
<div class="eight wide column">
<FeatureAttachements
:attachments="attachments"
/>
</div>
<div class="eight wide column">
<FeatureComments
:events="events"
:enable-key-doc-notif="feature_type.enable_key_doc_notif"
@fetchEvents="getFeatureEvents"
/>
</div>
</div>
<div
v-if="isDeleting"
class="ui dimmer modals visible active"
>
<div
:class="[
'ui mini modal',
{ 'active visible': isDeleting },
]"
>
<i
class="close icon"
aria-hidden="true"
@click="isDeleting = false"
/>
<div
v-if="isDeleting"
class="ui icon header"
>
<i
class="trash alternate icon"
aria-hidden="true"
/>
Supprimer le signalement
</div>
<div class="actions">
<button
type="button"
class="ui red compact fluid button"
@click="deleteFeature"
>
Confirmer la suppression
</button>
</div>
</div>
</div>
<div
v-if="isLeaving"
class="ui dimmer modals visible active"
>
<div
:class="[
'ui mini modal',
{ 'active visible': isLeaving },
]"
>
<i
class="close icon"
aria-hidden="true"
@click="isLeaving = false"
/>
<div class="ui icon header">
<i
class="sign-out icon"
aria-hidden="true"
/>
Abandonner les modifications
</div>
<div class="content">
Les modifications apportées au signalement ne seront pas sauvegardées, continuer ?
</div>
<div class="actions">
<button
type="button"
class="ui green compact button"
@click="stayOnPage"
>
<i
class="close icon"
aria-hidden="true"
/>
Annuler
</button>
<button
type="button"
class="ui red compact button"
@click="leavePage"
>
Continuer
<i
class="arrow right icon"
aria-hidden="true"
/>
</button>
</div>
</div>
</div>
</div>
<div v-else>
Pas de signalement correspondant trouvé
</div>
</div>
</template>
<script>
import { isEqual } from 'lodash';
import { mapState, mapActions, mapMutations, mapGetters } from 'vuex';
import mapService from '@/services/map-service';
import axios from '@/axios-client.js';
import featureAPI from '@/services/feature-api';
import FeatureHeader from '@/components/Feature/Detail/FeatureHeader';
import FeatureTable from '@/components/Feature/Detail/FeatureTable';
import FeatureAttachements from '@/components/Feature/Detail/FeatureAttachements';
import FeatureComments from '@/components/Feature/Detail/FeatureComments';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
import { buffer } from 'ol/extent';
export default {
name: 'FeatureDetail',
components: {
FeatureHeader,
FeatureTable,
FeatureAttachements,
FeatureComments,
SidebarLayers,
Geolocation,
},
beforeRouteUpdate (to, from, next) {
if (this.hasUnsavedChange && !this.isSavingChanges) {
this.confirmLeave(next); // prompt user that there is unsaved changes or that features order might change
} else {
next(); // continue navigation
}
},
beforeRouteLeave (to, from, next) {
if (this.hasUnsavedChange && !this.isSavingChanges) {
this.confirmLeave(next); // prompt user that there is unsaved changes or that features order might change
} else {
next(); // continue navigation
}
},
data() {
return {
attachments: [],
comment_form: {
attachment_file: {
errors: null,
title: '',
file: null,
},
comment: {
id_for_label: 'add-comment',
html_name: 'add-comment',
errors: '',
value: null,
},
},
events: [],
featuresCount: null,
isDeleting: false,
isLeaving: false,
isSavingChanges: false,
map: null,
slug: this.$route.params.slug,
slugSignal: '',
};
},
computed: {
...mapState([
'USER_LEVEL_PROJECTS',
'user'
]),
...mapState('projects', [
'project'
]),
...mapState('feature-type', [
'feature_types',
]),
...mapState('feature', [
'currentFeature',
'form',
]),
...mapGetters('feature-type', [
'feature_type',
]),
...mapGetters([
'permissions',
]),
...mapState('map', [
'basemaps',
]),
/**
* Checks if there are any unsaved changes in the form compared to the current feature's properties.
* This function is useful for prompting the user before they navigate away from a page with unsaved changes.
*
* @returns {boolean} - Returns true if there are unsaved changes; otherwise, returns false.
*/
hasUnsavedChange() {
// Ensure we are in edition mode and all required objects are present.
if (this.project && this.project.fast_edition_mode &&
this.form && this.currentFeature && this.currentFeature.properties) {
// Check for changes in title, description, and status.
if (this.form.title !== this.currentFeature.properties.title) return true;
if (this.form.description.value !== this.currentFeature.properties.description) return true;
if (this.form.status.value !== this.currentFeature.properties.status) return true;
if (this.form.assigned_member.value !== this.currentFeature.properties.assigned_member) return true;
// Iterate over extra forms to check for any changes.
for (const xForm of this.$store.state.feature.extra_forms) {
const originalValue = this.currentFeature.properties[xForm.name];
// Check if the form value has changed, considering edge cases for undefined, null, or empty values.
if (
!isEqual(xForm.value, originalValue) && // Check if values have changed.
!(!xForm.value && !originalValue) // Ensure both aren't undefined/null/empty, treating null as equivalent to false for unactivated conditionals or unset booleans.
) {
// Log the difference for debugging purposes.
console.log(`In custom form [${xForm.name}], the current form value [${xForm.value}] differs from original value [${originalValue}]`);
return true;
}
}
}
// If none of the above conditions are met, return false indicating no unsaved changes.
return false;
},
isFeatureCreator() {
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
isModerator() {
return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.slug] === 'Modérateur';
},
isAdministrator() {
return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.slug] === 'Administrateur projet';
},
canEditFeature() {
return (this.permissions && this.permissions.can_update_feature) ||
this.isFeatureCreator ||
this.isModerator ||
this.user.is_superuser;
},
canDeleteFeature() {
return (this.permissions && this.permissions.can_delete_feature && this.isFeatureCreator) ||
this.isFeatureCreator ||
this.isModerator ||
this.isAdministrator ||
this.user.is_superuser;
},
},
watch: {
/**
* To navigate back or forward to the previous or next URL, the query params in url are updated
* since the route doesn't change, mounted is not called, then the page isn't updated
* To reload page infos we need to call initPage() when query changes
*/
'$route.query'(newValue, oldValue) {
if (newValue !== oldValue) {
this.initPage();
}
},
},
created() {
this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
},
mounted() {
this.initPage();
},
beforeDestroy() {
this.$store.commit('CLEAR_MESSAGES');
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
},
methods: {
...mapMutations([
'DISPLAY_LOADER',
'DISCARD_LOADER'
]),
...mapMutations('feature', [
'SET_CURRENT_FEATURE'
]),
...mapMutations('feature-type', [
'SET_CURRENT_FEATURE_TYPE_SLUG'
]),
...mapActions('projects', [
'GET_PROJECT',
'GET_PROJECT_INFO'
]),
...mapActions('feature', [
'GET_PROJECT_FEATURE',
'GET_PROJECT_FEATURES'
]),
async initPage() {
await this.getPageInfo();
if(this.feature_type && this.feature_type.geom_type === 'none') {
// setting map to null to ensure map would be created when navigating next to a geographical feature
this.map = null;
} else if (this.currentFeature) {
this.initMap();
}
},
async getPageInfo() {
if (this.$route.params.slug_signal && this.$route.params.slug_type_signal) { // if coming from the route with an id
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal);
this.slugSignal = this.$route.params.slug_signal;
} //* else it would be retrieve after fetchFilteredFeature with offset
this.DISPLAY_LOADER('Recherche du signalement');
let promises = [];
//* Récupération du projet, en cas d'arrivée directe sur la page ou de refresh
if (!this.project) {
promises.push(this.GET_PROJECT(this.slug));
}
//* Récupération des types de signalement, en cas de redirection page détails signalement avec id (projet déjà récupéré) ou cas précédent
if (!this.featureType || !this.basemaps) {
promises.push(
this.GET_PROJECT_INFO(this.slug),
);
}
//* changement de requête selon s'il y a un id ou un offset(dans le cas du parcours des signalements filtrés)
if (this.$route.query.offset >= 0) {
promises.push(this.fetchFilteredFeature());
} else if (!this.currentFeature || this.currentFeature.id !== this.slugSignal) {
promises.push(
this.GET_PROJECT_FEATURE({
project_slug: this.slug,
feature_id: this.slugSignal,
})
);
}
await axios.all(promises);
this.DISCARD_LOADER();
if (this.currentFeature) {
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
if (this.project.fast_edition_mode) {
this.$store.commit('feature/INIT_FORM');
this.$store.dispatch('feature/INIT_EXTRA_FORMS');
}
}
},
confirmLeave(next) {
this.next = next;
this.isLeaving = true;
},
stayOnPage() {
this.isLeaving = false;
},
leavePage() {
this.isLeaving = false;
this.next();
},
pushNgo(newEntry) {
//* update the params or queries in the route/url
this.$router.push(newEntry)
//* catch error if navigation get aborted (in beforeRouteUpdate)
.catch(() => true);
},
goBackToProject(message) {
this.$router.push({
name: 'project_detail',
params: {
slug: this.slug,
message,
},
});
},
deleteFeature() {
this.isDeleting = false;
this.DISPLAY_LOADER('Suppression du signalement en cours...');
this.$store
.dispatch('feature/DELETE_FEATURE', { feature_id: this.currentFeature.id })
.then(async (response) => {
this.DISCARD_LOADER();
if (response.status === 200) {
this.goBackToProject({ comment: 'Le signalement a bien été supprimé', level: 'positive' });
} else {
this.$store.commit('DISPLAY_MESSAGE', { comment: 'Une erreur est survenue pendant la suppression du signalement', level: 'negative' });
}
});
},
fetchFilteredFeature() { // TODO : if no query for sort, use project default ones
const queryString = new URLSearchParams({ ...this.$route.query });
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-paginated/?limit=1&${queryString}&output=geojson`;
return featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data && data.results && data.results.features && data.results.features[0]) {
this.featuresCount = data.count;
this.previous = data.previous;
this.next = data.next;
const currentFeature = data.results.features[0];
this.slugSignal = currentFeature.id;
this.SET_CURRENT_FEATURE(currentFeature);
this.SET_CURRENT_FEATURE_TYPE_SLUG(currentFeature.properties.feature_type.slug);
return { feature_id: currentFeature.id };
}
return;
});
},
initMap() {
var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
if (this.map) {
mapService.removeFeatures();
} else {
this.map = mapService.createMap(this.$refs.map, {
mapDefaultViewCenter,
mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : {
doubleClickZoom :false,
mouseWheelZoom: true,
dragPan: true
},
fullScreenControl: true,
geolocationControl: true,
});
}
this.addFeatureToMap();
},
addFeatureToMap() {
const featureGroup = mapService.addFeatures({
project_slug: this.slug,
features: [this.currentFeature],
featureTypes: this.feature_types,
addToMap: true,
});
mapService.fitExtent(buffer(featureGroup.getExtent(),200));
},
getFeatureEvents() {
featureAPI
.getFeatureEvents(this.slugSignal)
.then((data) => (this.events = data));
},
getFeatureAttachments() {
featureAPI
.getFeatureAttachments(this.slugSignal)
.then((data) => (this.attachments = data));
},
getLinkedFeatures() {
featureAPI
.getFeatureLinks(this.slugSignal)
.then((data) =>
this.$store.commit('feature/SET_LINKED_FEATURES', data)
);
},
checkAddedForm() {
let isValid = true; //* fallback if all customForms returned true
if (this.$refs.featureTable && this.$refs.featureTable.$refs.extraForm) {
for (const extraForm of this.$refs.featureTable.$refs.extraForm) {
if (extraForm.checkForm() === false) {
isValid = false;
}
}
}
return isValid;
},
validateFastEdition() {
let is_valid = true;
is_valid = this.checkAddedForm();
if (is_valid) {
this.isSavingChanges = true; // change the value to avoid confirmation popup after redirection with new query
this.$store.dispatch(
'feature/SEND_FEATURE',
{
routeName: this.$route.name,
query: this.$route.query
}
).then((response) => {
if (response === 'reloadPage') {
// when query doesn't change we need to reload the page infos with initPage(),
// since it would not be called from the watcher'$route.query' when the query does change
this.initPage();
}
});
}
}
},
};
</script>
<style scoped>
.map-container {
height: 100%;
max-height: 70vh;
position: relative;
overflow: hidden;
background-color: #fff;
}
#map {
width: 100%;
height: 100%;
min-height: 250px;
border: 1px solid grey;
}
div.geolocation-container {
/* each button have (more or less depends on borders) .5em space between */
/* zoom buttons are 60px high, geolocation and full screen button is 34px high with borders */
top: calc(1.3em + 60px + 34px);
}
.prewrap {
white-space: pre-wrap;
}
.ui.active.dimmer {
position: fixed;
}
.ui.modal > .content {
text-align: center;
}
.ui.modal > .actions {
display: flex;
justify-content: space-evenly;
}
</style>
\ No newline at end of file
<template>
<div id="feature-edit">
<h1>
<span v-if="feature_type && isCreation">
Création d'un signalement <small>[{{ feature_type.title }}]</small>
</span>
<span v-else-if="currentFeature && currentRouteName === 'editer-signalement'">
Mise à jour du signalement "{{ currentFeature.properties ?
currentFeature.properties.title : currentFeature.id }}"
</span>
<span v-else-if="feature_type && currentRouteName === 'editer-attribut-signalement'">
Mise à jour des attributs de {{ checkedFeatures.length }} signalements
</span>
</h1>
<form
id="form-feature-edit"
enctype="multipart/form-data"
class="ui form"
>
<!-- Feature Fields -->
<div
v-if="currentRouteName !== 'editer-attribut-signalement'"
:class="[ project && project.feature_assignement ? 'three' : 'two', 'fields']"
>
<div :class="['field', {'required': !titleIsOptional}]">
<label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input
:id="form.title.id_for_label"
v-model="form.title.value"
type="text"
:required="!titleIsOptional"
:maxlength="form.title.field.max_length"
:name="form.title.html_name"
@blur="updateStore"
>
<ul
id="infoslist-title"
class="infoslist"
>
<li
v-for="info in form.title.infos"
:key="info"
>
{{ info }}
</li>
</ul>
<ul
id="errorlist-title"
class="errorlist"
>
<li
v-for="error in form.title.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label :for="form.status.id_for_label">{{
form.status.label
}}</label>
<Dropdown
:options="allowedStatusChoices"
:selected="selected_status.name"
:selection.sync="selected_status"
/>
</div>
<div
v-if="project && project.feature_assignement"
class="field"
>
<label for="assigned_member">Membre assigné</label>
<ProjectMemberSelect
:selected-user-id="form.assigned_member.value"
@update:user="setMemberAssigned($event)"
/>
</div>
</div>
<div
v-if="currentRouteName !== 'editer-attribut-signalement'"
class="field"
>
<label :for="form.description.id_for_label">{{
form.description.label
}}</label>
<textarea
v-model="form.description.value"
:name="form.description.html_name"
rows="5"
@blur="updateStore"
/>
</div>
<!-- Geom Field -->
<div
v-if="currentRouteName !== 'editer-attribut-signalement'
&& feature_type && feature_type.geom_type !== 'none'"
class="field"
>
<label :for="form.geom.id_for_label">{{ form.geom.label }}</label>
<!-- Import GeoImage -->
<div
v-if="feature_type && feature_type.geom_type === 'point'"
>
<p v-if="isOnline">
<button
id="add-geo-image"
type="button"
class="ui compact button"
@click="toggleGeoRefModal"
>
<i
class="file image icon"
aria-hidden="true"
/>Importer une image géoréférencée
</button>
Vous pouvez utiliser une image géoréférencée pour localiser le
signalement.
</p>
<div
v-if="showGeoRef"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
class="ui mini modal transition visible active"
style="display: block !important"
>
<i
class="close icon"
aria-hidden="true"
@click="toggleGeoRefModal"
/>
<div class="content">
<h3>Importer une image géoréférencée</h3>
<form
id="form-geo-image"
class="ui form"
enctype="multipart/form-data"
>
<p>
Attention, si vous avez déjà saisi une géométrie, celle
issue de l'image importée l'écrasera.
</p>
<div class="field georef-btn">
<label>Image (png ou jpeg)</label>
<label
class="ui icon button"
for="image_file"
>
<i
class="file icon"
aria-hidden="true"
/>
<span class="label">{{ geoRefFileLabel }}</span>
</label>
<input
id="image_file"
ref="file"
type="file"
accept="image/jpeg, image/png"
style="display: none"
name="image_file"
class="image_file"
@change="handleFileUpload"
>
<ul
v-if="erreurUploadMessage"
class="errorlist"
>
<li>
{{ erreurUploadMessage }}
</li>
</ul>
</div>
<button
id="get-geom-from-image-file"
type="button"
:class="[
'ui compact button',
file && !erreurUploadMessage ? 'green' : 'disabled',
{ red: erreurUploadMessage },
]"
@click="georeferencement"
>
<i
class="plus icon"
aria-hidden="true"
/>
Importer
</button>
</form>
</div>
</div>
</div>
<p v-if="showGeoPositionBtn">
<button
id="create-point-geoposition"
type="button"
class="ui compact button"
@click="create_point_geoposition"
>
<i
class="ui map marker alternate icon"
aria-hidden="true"
/>
Positionner le
signalement à partir de votre géolocalisation
</button>
</p>
<span
v-if="erreurGeolocalisationMessage"
id="erreur-geolocalisation"
>
<div class="ui negative message">
<div class="header">
Une erreur est survenue avec la fonctionnalité de
géolocalisation
</div>
<p id="erreur-geolocalisation-message">
{{ erreurGeolocalisationMessage }}
</p>
</div>
<br>
</span>
</div>
<ul
id="errorlist-geom"
class="errorlist"
>
<li
v-for="error in form.geom.errors"
:key="error"
>
{{ error }}
</li>
</ul>
<!-- Map -->
<input
:id="form.geom.id_for_label"
v-model="form.geom.value"
type="hidden"
:name="form.geom.html_name"
@blur="updateStore"
>
<div
class="ui tab active map-container"
data-tab="map"
>
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<div
id="map"
ref="map"
tabindex="0"
>
<SidebarLayers v-if="basemaps && map" />
<Geolocation />
<Geocoder />
<EditingToolbar
v-if="isEditable"
:map="map"
/>
</div>
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
</div>
<!-- Extra Fields -->
<div class="ui horizontal divider">
DONNÉES MÉTIER
</div>
<div
v-for="field in extra_forms"
:key="field.name"
class="extraform"
>
<ExtraForm
v-if="!field.isDeactivated"
:id="field.label"
ref="extraForm"
:field="field"
class="field"
/>
{{ field.errors }}
</div>
<!-- Pièces jointes -->
<div v-if="isOnline && currentRouteName !== 'editer-attribut-signalement'">
<div class="ui horizontal divider">
PIÈCES JOINTES
</div>
<div
v-if="isOnline"
id="formsets-attachment"
>
<FeatureAttachmentForm
v-for="attachForm in attachmentFormset"
:key="attachForm.dataKey"
ref="attachementForm"
:attachment-form="attachForm"
:enable-key-doc-notif="feature_type && feature_type.enable_key_doc_notif"
/>
</div>
<button
id="add-attachment"
type="button"
class="ui compact basic button"
@click="add_attachement_formset"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter une pièce jointe
</button>
</div>
<!-- Signalements liés -->
<div v-if="isOnline && currentRouteName !== 'editer-attribut-signalement'">
<div class="ui horizontal divider">
SIGNALEMENTS LIÉS
</div>
<div id="formsets-link">
<FeatureLinkedForm
v-for="linkForm in linkedFormset"
:key="linkForm.dataKey"
ref="linkedForm"
:linked-form="linkForm"
/>
</div>
<button
id="add-link"
type="button"
class="ui compact basic button"
@click="add_linked_formset"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter une liaison
</button>
</div>
<div class="ui divider" />
<button
id="save-changes"
type="button"
:class="['ui teal icon button', { loading: sendingFeature }]"
@click="onSave"
>
<i
class="white save icon"
aria-hidden="true"
/>
Enregistrer les changements
</button>
</form>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import { GeoJSON } from 'ol/format';
import FeatureAttachmentForm from '@/components/Feature/FeatureAttachmentForm';
import FeatureLinkedForm from '@/components/Feature/FeatureLinkedForm';
import ExtraForm from '@/components/ExtraForm';
import Dropdown from '@/components/Dropdown.vue';
import SidebarLayers from '@/components/Map/SidebarLayers';
import EditingToolbar from '@/components/Map/EditingToolbar';
import Geocoder from '@/components/Map/Geocoder';
import Geolocation from '@/components/Map/Geolocation';
import ProjectMemberSelect from '@/components/ProjectMemberSelect';
import featureAPI from '@/services/feature-api';
import mapService from '@/services/map-service';
import editionService from '@/services/edition-service';
import { statusChoices, allowedStatus2change } from '@/utils';
import axios from '@/axios-client.js';
export default {
name: 'FeatureEdit',
components: {
FeatureAttachmentForm,
FeatureLinkedForm,
Dropdown,
SidebarLayers,
Geocoder,
Geolocation,
EditingToolbar,
ExtraForm,
ProjectMemberSelect
},
data() {
return {
map: null,
mapLoading: false,
sendingFeature: false,
baseUrl: this.$store.state.configuration.BASE_URL,
file: null,
showGeoRef: false,
showGeoPositionBtn: true,
erreurGeolocalisationMessage: null,
erreurUploadMessage: null,
attachmentDataKey: 0,
linkedDataKey: 0,
form: {
title: {
errors: [],
infos: [],
id_for_label: 'name',
field: {
max_length: 128,
},
html_name: 'name',
label: 'Nom',
value: '',
},
status: {
id_for_label: 'status',
html_name: 'status',
label: 'Statut',
value: {
value: 'draft',
name: 'Brouillon',
},
},
assigned_member: {
value: null,
},
description: {
errors: [],
id_for_label: 'description',
html_name: 'description',
label: 'Description',
value: '',
},
geom: {
errors: [],
label: 'Localisation',
value: null,
},
},
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'isOnline'
]),
...mapState('projects', [
'project'
]),
...mapState('map', [
'basemaps'
]),
...mapState('feature', [
'attachmentFormset',
'checkedFeatures',
'currentFeature',
'extra_forms',
'features',
'linkedFormset',
]),
...mapState('feature-type', [
'feature_types'
]),
...mapGetters([
'permissions'
]),
...mapGetters('feature-type', [
'feature_type'
]),
titleIsOptional() {
return this.feature_type && this.feature_type.title_optional;
},
currentRouteName() {
return this.$route.name;
},
isCreation() {
return this.currentRouteName === 'ajouter-signalement';
},
geoRefFileLabel() {
if (this.file) {
return this.file.name;
}
return 'Sélectionner une image ...';
},
selected_status: {
get() {
return this.form.status.value;
},
set(newValue) {
this.form.status.value = newValue;
this.updateStore();
},
},
isFeatureCreator() {
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
allowedStatusChoices() {
if (this.project && this.USER_LEVEL_PROJECTS && this.user) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug];
return allowedStatus2change(this.user, isModerate, userStatus, this.isFeatureCreator, this.currentRouteName);
}
return [];
},
isEditable() {
return this.basemaps && this.map && (this.feature_type && !this.feature_type.geom_type.includes('multi'));
}
},
watch: {
'form.title.value': function(newValue) {
if (newValue && newValue.length === 128) {
this.form.title.infos.push('Le nombre de caractères et limité à 128.');
} else {
this.form.title.infos = [];
}
}
},
created() {
this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
this.$store.commit('feature/CLEAR_EXTRA_FORM');
this.$store.commit(
'feature-type/SET_CURRENT_FEATURE_TYPE_SLUG',
this.$route.params.slug_type_signal
);
//* empty previous feature data, not emptying by itself since it doesn't update by itself anymore
if (this.currentRouteName === 'ajouter-signalement' || this.currentRouteName === 'editer-attribut-signalement') {
this.$store.commit('feature/SET_CURRENT_FEATURE', []);
}
if (this.$route.params.slug_signal) {
this.getFeatureAttachments();
this.getLinkedFeatures();
}
},
mounted() {
const promises = [];
if (!this.project) {
promises.push(
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug),
this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug),
);
}
if (this.$route.params.slug_signal) {
promises.push(
this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
project_slug: this.$route.params.slug,
feature_id: this.$route.params.slug_signal,
})
);
}
Promise.all(promises).then(() => {
if (this.currentRouteName !== 'editer-attribut-signalement') {
this.initForm();
// if not in the case of a non geographical feature type, init map
if (this.feature_type.geom_type !== 'none') {
this.initMap();
this.initMapTools();
this.initDeleteFeatureOnKey();
}
}
this.$store.dispatch('feature/INIT_EXTRA_FORMS');
});
},
beforeDestroy() {
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
},
destroyed() {
editionService.removeActiveFeatures();
// emptying to enable adding event listener at feature edition straight after creation
editionService.selectForDeletion = null;
//* be sure that previous Formset have been cleared for creation
this.$store.commit('feature/CLEAR_ATTACHMENT_FORM');
this.$store.commit('feature/CLEAR_LINKED_FORM');
this.$store.commit('feature/CLEAR_EXTRA_FORM');
},
methods: {
initForm() {
if (this.currentRouteName.includes('editer')) {
for (const key in this.currentFeature.properties) {
if (key && this.form[key]) {
if (key === 'status') {
const value = this.currentFeature.properties[key];
this.form[key].value = statusChoices.find(
(key) => key.value === value
);
} else {
this.form[key].value = this.currentFeature.properties[key];
}
}
}
this.form.geom.value = this.currentFeature.geometry;
this.updateStore();
}
},
addPointToCoordinates(coordinates){
let json = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: coordinates,
},
properties: {},
};
this.updateMap(json);
this.updateGeomField(json.geometry);
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = '';
},
create_point_geoposition() {
function success(position) {
this.addPointToCoordinates([position.coords.longitude, position.coords.latitude]);
}
function error(err) {
this.erreurGeolocalisationMessage = err.message;
if (err.message === 'User denied geolocation prompt') {
this.erreurGeolocalisationMessage =
"La géolocalisation a été désactivée par l'utilisateur";
}
}
this.erreurGeolocalisationMessage = null;
if (!navigator.geolocation) {
this.erreurGeolocalisationMessage =
"La géolocalisation n'est pas supportée par votre navigateur.";
} else {
navigator.geolocation.getCurrentPosition(
success.bind(this),
error.bind(this)
);
}
},
toggleGeoRefModal() {
if (this.showGeoRef) {
//* when popup closes, empty form
this.erreurUploadMessage = '';
this.file = null;
}
this.showGeoRef = !this.showGeoRef;
},
handleFileUpload() {
this.erreurUploadMessage = '';
this.file = this.$refs.file.files[0];
},
georeferencement() {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}exif-geom-reader/`;
const formData = new FormData();
formData.append('image_file', this.file);
axios
.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => {
if (response.data.geom.indexOf('POINT') >= 0) {
const regexp = /POINT\s\((.*)\s(.*)\)/;
const arr = regexp.exec(response.data.geom);
this.addPointToCoordinates([parseFloat(arr[1]), parseFloat(arr[2])]);
// Set Attachment
this.addAttachment({
title: 'Localisation',
info: '',
attachment_file: this.file.name,
fileToImport: this.file,
});
this.toggleGeoRefModal();
}
})
.catch((error) => {
console.error({ error });
if (error && error.response && error.response) {
this.erreurUploadMessage = error.response.data.error;
} else {
this.erreurUploadMessage =
"Une erreur est survenue pendant l'import de l'image géoréférencée";
}
});
},
add_attachement_formset() {
this.$store.commit('feature/ADD_ATTACHMENT_FORM', {
dataKey: this.attachmentDataKey,
}); // * create an object with the counter in store
this.attachmentDataKey += 1; // * increment counter for key in v-for
},
addAttachment(attachment) {
this.$store.commit('feature/ADD_ATTACHMENT_FORM', {
dataKey: this.attachmentDataKey,
...attachment
});
this.attachmentDataKey += 1;
},
addExistingAttachementFormset(attachementFormset) {
for (const attachment of attachementFormset) {
this.addAttachment(attachment);
}
},
add_linked_formset() {
this.$store.commit('feature/ADD_LINKED_FORM', {
dataKey: this.linkedDataKey,
}); // * create an object with the counter in store
this.linkedDataKey += 1; // * increment counter for key in v-for
},
addExistingLinkedFormset(linkedFormset) {
for (const linked of linkedFormset) {
this.$store.commit('feature/ADD_LINKED_FORM', {
dataKey: this.linkedDataKey,
...linked
});
this.linkedDataKey += 1;
}
},
updateStore() {
return this.$store.commit('feature/UPDATE_FORM', {
title: this.form.title.value,
status: this.form.status.value,
description: this.form.description,
assigned_member: this.form.assigned_member,
geometry: this.form.geom.value,
feature_id: this.currentFeature ? this.currentFeature.id : '',
});
},
checkFormTitle() {
if (this.form.title.value) {
this.form.title.errors = [];
return true;
} else if (
!this.form.title.errors.includes('Veuillez compléter ce champ.')
) {
this.form.title.errors.push('Veuillez compléter ce champ.');
document
.getElementById('errorlist-title')
.scrollIntoView({ block: 'end', inline: 'nearest' });
}
return false;
},
checkFormGeom() {
if (this.form.geom.value) {
this.form.geom.errors = [];
return true;
} else if (
!this.form.geom.errors.includes('Valeur géométrique non valide.')
) {
this.form.geom.errors.push('Valeur géométrique non valide.');
document
.getElementById('errorlist-geom')
.scrollIntoView({ block: 'end', inline: 'nearest' });
}
return false;
},
checkAddedForm() {
let isValid = true; //* fallback if all customForms returned true
if (this.$refs.extraForm) {
for (const extraForm of this.$refs.extraForm) {
if (extraForm.checkForm() === false) {
isValid = false;
}
}
}
if (this.$refs.attachementForm) {
for (const attachementForm of this.$refs.attachementForm) {
if (attachementForm.checkForm() === false) {
isValid = false;
}
}
}
if (this.$refs.linkedForm) {
for (const linkedForm of this.$refs.linkedForm) {
if (linkedForm.checkForm() === false) {
isValid = false;
}
}
}
return isValid;
},
onSave() {
if (this.currentRouteName === 'editer-attribut-signalement') {
this.postMultipleFeatures();
} else {
this.postForm();
}
},
async postForm(extraForms) {
let response;
let is_valid = this.checkAddedForm();
// if not in the case of a non geographical feature type, check geometry's validity
if (this.feature_type && this.feature_type.geom_type !== 'none') {
is_valid = is_valid && this.checkFormGeom();
}
if (!this.feature_type.title_optional) {
is_valid = is_valid && this.checkFormTitle();
}
if (is_valid) {
//* in a moderate project, at edition of a published feature by someone else than admin or moderator, switch published status to draft.
if (
this.project.moderation &&
this.currentRouteName.includes('editer') &&
this.form.status.value.value === 'published' &&
!this.permissions.is_project_administrator &&
!this.permissions.is_project_moderator
) {
this.form.status.value = { name: 'Brouillon', value: 'draft' };
this.updateStore();
}
this.sendingFeature = true;
response = await this.$store.dispatch(
'feature/SEND_FEATURE',
{
routeName: this.currentRouteName,
query: this.$route.query,
extraForms// if multiple features, pass directly extraForms object to avoid mutating the store
}
);
this.sendingFeature = false;
return response;
}
},
async postMultipleFeatures() {
this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours...');
const responses = [];
// loop over each selected feature id
for (const featureId of this.checkedFeatures) {
// get other infos from this feature to feel the form
const response = await this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
project_slug: this.$route.params.slug,
feature_id: featureId,
});
if (response.status === 200) {
// fill title, status & description in store, required to send feature update request
this.initForm();
// create a new object of custom form to send directly with the request, to avoid multiple asynchronous mutation in store
const newXtraForms = [];
// parse each current custom form values to update the new custom form for this feature
for (const extraForm of this.extra_forms) {
// copy current custom form to prevent modifying the original one
let newXtForm = { ...extraForm };
// if value wasn't changed in this page, get back previous value of the feature (rather than using feature orginal form, which is missing information to send in request)
if (newXtForm.value === null) {
newXtForm.value = this.currentFeature.properties[newXtForm.name];
}
newXtraForms.push(newXtForm);
}
const response = await this.postForm(newXtraForms);
responses.push(response);
}
}
this.$store.commit('DISCARD_LOADER');
const errors = responses.filter((res) => res === undefined || res.status !== 200).length > 0;
const message = {
comment: errors ? 'Des signalements n\'ont pas pu être mis à jour' : 'Les signalements ont été mis à jour',
level: errors ? 'negative' : 'positive'
};
this.$store.commit('DISPLAY_MESSAGE', message);
this.$router.push({
name: 'liste-signalements',
params: {
slug: this.$route.params.slug,
},
});
},
//* ************* MAP *************** *//
initMapTools() {
const geomType = this.feature_type.geom_type;
editionService.addEditionControls(geomType);
editionService.draw.on('drawend', (evt) => {
const feature = evt.feature;
this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' }));
if (this.feature_type.geomType === 'point') {
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = '';
}
});
editionService.modify.on('modifyend', (evt) => {
let feature = evt.features.getArray()[0];
this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' }));
});
this.map.on(
'draw:deleted',
function () {
this.drawControlEditOnly.remove(this.map);
this.drawControlFull.addTo(this.map);
this.updateGeomField('');
if (geomType === 'point') {
this.showGeoPositionBtn = true;
this.erreurGeolocalisationMessage = '';
}
}.bind(this)
);
},
updateMap(geomFeatureJSON) {
if (editionService.drawSource) {
editionService.drawSource.clear();
}
if (geomFeatureJSON) {
let retour = new GeoJSON().readFeature(geomFeatureJSON,{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' });
editionService.initFeatureToEdit(retour);
} else {
this.map.setView(
this.$store.state.configuration.DEFAULT_MAP_VIEW.center,
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom
);
}
},
async updateGeomField(newGeom) {
this.form.geom.value = newGeom;
await this.updateStore();
},
initMap() {
this.mapLoading = true;
var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
// Create the map, then init features
this.map = mapService.createMap(this.$refs.map, {
mapDefaultViewCenter,
mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true },
fullScreenControl: true,
geolocationControl: true,
});
const currentFeatureId = this.$route.params.slug_signal;
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/features/?feature_type__slug=${this.$route.params.slug_type_signal}&project__slug=${this.$route.params.slug}&output=geojson`;
axios
.get(url)
.then((response) => {
const features = response.data.features;
if (features.length > 0) {
const allFeaturesExceptCurrent = features.filter(
(feat) => feat.id !== currentFeatureId
);
mapService.addFeatures({
addToMap: true,
project_slug: this.project.slug,
features: allFeaturesExceptCurrent,
featureTypes: this.feature_types,
});
if (this.currentRouteName === 'editer-signalement') {
editionService.setFeatureToEdit(this.currentFeature);
this.updateMap(this.currentFeature);
}
}
this.mapLoading = false;
})
.catch((error) => {
this.mapLoading = false;
throw error;
});
},
enableSnap() {
editionService.addSnapInteraction(this.map);
},
disableSnap() {
editionService.removeSnapInteraction(this.map);
},
getFeatureAttachments() {
featureAPI
.getFeatureAttachments(this.$route.params.slug_signal)
.then((data) => this.addExistingAttachementFormset(data));
},
getLinkedFeatures() {
featureAPI
.getFeatureLinks(this.$route.params.slug_signal)
.then((data) => this.addExistingLinkedFormset(data));
},
/**
* Deletes the selected feature when the "Delete" or "Escape" key is pressed.
* The map element has been made focusable by adding tabindex=0.
*/
initDeleteFeatureOnKey() {
// Add an event listener for key presses
document.addEventListener('keydown', function(event) {
// Check if the element with the ID "map" has focus
if ((event.key === 'Delete' || event.key === 'Escape') && document.activeElement.id === 'map') {
// If the conditions are met, call the deleteSelectedFeature function
editionService.removeFeatureFromMap();
}
});
},
setMemberAssigned(e) {
this.form.assigned_member.value = e;
this.updateStore();
}
},
};
</script>
<style scoped>
#map {
height: 70vh;
width: 100%;
border: 1px solid grey;
}
div.geolocation-container {
/* each button have .5em space between, zoom buttons are 60px high and full screen button is 34px high */
top: calc(1.3em + 60px + 34px);
}
#get-geom-from-image-file {
margin-bottom: 5px;
}
.georef-btn {
max-width: 400px;
}
@media only screen and (max-width: 767px) {
#map {
height: 80vh;
}
}
/* // ! missing style in semantic.min.css */
.ui.right.floated.button {
float: right;
margin-right: 0;
margin-left: 0.25em;
}
/* // ! margin écrasé par class last-child first-child */
.ui.segment {
margin: 1rem 0 !important;
}
/* override to display buttons under the dimmer of modal */
.leaflet-top,
.leaflet-bottom {
z-index: 800;
}
.extraform {
margin-bottom: 1em;
}
</style>
<template>
<div>
<div class="margin-1">
Erreur réseau lors de l'envoi du signalement.
</div>
<div class="margin-1">
Votre signalement devra être envoyé au serveur quand vous aurez de nouveau accès à internet.
</div>
<div class="margin-1">
Veuillez à ce moment là, cliquer sur le bouton "Envoyer", sur la page principale du projet
</div>
<router-link
:to="{
name: 'project_detail',
params: { slug: $route.params.slug },
}"
class="ui positive left labeled icon button margin-1"
>
<i
class="arrow left icon"
aria-hidden="true"
/>Retour au projet
</router-link>
</div>
</template>
<script>
export default {
name: 'FeatureOffline',
};
</script>
<style scoped>
.margin-1 {
margin: 1rem;
}
</style>
<template>
<div
v-if="feature_type"
id="feature-type-detail"
>
<div class="ui stackable grid">
<div class="five wide column">
<div
id="feature-type-title"
class="ui attached secondary segment"
>
<h1 class="ui center aligned header ellipsis">
<img
v-if="feature_type.geom_type === 'point'"
class="ui medium image"
alt="Géométrie point"
src="@/assets/img/marker.png"
>
<img
v-if="feature_type.geom_type === 'linestring'"
class="ui medium image"
alt="Géométrie ligne"
src="@/assets/img/line.png"
>
<img
v-if="feature_type.geom_type === 'polygon'"
class="ui medium image"
alt="Géométrie polygone"
src="@/assets/img/polygon.png"
>
<img
v-if="feature_type.geom_type === 'multipoint'"
class="ui medium image"
alt="Géométrie point"
src="@/assets/img/multimarker.png"
>
<img
v-if="feature_type.geom_type === 'multilinestring'"
class="ui medium image"
alt="Géométrie ligne"
src="@/assets/img/multiline.png"
>
<img
v-if="feature_type.geom_type === 'multipolygon'"
class="ui medium image"
alt="Géométrie polygone"
src="@/assets/img/multipolygon.png"
>
<span
v-if="feature_type.geom_type === 'none'"
class="ui medium image"
title="Aucune géométrie"
>
<i class="ui icon big outline file" />
</span>
{{ feature_type.title }}
</h1>
</div>
<div class="ui attached segment">
<div class="ui basic segment">
<div class="ui horizontal tiny statistic">
<div
:class="{ active: featuresLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
<div class="value">
{{ isOnline ? featuresCount : '?' }}
</div>
<div
class="label"
>
Signalement{{ featuresCount > 1 || !isOnline ? "s" : "" }}
</div>
</div>
<h3 class="ui header">
Champs
</h3>
<div class="ui divided list">
<div
v-for="(field, index) in feature_type.customfield_set"
:key="field.name + index"
class="item"
>
<div class="right floated content custom-field">
<div class="description">
{{ field.field_type }}
</div>
</div>
<div class="content">
{{ field.label }} ({{ field.name }})
</div>
</div>
</div>
</div>
</div>
<div class="ui bottom attached secondary segment">
<div
v-if="user && permissions.can_create_feature"
class="ui styled accordion"
data-test="features-import"
>
<div
id="toggle-show-import"
:class="['title', { active: showImport && isOnline, nohover: !isOnline }]"
@click="toggleShowImport"
>
<i
class="dropdown icon"
aria-hidden="true"
/>
Importer des signalements
</div>
<div :class="['content', { active: showImport && isOnline }]">
<div class="field">
<label
class="ui icon button ellipsis"
for="json_file"
>
<i
class="file icon"
aria-hidden="true"
/>
<span class="label">{{ geojsonFileToImport.name }}</span>
</label>
<input
id="json_file"
type="file"
accept="application/json, .json, .geojson"
style="display: none"
name="json_file"
@change="onGeojsonFileChange"
>
</div>
<div
v-if="feature_type.geom_type === 'point' || feature_type.geom_type === 'none'"
class="field"
>
<label
class="ui icon button ellipsis"
for="csv_file"
>
<i
class="file icon"
aria-hidden="true"
/>
<span class="label">{{ csvFileToImport.name }}</span>
</label>
<input
id="csv_file"
type="file"
accept="application/csv, .csv"
style="display: none"
name="csv_file"
@change="onCsvFileChange"
>
</div>
<router-link
v-if="
IDGO &&
permissions &&
permissions.can_create_feature
"
:to="{
name: 'catalog-import',
params: {
slug,
feature_type_slug: $route.params.feature_type_slug
},
}"
class="ui icon button import-catalog"
>
Importer les signalements à partir de {{ CATALOG_NAME|| 'IDGO' }}
</router-link>
<ul
v-if="importError"
class="errorlist"
>
<li>
{{ importError }}
</li>
</ul>
<button
id="start-import"
:disabled="
(geojsonFileToImport.size === 0 && !$route.params.geojson) &&
(csvFileToImport.size === 0 && !$route.params.csv)
"
class="ui fluid teal icon button"
@click="geojsonFileToImport.size !== 0 ? importGeoJson() : importCSV()"
>
<i
class="upload icon"
aria-hidden="true"
/>
Lancer l'import
</button>
<ImportTask
v-if="importsForFeatureType.length > 0"
ref="importTask"
:imports="importsForFeatureType"
@reloadFeatureType="reloadFeatureType"
/>
</div>
</div>
<div
class="ui styled accordion"
data-test="features-export"
>
<div
:class="['title', { active: !showImport && isOnline, nohover: !isOnline }]"
@click="toggleShowImport"
>
<i
class="dropdown icon"
aria-hidden="true"
/>
Exporter les signalements
</div>
<div :class="['content', { active: !showImport && isOnline }]">
<p>
Vous pouvez télécharger tous les signalements qui vous sont
accessibles.
</p>
<select
v-model="exportFormat"
class="ui fluid dropdown"
style="margin-bottom: 1em;"
>
<option value="GeoJSON">
{{ feature_type.geom_type === 'none' ? 'JSON' : 'GeoJSON' }}
</option>
<option
v-if="feature_type.geom_type === 'point' || feature_type.geom_type === 'none'"
value="CSV"
>
CSV
</option>
</select>
<button
:class="{ loading: exportLoading }"
type="button"
class="ui fluid teal icon button"
@click="exportFeatures"
>
<i
class="download icon"
aria-hidden="true"
/>
Exporter
</button>
</div>
</div>
</div>
</div>
<div
v-if="isOnline"
class="nine wide column"
>
<h3 class="ui header">
Derniers signalements
</h3>
<div
:class="{ active: featuresLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
<div
v-if="
importsForFeatureType &&
importsForFeatureType.length &&
importsForFeatureType.some((el) => el.status === 'pending')
"
class="ui message info"
data-test="wait-import-message"
>
<p>
Des signalements sont en cours d'import. Pour suivre le statut de
l'import, cliquez sur "Importer des Signalements".
</p>
</div>
<div
v-else-if="waitMessage"
class="ui message info"
>
<p>
L'import des signalements a été lancé.
Vous pourrez suivre le statut de l'import dans quelques instants...
</p>
</div>
<div
v-for="(feature, index) in lastFeatures"
:key="feature.feature_id + index"
class="ui small header"
data-test="last-features"
>
<span
v-if="feature.status === 'archived'"
data-tooltip="Archivé"
>
<i
class="grey archive icon"
aria-hidden="true"
/>
</span>
<span
v-else-if="feature.status === 'pending'"
data-tooltip="En attente de publication"
>
<i
class="teal hourglass outline icon"
aria-hidden="true"
/>
</span>
<span
v-else-if="feature.status === 'published'"
data-tooltip="Publié"
>
<i
class="olive check icon"
aria-hidden="true"
/>
</span>
<span
v-else-if="feature.status === 'draft'"
data-tooltip="Brouillon"
>
<i
class="orange pencil alternate icon"
aria-hidden="true"
/>
</span>
<FeatureFetchOffsetRoute
:feature-id="feature.feature_id"
:properties="feature"
/>
<div class="sub header">
<div>
{{
feature.description
? feature.description.substring(0, 200)
: "Pas de description disponible"
}}
</div>
<div>
[ Créé le {{ feature.created_on | formatDate }}
<span v-if="user">
par {{ feature.display_creator }}</span>
]
</div>
</div>
</div>
<router-link
v-if="project"
:to="{ name: 'liste-signalements', params: { slug } }"
class="ui right labeled icon button margin-25"
>
<i
class="right arrow icon"
aria-hidden="true"
/>
Voir tous les signalements
</router-link>
<router-link
v-if="permissions.can_create_feature && feature_type.geom_type && !feature_type.geom_type.includes('multi')"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: feature_type.slug },
}"
class="ui icon button button-hover-green margin-25"
>
Ajouter un signalement
</router-link>
<br>
</div>
<div
v-else
class="nine wide column"
>
<h3 class="ui header">
Derniers signalements
</h3>
<div class="ui message info">
<p>
Information non disponible en mode déconnecté.
</p>
</div>
</div>
</div>
</div>
</template>
<script>
import { csv } from 'csvtojson';
import { mapActions, mapMutations, mapGetters, mapState } from 'vuex';
import { formatStringDate, transformProperties } from '@/utils';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
import ImportTask from '@/components/ImportTask';
import featureAPI from '@/services/feature-api';
import { fileConvertSizeToMo, determineDelimiter, parseCSV, checkLonLatValues } from '@/assets/js/utils';
const geojsonFileToImport = {
name: 'Sélectionner un fichier GeoJSON ...',
size: 0,
};
const csvFileToImport = {
name: 'Sélectionner un fichier CSV ...',
size: 0,
};
export default {
name: 'FeatureTypeDetail',
components: {
FeatureFetchOffsetRoute,
ImportTask,
},
filters: {
formatDate(value) {
return formatStringDate(value);
},
},
props: {
geojson: {
type: Object,
default: null,
},
csv: {
type: Object,
default: null,
},
},
data() {
return {
importError: '',
geojsonFileToImport,
csvFileToImport,
showImport: false,
slug: this.$route.params.slug,
featureTypeSlug: this.$route.params.feature_type_slug,
featuresLoading: true,
loadingImportFile: false,
waitMessage: false,
exportFormat: 'GeoJSON',
exportLoading: false,
lastFeatures: [],
featuresCount: 0,
};
},
computed: {
...mapGetters([
'permissions',
]),
...mapGetters('projects', [
'project'
]),
...mapGetters('feature-type', [
'feature_type'
]),
...mapState([
'reloadIntervalId',
'configuration',
'isOnline',
'user',
]),
...mapState('projects', [
'project'
]),
...mapState('feature-type', [
'feature_types',
'importFeatureTypeData',
'selectedPrerecordedListValues'
]),
importsForFeatureType() { // filter import task datas only for this feature type
if (this.importFeatureTypeData) {
return this.importFeatureTypeData.filter((el) => el.feature_type_title === this.featureTypeSlug);
}
return [];
},
CATALOG_NAME() {
return this.configuration.VUE_APP_CATALOG_NAME;
},
IDGO() {
return this.$store.state.configuration.VUE_APP_IDGO;
},
},
watch: {
feature_type(newValue) {
this.toggleJsonUploadOption(newValue);
}
},
created() {
if (!this.project) {
this.$store.dispatch('projects/GET_PROJECT', this.slug);
this.$store.dispatch('projects/GET_PROJECT_INFO', this.slug);
}
this.SET_CURRENT_FEATURE_TYPE_SLUG(
this.featureTypeSlug
);
this.$store.dispatch('feature-type/GET_IMPORTS', {
project_slug: this.$route.params.slug,
feature_type: this.featureTypeSlug
});
this.getLastFeatures();
if (this.$route.params.type === 'external-geojson') {
this.showImport = true;
}
// empty prerecorded lists in case the list has been previously loaded with a limit in other component like ExtraForm
this.SET_PRERECORDED_LISTS([]);
// This function is also called by watcher at this stage, but to be safe in edge case
this.toggleJsonUploadOption(this.feature_type);
},
methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapMutations('feature-type', [
'SET_CURRENT_FEATURE_TYPE_SLUG',
'SET_FILE_TO_IMPORT',
'SET_PRERECORDED_LISTS'
]),
...mapActions('feature-type', [
'GET_PROJECT_FEATURE_TYPES',
'GET_SELECTED_PRERECORDED_LIST_VALUES',
'SEND_FEATURES_FROM_CSV',
]),
...mapActions('feature', [
'GET_PROJECT_FEATURES'
]),
getLastFeatures() {
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-paginated/?feature_type_slug=${this.featureTypeSlug}&ordering=-created_on&limit=5&offset=0`;
featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data) {
this.lastFeatures = data.results;
this.featuresCount = data.count;
}
this.featuresLoading = false;
});
},
reloadFeatureType() {
this.GET_PROJECT_FEATURE_TYPES(this.slug);
this.getLastFeatures();
},
toggleShowImport() {
this.showImport = !this.showImport;
},
/**
* In the case of a non geographical feature type, replace geoJSON by JSON in file to upload options
*
* @param {Object} featureType - The current featureType.
*/
toggleJsonUploadOption(featureType) {
if (featureType && featureType.geom_type === 'none') {
this.geojsonFileToImport = {
name: 'Sélectionner un fichier JSON ...',
size: 0,
};
}
},
async checkPreRecordedValue(fieldValue, listName) {
const fieldLabel = fieldValue.label || fieldValue;
// encode special characters like apostrophe or white space
const encodedPattern = encodeURIComponent(fieldLabel);
// query existing prerecorded list values (with label to limit results in response, there could be many) and escape special characters, since single quote causes error in backend
await this.GET_SELECTED_PRERECORDED_LIST_VALUES({ name: listName, pattern: encodedPattern });
// check if the value exist in available prerecorded list values
return this.selectedPrerecordedListValues[listName].some((el) => el.label === fieldLabel);
},
/**
* Validates the imported data against the pre-determined field types.
*
* This function iterates over all imported features and checks if each property's value matches
* the expected type specified in the feature type schema. It accommodates specific type conversions,
* such as allowing numerical strings for 'char' or 'text' fields and converting string representations
* of booleans and lists as necessary.
*
* @param {Array} features - The array of imported features to validate.
* @returns {boolean} Returns true if all features pass the validation; otherwise, false with an error message.
*/
async isValidTypes(features) {
this.importError = '';
// Extract relevant field type information from the feature type schema
const fields = this.feature_type.customfield_set.map((el) => {
return {
name: el.name,
field_type: el.field_type,
options: el.options,
};
});
let count = 1;
for (const feature of features) {
this.$store.commit('DISPLAY_LOADER', `Vérification du signalement ${count} sur ${features.length}`);
for (const { name, field_type, options } of fields) {
const properties = feature.properties || feature;
if (name in properties) {
let fieldInFeature = properties[name];
// Convert boolean strings from CSV to actual booleans
if (field_type === 'boolean') {
fieldInFeature = fieldInFeature === 'True' ? true : (fieldInFeature === 'False' ? false : fieldInFeature);
}
const customType = transformProperties(fieldInFeature);
// Validate field only if it has a non-null, non-empty, defined value
if (fieldInFeature !== null && fieldInFeature !== '' && fieldInFeature !== undefined) {
// Handle 'list' type by checking if value is among the defined options
if (field_type === 'list') {
if (!options.includes(fieldInFeature)) {
this.importError = `Fichier invalide: La valeur "${fieldInFeature}" n'est pas une option valide dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
// Handle 'pre_recorded_list' by checking if the value matches pre-recorded options
} else if (field_type === 'pre_recorded_list') {
if (typeof fieldInFeature === 'string' && fieldInFeature.charAt(0) === '{') { // data from CSV come as string, if it doesn't start with bracket then it should not be converted to an object and stay as a string, since the structure has been simplified: https://redmine.neogeo.fr/issues/18740
try {
const jsonStr = fieldInFeature.replace(/['‘’"]\s*label\s*['‘’"]\s*:/g, '"label":')
.replace(/:\s*['‘’"](.+?)['‘’"]\s*(?=[,}])/g, ':"$1"');
fieldInFeature = JSON.parse(jsonStr);
} catch (e) {
console.error(e);
this.DISPLAY_MESSAGE({ comment: `La valeur "${fieldInFeature}" n'a pas pu être vérifiée dans le champ "${name}" du signalement "${properties.title}"` });
}
}
let fieldLabel = fieldInFeature.label || fieldInFeature;
const isPreRecordedValue = await this.checkPreRecordedValue(fieldLabel, options[0]);
if (!isPreRecordedValue) {
this.importError = `Fichier invalide: La valeur "${fieldLabel}" ne fait pas partie des valeurs pré-enregistrées dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
// Handle 'multi_choices_list' by checking if each value in the array is among the defined options
} else if (field_type === 'multi_choices_list') {
if (typeof fieldInFeature === 'string' && fieldInFeature.charAt(0) === '[') { // data from CSV come as string, if it doesn't start with bracket then there's no need to convert it to an array
try {
fieldInFeature = JSON.parse(fieldInFeature.replaceAll('\'', '"'));
} catch (e) {
console.error(e);
this.DISPLAY_MESSAGE({ comment: `La valeur "${fieldInFeature}" n'a pas pu être vérifiée dans le champ "${name}" du signalement "${properties.title}"` });
}
}
// Check that the value is an array before asserting its validity
if (Array.isArray(fieldInFeature)) {
const invalidValues = fieldInFeature.filter((el) => !options.includes(el));
if (invalidValues.length > 0) {
const plural = invalidValues.length > 1;
this.importError = `Fichier invalide: ${plural ? 'Les valeurs' : 'La valeur'} "${invalidValues.join(', ')}" ${plural ? 'ne sont pas des options valides' : 'n\'est pas une option valide'}
dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
} else {
this.importError = `Fichier invalide: La valeur "${fieldInFeature}" doit être un tableau dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
// Validate custom field value type
} else if (customType !== field_type &&
// at feature type at creation, in case the value was 0, since it can be either float or integer, by default we've set its type as a float
// when importing features, to avoid an error with different types, we bypass this check when the incoming feature value is a integer while the feature type says it should be a float
!(
// Allow integers where decimals are expected
(customType === 'integer' && field_type === 'decimal') ||
// Allow numbers formatted as strings when 'char' or 'text' type is expected
((customType === 'integer' || customType === 'float') && field_type === 'char' || field_type === 'text') ||
// Allow 'char' values where 'text' (multiline string) is expected
(customType === 'char' && field_type === 'text')
)
) {
this.importError = `Fichier invalide : Le type de champ "${field_type}" ne peut pas avoir la valeur "${fieldInFeature}" dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
}
}
}
count +=1;
}
this.$store.commit('DISCARD_LOADER');
return true;
},
/**
* Checks the validity of a CSV string. It ensures the CSV uses a recognized delimiter,
* contains 'lat' and 'lon' headers, and that these columns contain decimal values within valid ranges.
* Additionally, it verifies the consistency and presence of data in the CSV, and that the types of values are valid.
*
* @param {string} csvString - The CSV content in string format.
* @returns {boolean|Promise<boolean>} Returns a boolean or a Promise resolving to a boolean,
* indicating the validity of the CSV.
*/
async checkCsvValidity(csvString) {
this.importError = '';
// Determine the delimiter of the CSV
const delimiter = determineDelimiter(csvString);
if (!delimiter) {
this.importError = `Le fichier ${this.csvFileToImport.name} n'est pas formaté correctement`;
return false;
}
// Parse the CSV string into rows
const rows = parseCSV(csvString, delimiter);
// Extract headers
const headers = rows.shift();
if (this.feature_type.geom_type !== 'none') {
// Check for required fields 'lat' and 'lon' in headers
if (!headers.includes('lat') || !headers.includes('lon')) {
this.importError = 'Les champs obligatoires "lat" et "lon" sont absents des headers.';
return false;
}
// Verify the presence and validity of coordinate values
const hasCoordValues = checkLonLatValues(headers, rows);
if (!hasCoordValues) {
this.importError = 'Les valeurs de "lon" et "lat" ne sont pas valides ou absentes.';
return false;
}
}
// Ensure there are data rows after the headers
if (rows.length === 0) {
this.importError = 'Aucune donnée trouvée après les en-têtes.';
return false;
}
// Ensure that each row has the same number of columns as the headers
if (rows.some(row => row.length !== headers.length)) {
this.importError = 'Incohérence dans le nombre de colonnes par ligne.';
return false;
}
// Convert the CSV string to a JSON object for further processing
const jsonFromCsv = await csv({ delimiter }).fromString(csvString);
// Validate the types of values in the JSON object
const validity = await this.isValidTypes(jsonFromCsv);
return validity;
},
/**
* Handles the change event for GeoJSON file input. This function is triggered when a user selects a file.
* It reads the file, checks its validity if it's not too large, and updates the component state accordingly.
*
* @param {Event} e - The event triggered by file input change.
*/
async onGeojsonFileChange(e) {
// Start loading process
this.loadingImportFile = true;
// Clear any previously selected CSV file to avoid confusion
this.csvFileToImport = csvFileToImport;
// Retrieve the files from the event
const files = e.target.files || e.dataTransfer.files;
// If no file is selected, stop the loading process and return
if (!files.length) {
this.loadingImportFile = false;
return;
}
const reader = new FileReader();
/**
* Asynchronously processes the content of the file.
* Checks the validity of the GeoJSON file if it's smaller than a certain size.
* Updates the state with the GeoJSON file if it's valid.
*
* @param {string} fileContent - The content of the file read by FileReader.
*/
const processFile = async (fileContent) => {
let jsonValidity;
// Check the file size and determine the GeoJSON validity
if (parseFloat(fileConvertSizeToMo(files[0].size)) <= 10) {
// If the file is smaller than 10 Mo, check its validity
try {
const json = JSON.parse(fileContent);
jsonValidity = await this.isValidTypes(json.features || json);
} catch (error) {
this.DISPLAY_MESSAGE({ comment: error, level: 'negative' });
jsonValidity = false;
}
} else {
// Assume validity for larger files
jsonValidity = true;
}
// If the GeoJSON is valid, update the component state with the file and set the file in store
if (jsonValidity) {
this.geojsonFileToImport = files[0];
this.SET_FILE_TO_IMPORT(this.geojsonFileToImport);
} else {
// Clear any previously selected geojson file to disable import button
this.geojsonFileToImport = geojsonFileToImport;
this.toggleJsonUploadOption(this.feature_type);
}
// Stop the loading process
this.loadingImportFile = false;
};
// Setup the load event listener for FileReader
reader.addEventListener('load', (e) => processFile(e.target.result));
// Read the text from the selected file
reader.readAsText(files[0]);
},
/**
* Handles the change event for CSV file input. This function is triggered when a user selects a file.
* It reads the file, checks its validity if it's not too large, and updates the component state accordingly.
*
* @param {Event} e - The event triggered by file input change.
*/
async onCsvFileChange(e) {
// Start loading process
this.loadingImportFile = true;
// Clear any previously selected geojson file to avoid confusion
this.geojsonFileToImport = geojsonFileToImport;
this.toggleJsonUploadOption(this.feature_type);
// Retrieve the files from the event
const files = e.target.files || e.dataTransfer.files;
// If no file is selected, stop the loading process and return
if (!files.length) {
this.loadingImportFile = false;
return;
}
// Create a new FileReader to read the selected file
const reader = new FileReader();
/**
* Asynchronously processes the content of the file.
* Checks the validity of the CSV file if it's smaller than a certain size.
* Updates the state with the CSV file if it's valid.
*
* @param {string} fileContent - The content of the file read by FileReader.
*/
const processFile = async (fileContent) => {
let csvValidity;
// Check the file size and determine the CSV validity
if (parseFloat(fileConvertSizeToMo(files[0].size)) <= 10) {
// If the file is smaller than 10 Mo, check its validity
csvValidity = await this.checkCsvValidity(fileContent);
} else {
// Assume validity for larger files
csvValidity = true;
}
// If the CSV is valid, update the component state with the file
if (csvValidity) {
this.csvFileToImport = files[0]; // TODO: Remove this value from state as it is stored (first attempt didn't work)
this.SET_FILE_TO_IMPORT(this.csvFileToImport);
} else {
// Clear any previously selected geojson file to disable import button
this.csvFileToImport = csvFileToImport;
}
// Stop the loading process
this.loadingImportFile = false;
};
// Setup the load event listener for FileReader
reader.addEventListener('load', (e) => processFile(e.target.result));
// Read the text from the selected file
reader.readAsText(files[0]);
},
importGeoJson() {
this.waitMessage = true;
const payload = {
slug: this.slug,
feature_type_slug: this.featureTypeSlug,
};
if (this.$route.params.geojson) { //* import after redirection, for instance with data from catalog
payload['geojson'] = this.$route.params.geojson;
} else if (this.geojsonFileToImport.size > 0) { //* import directly from geojson
payload['fileToImport'] = this.geojsonFileToImport;
} else {
this.importError = 'La ressource n\'a pas pu être récupéré.';
return;
}
this.$store.dispatch('feature-type/SEND_FEATURES_FROM_GEOJSON', payload)
.then(() => {
this.waitMessage = false;
this.$refs.importTask.fetchImports();
});
},
importCSV() {
this.waitMessage = true;
const payload = {
slug: this.slug,
feature_type_slug: this.featureTypeSlug,
};
if (this.$route.params.csv) { //* import after redirection, for instance with data from catalog
payload['csv'] = this.$route.params.csv;
} else if (this.csvFileToImport.size > 0) { //* import directly from csv file
payload['fileToImport'] = this.csvFileToImport;
} else {
this.importError = "La ressource n'a pas pu être récupéré.";
return;
}
this.SEND_FEATURES_FROM_CSV(payload)
.then(() => {
this.waitMessage = false;
this.$refs.importTask.fetchImports();
});
},
exportFeatures() {
this.exportLoading = true;
let exportFormat = this.feature_type.geom_type === 'none' && this.exportFormat === 'GeoJSON' ? 'json' : this.exportFormat.toLowerCase();
const url = `
${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-type/${this.featureTypeSlug}/export/?format_export=${exportFormat}
`;
featureAPI.getFeaturesBlob(url)
.then((blob) => {
if (blob) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.project.title}-${this.feature_type.title}.${exportFormat}`;
link.click();
setTimeout(function(){
URL.revokeObjectURL(link.href);
}, 1000);
}
this.exportLoading = false;
})
.catch(() => {
this.exportLoading = false;
});
},
},
};
</script>
<style scoped lang="less">
#feature-type-title i {
color: #000000;
margin: auto;
}
.custom-field.content {
overflow: hidden;
text-overflow: ellipsis;
}
.margin-25 {
margin: 0 0.25em 0.25em 0 !important;
}
.import-catalog {
margin-bottom: 1em;
}
.nohover, .nohover:hover {
cursor: default;
}
.ui.styled.accordion .nohover.title:hover {
color: rgba(0, 0, 0, .4);
}
.ui.styled.accordion {
.content {
.field {
label {
width: 100%;
}
}
.import-catalog {
width: 100%;
}
}
}
</style>
\ No newline at end of file
<template>
<div id="displayCustomisation">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<h1 v-if="project && feature_type">
Modifier l'affichage sur la carte des signalements de type "{{ feature_type.title }}" pour le
projet "{{ project.title }}"
</h1>
<section id="symbology">
<h3>Symbologie</h3>
<form
id="form-symbology-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form"
>
<SymbologySelector
v-if="feature_type"
id="default"
:init-color="feature_type.color"
:init-icon="feature_type.icon"
:init-opacity="feature_type.opacity"
:geom-type="feature_type.geom_type"
@set="setDefaultStyle"
/>
<div
v-if="customizableFields.length > 0"
class="fields inline"
>
<label
id="customfield-select-label"
for="customfield-select"
>
Champ de personnalisation de la symbologie:
</label>
<div id="custom_types-dropdown">
<Dropdown
:options="customizableFields"
:selected="selectedCustomfield"
:selection.sync="selectedCustomfield"
:clearable="true"
/>
</div>
</div>
<div
v-if="selectedCustomfield"
id="customFieldSymbology"
class="field"
>
<SymbologySelector
v-for="option of selectedFieldOptions"
:id="option"
:key="option"
:title="option"
:init-color="feature_type.colors_style.value ?
feature_type.colors_style.value.colors[option] ?
feature_type.colors_style.value.colors[option].value :
feature_type.colors_style.value.colors[option]
: null
"
:init-icon="feature_type.colors_style.value ?
feature_type.colors_style.value.icons[option] :
null
"
:init-opacity="getOpacity(feature_type, option)"
:geom-type="feature_type.geom_type"
@set="setColorsStyle"
/>
</div>
</form>
</section>
<div class="ui divider" />
<section
v-if="feature_type && feature_type.customfield_set"
id="popupDisplay"
>
<h3>Prévisualisation des champs personnalisés de l'info-bulle</h3>
<table
id="table-fields-to-display"
class="ui definition single line compact table"
aria-describedby="Liste des champs à afficher"
>
<thead>
<tr>
<th scope="col">
Prévisualisation du champ
</th>
<th scope="col">
Champ
</th>
<th scope="col">
Type
</th>
</tr>
</thead>
<tbody>
<tr
v-for="field in featureAnyFields"
:key="field.name"
:class="{ first_customfield: feature_type.customfield_set[0] &&
field.name === feature_type.customfield_set[0].name }"
>
<td
scope="row"
class="collapsing center aligned"
>
<div class="ui toggle checkbox">
<input
:checked="form.displayed_fields.includes(field.name)"
type="checkbox"
@input="toggleDisplay($event, field.name)"
>
<label />
</div>
</td>
<td scope="row">
{{ field.name }} ({{ field.label }})
</td>
<td scope="row">
{{ field.field_type || getCustomFieldType(field.field_type) }}
</td>
</tr>
</tbody>
</table>
</section>
<section id="notification">
<h3>Configuration de la notification d'abonnement</h3>
<div class="ui form">
<div class="field">
<div class="ui checkbox">
<input
id="enable_key_doc_notif"
v-model="form.enable_key_doc_notif"
class="hidden"
name="enable_key_doc_notif"
type="checkbox"
>
<label for="enable_key_doc_notif">Activer la notification de publication de pièces jointes</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="disable_notification"
v-model="form.disable_notification"
class="hidden"
name="disable_notification"
type="checkbox"
>
<label for="disable_notification">Désactiver les notifications</label>
</div>
</div>
</div>
</section>
<button
id="save-display"
class="ui teal icon button margin-25"
type="button"
:disabled="!canSaveDisplayConfig"
@click="sendDisplayConfig"
>
<i
class="white save icon"
aria-hidden="true"
/>
Sauvegarder l'affichage du type de signalement
</button>
</div>
</template>
<script>
import { isEqual } from 'lodash';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { customFieldTypeChoices, featureNativeFields } from '@/utils';
import SymbologySelector from '@/components/FeatureType/SymbologySelector.vue';
import Dropdown from '@/components/Dropdown.vue';
export default {
name: 'FeatureTypeDisplay',
components: {
SymbologySelector,
Dropdown,
},
data() {
return {
loading: false,
form: {
color: '#000000',
icon: 'circle',
colors_style: {
fields: [],
colors: {},
icons: {},
opacities: {},
custom_field_name: '',
value: {
colors: {},
icons: {},
opacities: {},
}
},
displayed_fields: ['status', 'feature_type', 'updated_on'],
enable_key_doc_notif: false,
disable_notification: false,
},
canSaveDisplayConfig: false
};
},
computed: {
...mapState('projects', [
'project'
]),
...mapState('feature-type', [
'customForms',
'colorsStyleList'
]),
...mapGetters('feature-type', [
'feature_type'
]),
customizableFields() {
if (this.feature_type) {
let options = this.feature_type.customfield_set.filter(el => el.field_type === 'list' || el.field_type === 'char' || el.field_type === 'boolean');
options = options.map((el) => {
return { name: [el.name, this.getCustomFieldType(el.field_type)], value: el };
});
return options;
}
return [];
},
selectedFieldOptions() {
if (this.selectedCustomfield) {
const customFieldSet = this.feature_type.customfield_set.find(el => el.name === this.selectedCustomfield);
if (customFieldSet) {
if (customFieldSet.options && customFieldSet.options.length > 0) {
return customFieldSet.options;
} else if (customFieldSet.field_type === 'char') {
return ['Vide', 'Non vide'];
} else if (customFieldSet.field_type === 'boolean') {
return ['Décoché', 'Coché'];
}
}
}
return [];
},
selectedCustomfield: {
get() {
return this.form.colors_style.custom_field_name;
},
set(newValue) {
if (newValue !== undefined) {
this.form.colors_style.custom_field_name = newValue.value ? newValue.value.name : null;
}
}
},
featureAnyFields() {
return [...featureNativeFields, ...this.feature_type.customfield_set];
}
},
watch: {
feature_type(newValue) {
// In which case the feature type would change while on this page ?
if (newValue) {
this.initForm();
}
},
form: {
deep: true,
handler(newValue) {
// checks if they are changes to be saved to enable save button
if (isEqual(newValue, {
color: this.feature_type.color,
icon: this.feature_type.icon,
opacity: this.feature_type.opacity,
colors_style: this.feature_type.colors_style,
displayed_fields: this.feature_type.displayed_fields,
enable_key_doc_notif: this.feature_type.enable_key_doc_notif,
disable_notification: this.feature_type.disable_notification
})) {
this.canSaveDisplayConfig = false;
} else {
this.canSaveDisplayConfig = true;
}
}
}
},
created() {
if (!this.project) {
this.GET_PROJECT(this.$route.params.slug);
this.GET_PROJECT_INFO(this.$route.params.slug);
}
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal);
if (this.feature_type) {
this.initForm();
} else {
this.loading = true;
this.GET_PROJECT_FEATURE_TYPES(this.$route.params.slug)
.then(() => {
this.initForm();
// TODO : Use the global loader and get rid of this redondant loader
this.loading = false;
})
.catch(() => {
this.loading = false;
});
}
},
methods: {
...mapMutations('feature-type', [
'SET_CURRENT_FEATURE_TYPE_SLUG'
]),
...mapActions('feature-type', [
'SEND_FEATURE_DISPLAY_CONFIG',
'GET_PROJECT_FEATURE_TYPES'
]),
...mapActions('projects', [
'GET_PROJECT',
'GET_PROJECT_INFO',
]),
initForm() {
this.form.color = JSON.parse(JSON.stringify(this.feature_type.color)); //? wouldn't be better to use lodash: https://medium.com/@pmzubar/why-json-parse-json-stringify-is-a-bad-practice-to-clone-an-object-in-javascript-b28ac5e36521
this.form.icon = JSON.parse(JSON.stringify(this.feature_type.icon)); //? since the library is already imported ?
this.form.colors_style = {
...this.form.colors_style,
...JSON.parse(JSON.stringify(this.feature_type.colors_style))
};
if (!this.form.colors_style.value['opacities']) { //* if the opacity values were never setted (but why would it happen, is it necessary ?)
this.form.colors_style.value['opacities'] = {};
}
if (this.feature_type.colors_style && Object.keys(this.feature_type.colors_style.colors).length > 0) {
const coloredCustomField = this.feature_type.customfield_set.find(
el => el.name === this.feature_type.colors_style.custom_field_name
);
if (coloredCustomField) {
this.selectedCustomfield = coloredCustomField.name;
}
}
if (this.feature_type && this.feature_type.displayed_fields) {
this.form.displayed_fields = [...this.feature_type.displayed_fields];
}
this.form.enable_key_doc_notif = this.feature_type.enable_key_doc_notif;
this.form.disable_notification = this.feature_type.disable_notification;
},
setDefaultStyle(e) {
const { color, icon, opacity } = e.value;
this.form.color = color.value;
this.form.icon = icon;
this.form.opacity = opacity;
},
setColorsStyle(e) {
const { name, value } = e;
const { color, icon, opacity } = value;
this.form.colors_style.colors[name] = color;
this.form.colors_style.icons[name] = icon;
this.form.colors_style.opacities[name] = opacity;
this.form.colors_style.value.colors[name] = color;
this.form.colors_style.value.icons[name] = icon;
this.form.colors_style.value.opacities[name] = opacity; //? why do we need to duplicate values ? for MVT ?
},
toggleDisplay(evt, name) {
if (evt.target.checked) {
this.form.displayed_fields.push(name);
} else {
this.form.displayed_fields = this.form.displayed_fields.filter(el => el !== name);
}
},
sendDisplayConfig() {
this.loading = true;
this.SEND_FEATURE_DISPLAY_CONFIG(this.form)
.then(() => {
this.loading = false;
this.$router.push({
name: 'project_detail',
params: {
slug: this.$route.params.slug,
message: { comment: `La modification de l'affichage du type de signalement "${this.feature_type.title}" a été prise en compte.`, level: 'positive' }
},
});
})
.catch((err) => {
console.error(err);
this.$store.commit('DISPLAY_MESSAGE', {
comment: `Une erreur est survenue pendant l'envoi des modifications de l'affichage du type de signalement "${this.feature_type.title}"`,
level: 'negative'
});
this.loading = false;
});
},
getOpacity(feature_type, optionName) {
if (feature_type.colors_style.value && feature_type.colors_style.value.opacities) {
return feature_type.colors_style.value.opacities[optionName];
}
return null;
},
getCustomFieldType(fieldType) {
return customFieldTypeChoices.find(el => el.value === fieldType).name;
}
}
};
</script>
<style lang="less" scoped>
#displayCustomisation {
h1 {
margin-top: 1em;
}
form {
text-align: left;
margin-left: 1em;
#customfield-select-label {
font-weight: 600;
font-size: 1.1em;
}
#custom_types-dropdown {
margin: 1em;
&& > .dropdown {
width: 50%;
}
}
}
}
section {
padding: 1.5em 0;
// shrink toggle background width and height
.ui.toggle.checkbox .box::after, .ui.toggle.checkbox label::after {
height: 15px;
width: 15px;
}
.ui.toggle.checkbox .box, .ui.toggle.checkbox label {
padding-left: 2.5rem;
}
// reduce toggle button width and height
.ui.toggle.checkbox .box::before, .ui.toggle.checkbox label::before {
height: 15px;
width: 35px;
}
// adjust toggled button placement
.ui.toggle.checkbox input:checked ~ .box::after, .ui.toggle.checkbox input:checked ~ label::after {
left: 20px;
}
.ui.toggle.checkbox .box, .ui.toggle.checkbox label, .ui.toggle.checkbox {
min-height: 15px;
}
table {
border-collapse: collapse;
}
tr.first_customfield td {
border-top-width: 4px !important;
}
}
</style>
<template> <template>
<div v-frag> <div id="feature-type-edit">
<div id="message" class="fullwidth">
<div v-if="error" class="ui negative message">
<p><i class="cross icon"></i> {{ error }}</p>
</div>
</div>
<div class="fourteen wide column"> <div class="fourteen wide column">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<form <form
v-if="project"
id="form-type-edit" id="form-type-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form" class="ui form"
> >
<h1 v-if="action === 'create'"> <h1 v-if="action === 'create'">
...@@ -30,22 +29,31 @@ ...@@ -30,22 +29,31 @@
<div class="required field"> <div class="required field">
<label :for="form.title.id_for_label">{{ form.title.label }}</label> <label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input <input
:id="form.title.id_for_label"
v-model="form.title.value"
type="text" type="text"
required required
:maxlength="form.title.field.max_length" :maxlength="form.title.field.max_length"
:name="form.title.html_name" :name="form.title.html_name"
:id="form.title.id_for_label"
v-model="form.title.value"
@blur="updateStore" @blur="updateStore"
/> >
<ul id="errorlist" class="errorlist"> <ul
<li v-for="error in form.title.errors" :key="error"> id="errorlist"
{{ error }} class="errorlist"
>
<li
v-for="err in form.title.errors"
:key="err"
>
{{ err }}
</li> </li>
</ul> </ul>
</div> </div>
<div class="required field"> <div
id="geometry-type"
:class="['required field', { disabled: csv }]"
>
<label :for="form.geom_type.id_for_label">{{ <label :for="form.geom_type.id_for_label">{{
form.geom_type.label form.geom_type.label
}}</label> }}</label>
...@@ -54,107 +62,95 @@ ...@@ -54,107 +62,95 @@
:selected="selectedGeomType" :selected="selectedGeomType"
:selection.sync="selectedGeomType" :selection.sync="selectedGeomType"
/> />
<!-- {{ form.geom_type.errors }} -->
</div>
<div class="required field">
<label :for="form.color.id_for_label">{{ form.color.label }}</label>
<input
type="color"
required
style="width: 100%; height: 38px"
:name="form.color.html_name"
:id="form.color.id_for_label"
v-model="form.color.value"
@blur="updateStore"
/>
<!-- {{ form.color.errors }} -->
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input <input
:name="form.title_optional.html_name" :id="form.title_optional.html_name"
v-model="form.title_optional.value" v-model="form.title_optional.value"
class="hidden"
:name="form.title_optional.html_name"
type="checkbox" type="checkbox"
/> >
<label>{{ form.title_optional.label }}</label> <label :for="form.title_optional.html_name">{{ form.title_optional.label }}</label>
</div> </div>
</div> </div>
<div class="field">
<!-- //* s'affiche après sélection d'option de type liste dans type de champ --> <div class="ui checkbox">
<div <input
v-if="colorsStyleList.length > 0" :id="form.enable_key_doc_notif.html_name"
class="custom_style" v-model="form.enable_key_doc_notif.value"
id="id_style_container" class="hidden"
> :name="form.enable_key_doc_notif.html_name"
<div class="list_selection" id="id_list_selection"> type="checkbox"
<Dropdown >
:options="colorsStyleList" <label :for="form.enable_key_doc_notif.html_name">{{ form.enable_key_doc_notif.label }}</label>
:selected="selected_colors_style"
:selection.sync="selected_colors_style"
:placeholder="'Sélectionner la liste de valeurs'"
/>
</div> </div>
<div class="colors_selection" id="id_colors_selection" hidden> </div>
<div <div class="field">
v-for="(value, key, index) in form.colors_style.value.colors" <div class="ui checkbox">
:key="'colors_style-' + index" <input
:id="form.disable_notification.html_name"
v-model="form.disable_notification.value"
class="hidden"
:name="form.disable_notification.html_name"
type="checkbox"
> >
<div v-if="key" class="color-input"> <label :for="form.disable_notification.html_name">{{ form.disable_notification.label }}</label>
<label>{{ key }}</label
><input
:name="key"
type="color"
:value="value"
@input="setColorStyles"
/>
</div>
</div>
</div> </div>
</div> </div>
<span v-if="action === 'duplicate' || action === 'edit'"> </span>
<div id="formsets"> <div id="formsets">
<FeatureTypeCustomForm <FeatureTypeCustomForm
v-for="customForm in customForms" v-for="customForm in customForms"
:key="customForm.dataKey" :key="customForm.dataKey"
:dataKey="customForm.dataKey"
:customForm="customForm"
:selectedColorStyle="form.colors_style.value.custom_field_name"
v-on:update="updateColorsStyle($event)"
ref="customForms" ref="customForms"
:data-key="customForm.dataKey"
:custom-form="customForm"
:selected-color-style="form.colors_style.value.custom_field_name"
@update="updateColorsStyle($event)"
/> />
</div> </div>
<button <button
id="add-field"
type="button" type="button"
class="ui compact basic button"
@click="addCustomForm" @click="addCustomForm"
id="add-field"
class="ui compact basic button button-hover-green"
> >
<i class="ui plus icon"></i>Ajouter un champ personnalisé <i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter un champ personnalisé
</button> </button>
<div class="ui divider"></div> <div class="ui divider" />
<button <button
class="ui teal icon button margin-25" id="send-feature_type"
:class="['ui teal icon button margin-25', { disabled: loading }]"
type="button" type="button"
@click="sendFeatureType" @click="sendFeatureType"
> >
<i class="white save icon"></i> <i
class="white save icon"
aria-hidden="true"
/>
{{ action === "create" ? "Créer" : "Sauvegarder" }} le type de {{ action === "create" ? "Créer" : "Sauvegarder" }} le type de
signalement signalement
</button> </button>
<button <button
v-if="geojson" v-if="geojson || csv || json"
class="ui teal icon button margin-25" :class="['ui teal icon button margin-25', { disabled: loading }]"
type="button" type="button"
@click="postFeatureTypeThenFeatures" @click="postFeatureTypeThenFeatures"
> >
<i class="white save icon"></i> <i
Créer et importer le(s) signalement(s) du geojson class="white save icon"
aria-hidden="true"
/>
Créer et importer le(s) signalement(s) du {{ geojson ? 'geojson' : csv ? 'csv' : 'json' }}
</button> </button>
</form> </form>
</div> </div>
...@@ -162,101 +158,123 @@ ...@@ -162,101 +158,123 @@
</template> </template>
<script> <script>
import frag from "vue-frag";
import { mapGetters, mapState } from "vuex";
import Dropdown from "@/components/Dropdown.vue";
import FeatureTypeCustomForm from "@/components/feature_type/FeatureTypeCustomForm.vue";
export default { import { mapGetters, mapState, mapMutations, mapActions } from 'vuex';
name: "Feature_type_edit",
directives: { import Dropdown from '@/components/Dropdown.vue';
frag, import FeatureTypeCustomForm from '@/components/FeatureType/FeatureTypeCustomForm.vue';
}, import { transformProperties, reservedKeywords } from'@/utils';
export default {
name: 'FeatureTypeEdit',
components: { components: {
Dropdown, Dropdown,
FeatureTypeCustomForm, FeatureTypeCustomForm,
}, },
props: ["geojson", "fileToImport"], props: {
geojson: {
type: Object,
default: null,
},
csv: {
type: Array,
default: null,
},
json: {
type: Array,
default: null,
},
},
data() { data() {
return { return {
action: "create", loading: false,
action: 'create',
dataKey: 0, dataKey: 0,
error: null, csvFields: null,
geomTypeChoices: [ geomTypeChoices: [
{ value: "linestring", name: "Ligne" }, { value: 'linestring', name: 'Ligne' },
{ value: "point", name: "Point" }, { value: 'point', name: 'Point' },
{ value: "polygon", name: "Polygone" }, { value: 'polygon', name: 'Polygone' },
{ value: 'none', name: 'Aucune' },
], ],
form: { form: {
colors_style: { colors_style: {
fields: [], fields: [],
value: { value: {
colors: {}, colors: {},
custom_field_name: "", custom_field_name: '',
}, },
}, },
color: { color: {
id_for_label: "couleur", id_for_label: 'couleur',
label: "Couleur", label: 'Couleur',
field: { field: {
max_length: 128, // ! Vérifier la valeur dans django max_length: 128, // ! Vérifier la valeur dans django
}, },
html_name: "couleur", html_name: 'couleur',
value: "#000000", value: '#000000',
}, },
title: { title: {
errors: [], errors: [],
id_for_label: "title", id_for_label: 'title',
label: "Titre", label: 'Titre',
field: { field: {
max_length: 128, // ! Vérifier la valeur dans django max_length: 128,
}, },
html_name: "title", html_name: 'title',
value: null, value: null,
}, },
title_optional: { title_optional: {
errors: null, errors: null,
id_for_label: "title_optional", id_for_label: 'title_optional',
html_name: "title_optional", html_name: 'title_optional',
label: "Titre du signalement optionnel", label: 'Titre du signalement optionnel',
value: false,
},
enable_key_doc_notif: {
errors: null,
id_for_label: 'enable_key_doc_notif',
html_name: 'enable_key_doc_notif',
label: 'Activer la notification de publication de pièces jointes',
value: false,
},
disable_notification: {
errors: null,
id_for_label: 'disable_notification',
html_name: 'disable_notification',
label: 'Désactiver les notifications',
value: false, value: false,
}, },
geom_type: { geom_type: {
id_for_label: "geom_type", id_for_label: 'geom_type',
label: "Type de géométrie", label: 'Type de géométrie',
field: { field: {
max_length: 128, // ! Vérifier la valeur dans django max_length: 128,
}, },
html_name: "geom_type", html_name: 'geom_type',
value: "point", value: 'point',
}, },
}, },
reservedKeywords: [ slug: this.$route.params.slug,
// todo : add keywords for mapstyle (strokewidth...)
"title",
"description",
"status",
"created_on",
"updated_on",
"archived_on",
"deletion_on",
"feature_type",
"display_creator",
"display_last_editor",
"project",
"creator",
],
}; };
}, },
computed: { computed: {
...mapGetters(["project"]), ...mapState('projects', [
...mapState("feature_type", ["customForms", "colorsStyleList"]), 'project'
...mapGetters("feature_type", ["feature_type"]), ]),
...mapState('feature-type', [
'customForms',
'colorsStyleList',
'fileToImport'
]),
...mapGetters('feature-type', [
'feature_type'
]),
selectedGeomType: { selectedGeomType: {
get() { get() {
const currentGeomType = this.geomTypeChoices.find( const currentGeomType = this.geomTypeChoices.find(
...@@ -293,7 +311,7 @@ export default { ...@@ -293,7 +311,7 @@ export default {
} else { } else {
const newColorsStyle = { const newColorsStyle = {
colors: newValue.options.reduce((obj, key) => { colors: newValue.options.reduce((obj, key) => {
obj[key] = "#000000"; obj[key] = '#000000';
return obj; return obj;
}, {}), }, {}),
}; };
...@@ -315,31 +333,97 @@ export default { ...@@ -315,31 +333,97 @@ export default {
}, },
customForms(newValue, oldValue) { customForms(newValue, oldValue) {
if (newValue !== oldValue) { if (newValue !== oldValue) {
const name = this.form.colors_style.value.custom_field_name; // Retrieve custom_field_name; returns undefined if colors_style.value is null/undefined
const customField = this.customForms.find((el) => el.name === name); const customFieldName = this.form.colors_style.value?.custom_field_name;
if (!customField || customField.length === 0) {
//* if the customForm corresponding doesn't exist reset colors_style values // Determine if a custom field with the given name exists in customForms
this.form.colors_style.value = { // 'some' returns true if any element matches the condition
colors: {}, const customFieldExists = customFieldName && this.customForms.some(el => el.name === customFieldName);
custom_field_name: "",
}; // Reset colors_style if no corresponding custom field is found
if (!customFieldExists) {
this.form.colors_style.value = { colors: {}, custom_field_name: '' };
} }
} }
}, },
}, },
created() {
if (!this.project) {
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug);
this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug);
}
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal);
this.definePageType();
},
mounted() {
if (this.action === 'edit' || this.action === 'duplicate') {
if (this.feature_type) {
//* add datas from store to state to avoid mutating directly store with v-model (not good practice), could have used computed with getter and setter as well
this.fillFormData(this.feature_type);
}
if (this.action === 'duplicate') {
//* replace original name with new default title
this.form.title.value += ` (Copie-${new Date()
.toLocaleString()
.slice(0, -3)
.replace(',', '')})`;
this.updateStore(); // * initialize form in store in case this.form would not be modified
}
}
//* when creation from a geojson
if (this.geojson) {
this.importGeoJsonFeatureType();
//* add multiple geometries options available only for geojson (therefore when importing from catalog also)
this.geomTypeChoices.push(
{ value: 'multilinestring', name: 'Multiligne' },
{ value: 'multipoint', name: 'Multipoint' },
{ value: 'multipolygon', name: 'Multipolygone' },
);
}
if (this.csv) {
this.importCSVFeatureType();
}
if (this.json) {
this.importJsonFeatureType();
}
},
beforeDestroy() {
this.EMPTY_FORM();
this.EMPTY_CUSTOM_FORMS();
this.SET_FILE_TO_IMPORT(null);
},
methods: { methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapMutations('feature-type', [
'ADD_CUSTOM_FORM',
'EMPTY_FORM',
'EMPTY_CUSTOM_FORMS',
'UPDATE_FORM',
'SET_FILE_TO_IMPORT',
'SET_CURRENT_FEATURE_TYPE_SLUG',
]),
...mapActions('feature-type', [
'SEND_FEATURE_TYPE',
'SEND_FEATURES_FROM_GEOJSON'
]),
definePageType() { definePageType() {
if (this.$router.history.current.name === "ajouter-type-signalement") { if (this.$router.history.current.name === 'ajouter-type-signalement') {
this.action = "create"; this.action = 'create';
} else if ( } else if (
this.$router.history.current.name === "editer-type-signalement" this.$router.history.current.name === 'editer-type-signalement'
) { ) {
this.action = "edit"; this.action = 'edit';
} else if ( } else if (
this.$router.history.current.name === "dupliquer-type-signalement" this.$router.history.current.name === 'dupliquer-type-signalement'
) { ) {
this.action = "duplicate"; this.action = 'duplicate';
} }
}, },
...@@ -359,16 +443,18 @@ export default { ...@@ -359,16 +443,18 @@ export default {
//* if adding an existing customForm -> add its property to newCustomForm containing only dataKey //* if adding an existing customForm -> add its property to newCustomForm containing only dataKey
newCustomForm = { ...newCustomForm, ...customForm }; newCustomForm = { ...newCustomForm, ...customForm };
} }
this.$store.commit("feature_type/ADD_CUSTOM_FORM", newCustomForm); // * create an object with the counter in store this.ADD_CUSTOM_FORM(newCustomForm); // * create an object with the counter in store
}, },
fillFormData(formData) { fillFormData(formData) {
for (const el in formData) { for (const el in formData) {
// * find feature_type and fill form values // * find feature_type and fill form values
if (this.form[el]) this.form[el].value = formData[el]; if (this.form[el]) {
this.form[el].value = formData[el];
}
} }
//! add custom fields using ONLY this function, incrementing dataKey for Vue to correctly update components //! add custom fields using ONLY this function, incrementing dataKey for Vue to correctly update components
formData.customfield_set.forEach((el) => this.addCustomForm(el)); [...formData.customfield_set].forEach((el) => this.addCustomForm(el));
this.updateStore(); this.updateStore();
}, },
...@@ -384,12 +470,12 @@ export default { ...@@ -384,12 +470,12 @@ export default {
for (const key of newOptions) { for (const key of newOptions) {
if (key && !optionNames.includes(key)) { if (key && !optionNames.includes(key)) {
//* check if key is not en empty string //* check if key is not en empty string
this.form.colors_style.value.colors[key] = "#000000"; //* add new entry this.form.colors_style.value.colors[key] = '#000000'; //* add new entry
} }
} }
//* if modified or deleted //* if modified or deleted
} else { } else {
let modifiedColorStyle = {}; const modifiedColorStyle = {};
for (const [index, key] of newOptions.entries()) { for (const [index, key] of newOptions.entries()) {
//* if no key then item will disappear (deleted) //* if no key then item will disappear (deleted)
if (key) { if (key) {
...@@ -402,10 +488,12 @@ export default { ...@@ -402,10 +488,12 @@ export default {
}, },
updateStore() { updateStore() {
this.$store.commit("feature_type/UPDATE_FORM", { this.UPDATE_FORM({
color: this.form.color, color: this.form.color,
title: this.form.title, title: this.form.title,
title_optional: this.form.title_optional, title_optional: this.form.title_optional,
enable_key_doc_notif: this.form.enable_key_doc_notif,
disable_notification: this.form.disable_notification,
geom_type: this.form.geom_type, geom_type: this.form.geom_type,
colors_style: this.form.colors_style, colors_style: this.form.colors_style,
}); });
...@@ -413,12 +501,13 @@ export default { ...@@ -413,12 +501,13 @@ export default {
checkCustomForms() { checkCustomForms() {
let is_valid = true; let is_valid = true;
if (this.$refs.customForms) if (this.$refs.customForms) {
for (const customForm of this.$refs.customForms) { for (const customForm of this.$refs.customForms) {
if (customForm.checkCustomForm() === false) { if (customForm.checkCustomForm() === false) {
is_valid = false; is_valid = false;
} }
} }
}
return is_valid; //* fallback if all customForms returned true return is_valid; //* fallback if all customForms returned true
}, },
...@@ -427,21 +516,21 @@ export default { ...@@ -427,21 +516,21 @@ export default {
this.form.title.errors = []; this.form.title.errors = [];
return this.checkCustomForms(); //* if customForms are ok, validate, if get out function return this.checkCustomForms(); //* if customForms are ok, validate, if get out function
} else if ( } else if (
!this.form.title.errors.includes("Veuillez compléter ce champ.") !this.form.title.errors.includes('Veuillez compléter ce champ.')
) { ) {
this.form.title.errors.push("Veuillez compléter ce champ."); this.form.title.errors.push('Veuillez compléter ce champ.');
document document
.getElementById("errorlist") .getElementById('errorlist')
.scrollIntoView({ block: "end", inline: "nearest" }); .scrollIntoView({ block: 'end', inline: 'nearest' });
} }
return false; return false;
}, },
goBackToProject(message) { goBackToProject(message) {
this.$router.push({ this.$router.push({
name: "project_detail", name: 'project_detail',
params: { params: {
slug: this.project.slug, slug: this.slug,
message, message,
}, },
}); });
...@@ -449,164 +538,228 @@ export default { ...@@ -449,164 +538,228 @@ export default {
sendFeatureType() { sendFeatureType() {
// * si édition d'une feature_type déja existante, faire un put // * si édition d'une feature_type déja existante, faire un put
const requestType = this.action === "edit" ? "put" : "post"; const requestType = this.action === 'edit' ? 'put' : 'post';
if (this.checkForms()) { if (this.checkForms()) {
this.$store this.SEND_FEATURE_TYPE(requestType)
.dispatch("feature_type/SEND_FEATURE_TYPE", requestType) .then((response) => {
.then(({ status }) => { const { status, data } = response;
if (status === 200) { if (status === 200) {
this.goBackToProject("Le type de signalement a été mis à jour"); this.goBackToProject({ comment: 'Le type de signalement a été mis à jour', level: 'positive' });
} else if (status === 201) { } else if (status === 201) {
this.goBackToProject("Le nouveau type de signalement a été créé"); this.goBackToProject({ comment: 'Le nouveau type de signalement a été créé', level: 'positive' });
} else { } else {
this.displayMessage( let comment = 'Une erreur est survenue lors de l\'import du type de signalement';
"Une erreur est survenue lors de l'import du type de signalement" if (data.customfield_set) {
); let errors = data.customfield_set.find((el) => el.options);
if (errors.options) {
let customFieldError = errors.options[0];
if(customFieldError) comment = customFieldError[0].replace('ce champ', 'chaque option de champ personnalisé');
}
}
this.DISPLAY_MESSAGE({
comment,
level: 'negative'
});
} }
}); });
} }
}, },
postFeatures(feature_type_slug) { postGeojsonFeatures(feature_type_slug) {
this.SEND_FEATURES_FROM_GEOJSON({
slug: this.slug,
feature_type_slug,
geojson: this.geojson || this.json
})
.then((response) => {
if (response && response.status === 200) {
this.goBackToProject({
comment: 'Le nouveau type de signalement a été créé. L\'import des signalements est en cours',
level: 'positive'
});
} else {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenue lors de l'import de signalements.\n ${ response.data.detail }`,
level: 'negative'
});
}
this.loading = false;
})
.catch(() => {
this.loading = false;
});
},
postCSVFeatures(feature_type_slug) {
this.$store this.$store
.dispatch("feature_type/SEND_FEATURES_FROM_GEOJSON", { .dispatch('feature-type/SEND_FEATURES_FROM_CSV', {
slug: this.$route.params.slug, slug: this.slug,
feature_type_slug, feature_type_slug,
csv: this.csv
}) })
.then((response) => { .then((response) => {
if (response.status === 200) { if (response && response.status === 200) {
this.goBackToProject(); this.goBackToProject({
comment: 'Le nouveau type de signalement a été créé. Import des signalements est en cours',
level: 'positive'
});
} else { } else {
this.displayMessage( this.DISPLAY_MESSAGE({
"Une erreur est survenue lors de l'import de signalements.\n " + comment: `Une erreur est survenue lors de l'import de signalements.\n ${ response.data.detail }`,
response.data.detail level: 'negative'
); });
} }
this.loading = false;
})
.catch(() => {
this.loading = false;
}); });
}, },
async postFeatureTypeThenFeatures() { async postFeatureTypeThenFeatures() {
const requestType = this.action === "edit" ? "put" : "post"; const requestType = this.action === 'edit' ? 'put' : 'post';
if (this.checkForms()) { if (this.checkForms()) {
this.loading = true;
await this.$store await this.$store
.dispatch("feature_type/SEND_FEATURE_TYPE", requestType) .dispatch('feature-type/SEND_FEATURE_TYPE', requestType)
.then(({ feature_type_slug }) => { .then(({ feature_type_slug }) => {
if (feature_type_slug) { if (feature_type_slug) {
this.postFeatures(feature_type_slug); if (this.geojson || this.json) {
this.postGeojsonFeatures(feature_type_slug);
} else if (this.csv) {
this.postCSVFeatures(feature_type_slug);
}
} else {
this.loading = false;
} }
})
.catch(() => {
this.loading = false;
}); });
} }
}, },
displayMessage(message) {
this.error = message;
document
.getElementById("message")
.scrollIntoView({ block: "end", inline: "nearest" });
},
// ****** Methodes for geojson import ****** // // ****** Methodes for geojson import ****** //
toNewFeatureType() { toNewFeatureType() {
this.$router.push({ this.$router.push({
name: "ajouter-type-signalement", name: 'ajouter-type-signalement',
params: { geojson: this.jsonDict }, params: { geojson: this.jsonDict },
}); });
}, },
translateLabel(value) { /**
if (value === "LineString") { * Builds custom form fields based on the properties of data entries.
return "linestring"; *
} else if (value === "Polygon" || value === "MultiPolygon") { * This function iterates through a subset of data entries (such as rows from a CSV, JSON objects, or GeoJSON features)
return "polygon"; * to determine the most appropriate type for each field. It tracks confirmed types to avoid redundant checks and
* stops processing a field once its type is definitively determined. If a field is initially detected as a 'char',
* it remains as 'char' unless a multiline text ('text') is detected later. The function prioritizes the detection
* of definitive types (like 'text', 'boolean', 'integer') and updates the form with the confirmed types.
*
* @param {Array} propertiesList - An array of data entries, where each entry is an object representing a set of properties.
*/
buildCustomForm(propertiesList) {
const confirmedTypes = {}; // Store confirmed types for each field
const detectedAsChar = {}; // Track fields initially detected as 'char'
// Iterate over each row or feature in the subset
propertiesList.forEach((properties) => {
for (const [key, val] of Object.entries(properties)) {
if (!reservedKeywords.includes(key)) {
// If the type for this field has already been confirmed as something other than 'char', skip it
if (confirmedTypes[key] && confirmedTypes[key] !== 'char') {
continue;
}
// Determine the type of the current value
const detectedType = transformProperties(val);
if (detectedType === 'text') {
// Once 'text' (multiline) is detected, confirm it immediately
confirmedTypes[key] = 'text';
} else if (!confirmedTypes[key] && detectedType !== 'char') {
// If a type is detected that is not 'char' and not yet confirmed, confirm it
confirmedTypes[key] = detectedType;
} else if (!confirmedTypes[key]) {
// If this field hasn't been confirmed yet, initialize it as 'char'
confirmedTypes[key] = 'char';
detectedAsChar[key] = true;
} else if (detectedAsChar[key] && detectedType !== 'char') {
// If a field was initially detected as 'char' but now has a different type, update it
confirmedTypes[key] = detectedType;
delete detectedAsChar[key]; // Remove from 'char' tracking once updated
}
}
}
});
// Build custom forms using the confirmed types
for (const [key, confirmedType] of Object.entries(confirmedTypes)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // use dataKey already incremented by addCustomForm
field_type: { value: confirmedType }, // use the confirmed type
options: { value: [] }, // not available in export
};
this.addCustomForm(customForm);
} }
return "point";
}, },
transformProperties(prop) { setTitleFromFile() {
const type = typeof prop; if (this.fileToImport && this.fileToImport.name) {
const date = new Date(prop); this.form.title.value = // * use the filename as title by default
if (type === "boolean") { this.fileToImport.name.split('.')[0];
return "boolean"; } else { //* case when the data comes from datasud catalog
} else if (Number.isSafeInteger(prop)) { // * use the typename as title by default
return "integer"; this.form.title.value = this.geojson.name || this.csv.name || this.json.name;
} else if (
type === "string" &&
date instanceof Date &&
!isNaN(date.valueOf())
) {
return "date";
} else if (type === "number" && !isNaN(parseFloat(prop))) {
return "decimal";
} }
return "char"; //* string by default, most accepted type in database
}, },
importGeoJsonFeatureType() { importGeoJsonFeatureType() {
if (this.geojson.features && this.geojson.features.length) { if (this.geojson.features && this.geojson.features.length) {
//* in order to get feature_type properties, the first feature is enough const { geometry } = this.geojson.features[0];
const { properties, geometry } = this.geojson.features[0]; this.form.geom_type.value = geometry.type.toLowerCase();
this.form.title.value = properties.feature_type; this.updateStore(); // register geom_type in store
this.form.geom_type.value = this.translateLabel(geometry.type);
this.updateStore(); //* register title & geom_type in store // Use a subset of the first N features to build the form
const subsetFeatures = this.geojson.features.slice(0, 200); // Adjust '200' based on performance needs
//* loop properties to create a customForm for each of them const propertiesList = subsetFeatures.map(feature => feature.properties);
for (const [key, val] of Object.entries(properties)) { this.buildCustomForm(propertiesList);
//* check that the property is not a keyword from the backend or map style
// todo: add map style keywords
if (!this.reservedKeywords.includes(key)) {
const customForm = {
label: { value: key || "" },
name: { value: key || "" },
position: this.dataKey, // * use dataKey already incremented by addCustomForm
field_type: { value: this.transformProperties(val) }, // * guessed from the type
options: { value: [] }, // * not available in export
};
this.addCustomForm(customForm);
}
}
} }
this.setTitleFromFile();
}, },
},
created() { importCSVFeatureType() {
if (!this.project) { if (this.csv.length) {
this.$store.dispatch("GET_PROJECT_INFO", this.$route.params.slug); this.updateStore(); // register title in store
}
this.$store.commit(
"feature_type/SET_CURRENT_FEATURE_TYPE_SLUG",
this.$route.params.slug_type_signal
);
this.definePageType(); // Use a subset of the first N rows to build the form
}, const subsetCSV = this.csv.slice(0, 200); // Adjust '200' based on performance needs
this.buildCustomForm(subsetCSV);
mounted() { // Check for geom data
if (this.action === "edit" || this.action === "duplicate") { if (!('lat' in this.csv[0]) || !('lon' in this.csv[0])) {
if (this.feature_type) { this.form.geom_type.value = 'none';
//* add datas from store to state to avoid mutating directly store with v-model (not good practice), could have used computed with getter and setter as well }
this.fillFormData(this.feature_type);
}
if (this.action === "duplicate") {
//* replace original name with new default title
this.form.title.value += ` (Copie-${new Date()
.toLocaleString()
.slice(0, -3)
.replace(",", "")})`;
this.updateStore(); // * initialize form in store in case this.form would not be modified
} }
} this.setTitleFromFile();
//* when creation from a geojson },
if (this.geojson) {
this.importGeoJsonFeatureType(); importJsonFeatureType() {
if (this.$store.state.feature_type.fileToImport.name) { if (this.json.length) {
this.form.title.value = // * use the filename as title by default this.form.geom_type.value = 'none'; // JSON are non-geom features
this.$store.state.feature_type.fileToImport.name.split(".")[0]; this.updateStore(); // register title in store
// Use a subset of the first N objects to build the form
const subsetJson = this.json.slice(0, 200); // Adjust '200' based on performance needs
this.buildCustomForm(subsetJson);
} }
} this.setTitleFromFile();
}, },
beforeDestroy() {
this.$store.commit("feature_type/EMPTY_FORM");
this.$store.commit("feature_type/EMPTY_CUSTOM_FORMS");
}, },
}; };
</script> </script>
......
<template> <template>
<div v-if="flatpage" class="row"> <div
v-if="flatpage"
class="row"
>
<div class="ten wide column"> <div class="ten wide column">
<h1>{{ flatpage.title }}</h1> <h1>{{ flatpage.title }}</h1>
<div v-html="flatpage.content"></div> <!-- eslint-disable vue/no-v-html -->
<div v-html="flatpage.content" />
<!--eslint-enable-->
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { mapState } from "vuex"; import { mapState } from 'vuex';
export default { export default {
name: "Default", name: 'Help',
computed: { computed: {
...mapState(["staticPages"]), ...mapState(['staticPages']),
flatpage() { flatpage() {
if (this.staticPages) { if (this.staticPages) {
return this.staticPages.find( return this.staticPages.find(
......
...@@ -2,19 +2,27 @@ ...@@ -2,19 +2,27 @@
<div class="row"> <div class="row">
<div class="ten wide column"> <div class="ten wide column">
<h1>{{ flatpage.title }}</h1> <h1>{{ flatpage.title }}</h1>
<div v-html="flatpage.content"></div> <!-- eslint-disable vue/no-v-html -->
<div v-html="flatpage.content" />
<!--eslint-enable-->
<div class="ui right rail"> <div class="ui right rail">
<div id="toc-container" class="ui sticky fixed"> <div
<h4 class="ui header">Table des matières</h4> id="toc-container"
<div id="page-toc" class="ui vertical large text menu"> class="ui sticky fixed"
>
<h4 class="ui header">
Table des matières
</h4>
<div
id="page-toc"
class="ui vertical large text menu"
>
<a <a
v-for="h2 in sections" v-for="h2 in sections"
:key="h2.id" :key="h2.id"
class="item" class="item"
:href="'#' + h2.id" :href="'#' + h2.id"
>{{ h2.text }}</a >{{ h2.text }}</a>
>
</div> </div>
</div> </div>
</div> </div>
...@@ -23,10 +31,10 @@ ...@@ -23,10 +31,10 @@
</template> </template>
<script> <script>
import { mapState } from "vuex"; import { mapState } from 'vuex';
export default { export default {
name: "With_right_menu", name: 'Mentions',
data() { data() {
return { return {
...@@ -35,7 +43,7 @@ export default { ...@@ -35,7 +43,7 @@ export default {
}, },
computed: { computed: {
...mapState(["staticPages"]), ...mapState(['staticPages']),
flatpage() { flatpage() {
if (this.staticPages) { if (this.staticPages) {
return this.staticPages.find( return this.staticPages.find(
...@@ -45,20 +53,26 @@ export default { ...@@ -45,20 +53,26 @@ export default {
return null; return null;
}, },
}, },
mounted() {
this.$nextTick(() => {
// The whole view is rendered, so we can safely access or query the DOM.
this.createMenu();
});
},
methods: { methods: {
createMenu() { createMenu() {
// parse the ToC content (looking for h2 elements) // parse the ToC content (looking for h2 elements)
let list = document.querySelectorAll("h2"); const list = document.querySelectorAll('h2');
let tocArr = []; const tocArr = [];
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
let e = list[i]; const e = list[i];
let id = e.id; let id = e.id;
// add id in html if not present // add id in html if not present
if (id === "") { if (id === '') {
id = "toc-id-" + i; id = 'toc-id-' + i;
e.id = id; e.id = id;
} }
...@@ -70,11 +84,5 @@ export default { ...@@ -70,11 +84,5 @@ export default {
this.sections = tocArr; this.sections = tocArr;
}, },
}, },
mounted() {
this.$nextTick(() => {
// The whole view is rendered, so we can safely access or query the DOM.
this.createMenu();
});
},
}; };
</script> </script>
<template>
<div class="fourteen wide column">
<img class="ui centered small image" :src="logo" />
<!-- :src="LOGO_PATH" -->
<h2 class="ui center aligned icon header">
<div class="content">
{{ APPLICATION_NAME }}
<div class="sub header">{{ APPLICATION_ABSTRACT }}</div>
</div>
</h2>
<h4 id="les_projets" class="ui horizontal divider header">PROJETS</h4>
<div class="flex">
<router-link
v-if="user && user.can_create_project && isOffline() != true"
:to="{ name: 'project_create', params: { action: 'create' } }"
class="ui green basic button"
>
<i class="plus icon"></i> Créer un nouveau projet
</router-link>
<router-link
v-if="user && user.can_create_project && isOffline() != true"
:to="{
name: 'project_type_list',
}"
class="ui blue basic button"
>
<i class="copy icon"></i> Accéder à la liste des modèles de projets
</router-link>
</div>
<div v-if="projects" class="ui divided items">
<div v-for="project in projects" class="item" :key="project.slug">
<div class="ui tiny image">
<img
:src="
!project.thumbnail
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
/>
</div>
<div class="middle aligned content">
<router-link
:to="{
name: 'project_detail',
params: { slug: project.slug },
}"
class="header"
>{{ project.title }}</router-link
>
<div class="description">
<p>{{ project.description }}</p>
</div>
<div class="meta">
<span class="right floated">
<strong v-if="project.moderation">Projet modéré</strong>
<strong v-else>Projet non modéré</strong>
</span>
<span
>Niveau d'autorisation requis :
{{ project.access_level_pub_feature }}</span
><br />
<span>
Mon niveau d'autorisation :
<span v-if="USER_LEVEL_PROJECTS && project">{{
USER_LEVEL_PROJECTS[project.slug]
}}</span>
<span v-if="user && user.is_administrator">{{
"+ Gestionnaire métier"
}}</span>
</span>
</div>
<div class="meta">
<span class="right floated">
<i class="calendar icon"></i>&nbsp; {{ project.created_on }}
</span>
<span data-tooltip="Membres">
{{ project.nb_contributors }}&nbsp;<i class="user icon"></i>
</span>
<span data-tooltip="Signalements">
{{ project.nb_published_features }}&nbsp;<i
class="map marker icon"
></i>
</span>
<span data-tooltip="Commentaires">
{{ project.nb_published_features_comments }}&nbsp;<i
class="comment icon"
></i>
</span>
</div>
</div>
</div>
<span v-if="!projects || projects.length === 0"
>Vous n'avez accès à aucun projet.</span
>
<div class="item"></div>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
export default {
name: "Index",
computed: {
...mapState(["projects", "user", "USER_LEVEL_PROJECTS"]),
APPLICATION_NAME() {
return this.$store.state.configuration.VUE_APP_APPLICATION_NAME;
},
APPLICATION_ABSTRACT() {
return this.$store.state.configuration.VUE_APP_APPLICATION_ABSTRACT;
},
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
logo() {
return this.$store.state.configuration.VUE_APP_LOGO_PATH;
},
},
methods: {
isOffline() {
return navigator.onLine == false;
},
refreshId() {
//* change path of thumbnail to update image
return "?ver=" + Math.random();
},
},
created() {
if (this.$store.getters.project) {
this.$store.commit("SET_PROJECT_SLUG", null);
}
},
};
</script>
<style scoped>
.flex {
display: flex;
justify-content: space-between;
}
</style>
\ No newline at end of file