diff --git a/Gemfile b/Gemfile
index 14407ddc5..be4cfbaea 100644
--- a/Gemfile
+++ b/Gemfile
@@ -28,6 +28,7 @@ gem 'dotenv-rails', require: 'dotenv/rails-now' # dotenv should always be loaded
gem 'flipper'
gem 'flipper-active_record'
gem 'flipper-ui'
+gem 'font-awesome-rails'
gem 'fugit'
gem 'geocoder'
gem 'gon'
diff --git a/Gemfile.lock b/Gemfile.lock
index 7e80e2d2b..5544e433f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -236,6 +236,8 @@ GEM
fog-core (~> 2.1)
fog-json (>= 1.0)
ipaddress (>= 0.8)
+ font-awesome-rails (4.7.0.5)
+ railties (>= 3.2, < 6.1)
formatador (0.2.5)
fugit (1.3.3)
et-orbi (~> 1.1, >= 1.1.8)
@@ -751,6 +753,7 @@ DEPENDENCIES
flipper
flipper-active_record
flipper-ui
+ font-awesome-rails
fugit
geocoder
gon
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 121de171b..ee4b980da 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -33,6 +33,7 @@
// = require attestation_template_edit
// = require_self
+// = require font-awesome
// = require leaflet
// = require franceconnect
// = require bootstrap-wysihtml5
diff --git a/app/controllers/champs/carte_controller.rb b/app/controllers/champs/carte_controller.rb
index 4459ab781..d1be197af 100644
--- a/app/controllers/champs/carte_controller.rb
+++ b/app/controllers/champs/carte_controller.rb
@@ -39,9 +39,10 @@ class Champs::CarteController < ApplicationController
end
end
- selection_utilisateur = ApiCartoService.generate_selection_utilisateur(coordinates)
- selection_utilisateur[:source] = GeoArea.sources.fetch(:selection_utilisateur)
- geo_areas << selection_utilisateur
+ selections_utilisateur = legacy_selections_utilisateur_to_polygons(coordinates)
+ geo_areas += selections_utilisateur.map do |selection_utilisateur|
+ selection_utilisateur.merge(source: GeoArea.sources.fetch(:selection_utilisateur))
+ end
@champ.geo_areas = geo_areas.map do |geo_area|
GeoArea.new(geo_area)
@@ -58,4 +59,17 @@ class Champs::CarteController < ApplicationController
flash.alert = 'Les données cartographiques sont temporairement indisponibles. Réessayez dans un instant.'
response.status = 503
end
+
+ private
+
+ def legacy_selections_utilisateur_to_polygons(coordinates)
+ coordinates.map do |lat_longs|
+ {
+ geometry: {
+ type: 'Polygon',
+ coordinates: [lat_longs.map { |lat_long| [lat_long['lng'], lat_long['lat']] }]
+ }
+ }
+ end
+ end
end
diff --git a/app/helpers/champ_helper.rb b/app/helpers/champ_helper.rb
index 1e51c662d..b41d81fa2 100644
--- a/app/helpers/champ_helper.rb
+++ b/app/helpers/champ_helper.rb
@@ -37,4 +37,18 @@ module ChampHelper
champs_piece_justificative_url(object.id)
end
end
+
+ def geo_area_label(geo_area)
+ case geo_area.source
+ when GeoArea.sources.fetch(:cadastre)
+ capture do
+ concat "Parcelle n° #{geo_area.numero} - Feuille #{geo_area.code_arr} #{geo_area.section} #{geo_area.feuille} - #{geo_area.surface_parcelle.round} m"
+ concat content_tag(:sup, "2")
+ end
+ when GeoArea.sources.fetch(:quartier_prioritaire)
+ "#{geo_area.commune} : #{geo_area.nom}"
+ when GeoArea.sources.fetch(:parcelle_agricole)
+ "Culture : #{geo_area.culture} - Surface : #{geo_area.surface} ha"
+ end
+ end
end
diff --git a/app/javascript/components/MapReader.js b/app/javascript/components/MapReader.js
index aaa2380ab..2e3c16fa5 100644
--- a/app/javascript/components/MapReader.js
+++ b/app/javascript/components/MapReader.js
@@ -1,48 +1,36 @@
import React from 'react';
import ReactMapboxGl, { ZoomControl, GeoJSONLayer } from 'react-mapbox-gl';
-import mapboxgl, { LngLatBounds } from 'mapbox-gl';
+import mapboxgl from 'mapbox-gl';
import PropTypes from 'prop-types';
const Map = ReactMapboxGl({});
-const MapReader = ({ geoData }) => {
- let [selectionCollection, cadastresCollection] = [[], []];
+const MapReader = ({ featureCollection }) => {
+ const [a1, a2, b1, b2] = featureCollection.bbox;
+ const boundData = [
+ [a1, a2],
+ [b1, b2]
+ ];
- for (let selection of geoData.selection.coordinates) {
- selectionCollection.push({
- type: 'Feature',
- geometry: {
- type: 'Polygon',
- coordinates: selection
- }
- });
- }
-
- for (let cadastre of geoData.cadastres) {
- cadastresCollection.push({
- type: 'Feature',
- geometry: {
- type: 'Polygon',
- coordinates: cadastre.geometry.coordinates[0]
- }
- });
- }
-
- const selectionData = {
- type: 'geojson',
- data: {
- type: 'FeatureCollection',
- features: selectionCollection
- }
+ const selectionsFeatureCollection = {
+ type: 'FeatureCollection',
+ features: []
+ };
+ const cadastresFeatureCollection = {
+ type: 'FeatureCollection',
+ features: []
};
- const cadastresData = {
- type: 'geojson',
- data: {
- type: 'FeatureCollection',
- features: cadastresCollection
+ for (let feature of featureCollection.features) {
+ switch (feature.properties.source) {
+ case 'selection_utilisateur':
+ selectionsFeatureCollection.features.push(feature);
+ break;
+ case 'cadastre':
+ cadastresFeatureCollection.features.push(feature);
+ break;
}
- };
+ }
const polygonSelectionFill = {
'fill-color': '#EC3323',
@@ -65,19 +53,6 @@ const MapReader = ({ geoData }) => {
'line-dasharray': [1, 1]
};
- let bounds = new LngLatBounds();
-
- for (let selection of selectionCollection) {
- for (let coordinate of selection.geometry.coordinates[0]) {
- bounds.extend(coordinate);
- }
- }
- let [swCoords, neCoords] = [
- Object.values(bounds._sw),
- Object.values(bounds._ne)
- ];
- const boundData = [swCoords, neCoords];
-
if (!mapboxgl.supported()) {
return (
@@ -99,12 +74,12 @@ const MapReader = ({ geoData }) => {
}}
>
@@ -114,10 +89,10 @@ const MapReader = ({ geoData }) => {
};
MapReader.propTypes = {
- geoData: PropTypes.shape({
- position: PropTypes.object,
- selection: PropTypes.object,
- cadastres: PropTypes.array
+ featureCollection: PropTypes.shape({
+ type: PropTypes.string,
+ bbox: PropTypes.array,
+ features: PropTypes.array
})
};
diff --git a/app/javascript/new_design/champs/carte.js b/app/javascript/new_design/champs/carte.js
index 2ae19ac8d..a5b6c2ebe 100644
--- a/app/javascript/new_design/champs/carte.js
+++ b/app/javascript/new_design/champs/carte.js
@@ -16,10 +16,6 @@ async function loadAndDrawMap(element) {
const { drawEditableMap } = await import('../../shared/carte-editor');
drawEditableMap(element, data);
- } else {
- const { drawMap } = await import('../../shared/carte');
-
- drawMap(element, data);
}
}
diff --git a/app/javascript/new_design/dossiers/auto-upload-controller.js b/app/javascript/new_design/dossiers/auto-upload-controller.js
index ad4eec39c..a2780a65e 100644
--- a/app/javascript/new_design/dossiers/auto-upload-controller.js
+++ b/app/javascript/new_design/dossiers/auto-upload-controller.js
@@ -1,6 +1,6 @@
import Uploader from '../../shared/activestorage/uploader';
-import ProgressBar from '../../shared/activestorage/progress-bar';
-import { ajax, show, hide, toggle } from '@utils';
+import { show, hide, toggle } from '@utils';
+import { FAILURE_CONNECTIVITY } from '../../shared/activestorage/file-upload-error';
// Given a file input in a champ with a selected file, upload a file,
// then attach it to the dossier.
@@ -11,27 +11,21 @@ export default class AutoUploadController {
constructor(input, file) {
this.input = input;
this.file = file;
+ this.uploader = new Uploader(
+ input,
+ file,
+ input.dataset.directUploadUrl,
+ input.dataset.autoAttachUrl
+ );
}
+ // Create, upload and attach the file.
+ // On failure, display an error message and throw a FileUploadError.
async start() {
try {
this._begin();
-
- // Sanity checks
- const autoAttachUrl = this.input.dataset.autoAttachUrl;
- if (!autoAttachUrl) {
- throw new Error('L’attribut "data-auto-attach-url" est manquant');
- }
-
- // Upload the file (using Direct Upload)
- let blobSignedId = await this._upload();
-
- // Attach the blob to the champ
- // (The request responds with Javascript, which displays the attachment HTML fragment).
- await this._attach(blobSignedId, autoAttachUrl);
-
- // Everything good: clear the original file input value
- this.input.value = null;
+ await this.uploader.start();
+ this._succeeded();
} catch (error) {
this._failed(error);
throw error;
@@ -45,35 +39,8 @@ export default class AutoUploadController {
this._hideErrorMessage();
}
- async _upload() {
- const uploader = new Uploader(
- this.input,
- this.file,
- this.input.dataset.directUploadUrl
- );
- return await uploader.start();
- }
-
- async _attach(blobSignedId, autoAttachUrl) {
- // Now that the upload is done, display a new progress bar
- // to show that the attachment request is still pending.
- const progressBar = new ProgressBar(
- this.input,
- `${this.input.id}-progress-bar`,
- this.file
- );
- progressBar.progress(100);
- progressBar.end();
-
- const attachmentRequest = {
- url: autoAttachUrl,
- type: 'PUT',
- data: `blob_signed_id=${blobSignedId}`
- };
- await ajax(attachmentRequest);
-
- // The progress bar has been destroyed by the attachment HTML fragment that replaced the input,
- // so no further cleanup is needed.
+ _succeeded() {
+ this.input.value = null;
}
_failed(error) {
@@ -81,56 +48,39 @@ export default class AutoUploadController {
return;
}
- let progressBar = this.input.parentElement.querySelector('.direct-upload');
- if (progressBar) {
- progressBar.remove();
- }
+ this.uploader.progressBar.destroy();
- this._displayErrorMessage(error);
+ let message = this._messageFromError(error);
+ this._displayErrorMessage(message);
}
_done() {
this.input.disabled = false;
}
- _isError422(error) {
- // Ajax errors have an xhr attribute
- if (error && error.xhr && error.xhr.status == 422) return true;
- // Rails DirectUpload errors are returned as a String, e.g. 'Error creating Blob for "Demain.txt". Status: 422'
- if (error && error.toString().includes('422')) return true;
-
- return false;
- }
-
_messageFromError(error) {
- let allowRetry = !this._isError422(error);
+ let message = error.message || error.toString();
+ let canRetry = error.status && error.status != 422;
- if (
- error.xhr &&
- error.xhr.status == 422 &&
- error.response &&
- error.response.errors &&
- error.response.errors[0]
- ) {
+ if (error.failureReason == FAILURE_CONNECTIVITY) {
return {
- title: error.response.errors[0],
- description: '',
- retry: allowRetry
+ title: 'Le fichier n’a pas pu être envoyé.',
+ description: 'Vérifiez votre connexion à Internet, puis ré-essayez.',
+ retry: true
};
} else {
return {
- title: 'Une erreur s’est produite pendant l’envoi du fichier.',
- description: error.message || error.toString(),
- retry: allowRetry
+ title: 'Le fichier n’a pas pu être envoyé.',
+ description: message,
+ retry: canRetry
};
}
}
- _displayErrorMessage(error) {
+ _displayErrorMessage(message) {
let errorNode = this.input.parentElement.querySelector('.attachment-error');
if (errorNode) {
show(errorNode);
- let message = this._messageFromError(error);
errorNode.querySelector('.attachment-error-title').textContent =
message.title || '';
errorNode.querySelector('.attachment-error-description').textContent =
diff --git a/app/javascript/new_design/dossiers/auto-uploads-controllers.js b/app/javascript/new_design/dossiers/auto-uploads-controllers.js
index a50b083ac..ad68b081d 100644
--- a/app/javascript/new_design/dossiers/auto-uploads-controllers.js
+++ b/app/javascript/new_design/dossiers/auto-uploads-controllers.js
@@ -1,5 +1,6 @@
import Rails from '@rails/ujs';
import AutoUploadController from './auto-upload-controller.js';
+import { FAILURE_CONNECTIVITY } from '../../shared/activestorage/file-upload-error';
// Manage multiple concurrent uploads.
//
@@ -17,6 +18,11 @@ export default class AutoUploadsControllers {
try {
let controller = new AutoUploadController(input, file);
await controller.start();
+ } catch (error) {
+ // Report errors to Sentry (except connectivity issues)
+ if (error.failureReason != FAILURE_CONNECTIVITY) {
+ throw error;
+ }
} finally {
this._decrementInFlightUploads(form);
}
diff --git a/app/javascript/shared/activestorage/errors.js b/app/javascript/shared/activestorage/errors.js
deleted file mode 100644
index 072e99b5c..000000000
--- a/app/javascript/shared/activestorage/errors.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Convert an error message returned by DirectUpload to a proper error object.
-//
-// This function has two goals:
-// 1. Remove the file name from the DirectUpload error message
-// (because the filename confuses Sentry error grouping)
-// 2. Create each kind of error on a different line
-// (so that Sentry knows they are different kind of errors, from
-// the line they were created.)
-export default function errorFromDirectUploadMessage(message) {
- let matches = message.match(/ Status: [0-9]{1,3}/);
- let status = (matches && matches[0]) || '';
-
- if (message.includes('Error creating')) {
- return new Error('Error creating file.' + status);
- } else if (message.includes('Error storing')) {
- return new Error('Error storing file.' + status);
- } else if (message.includes('Error reading')) {
- return new Error('Error reading file.' + status);
- } else {
- return new Error(message);
- }
-}
diff --git a/app/javascript/shared/activestorage/file-upload-error.js b/app/javascript/shared/activestorage/file-upload-error.js
new file mode 100644
index 000000000..360557956
--- /dev/null
+++ b/app/javascript/shared/activestorage/file-upload-error.js
@@ -0,0 +1,67 @@
+// Error while reading the file client-side
+export const ERROR_CODE_READ = 'file-upload-read-error';
+// Error while creating the empty blob on the server
+export const ERROR_CODE_CREATE = 'file-upload-create-error';
+// Error while uploading the blob content
+export const ERROR_CODE_STORE = 'file-upload-store-error';
+// Error while attaching the blob to the record
+export const ERROR_CODE_ATTACH = 'file-upload-attach-error';
+
+// Failure on the client side (syntax error, error reading file, etc.)
+export const FAILURE_CLIENT = 'file-upload-failure-client';
+// Failure on the server side (typically non-200 response)
+export const FAILURE_SERVER = 'file-upload-failure-server';
+// Failure during the transfert (request aborted, connection lost, etc)
+export const FAILURE_CONNECTIVITY = 'file-upload-failure-connectivity';
+
+/**
+ Represent an error during a file upload.
+ */
+export default class FileUploadError extends Error {
+ constructor(message, status, code) {
+ super(message);
+ this.name = 'FileUploadError';
+ this.status = status;
+ this.code = code;
+ }
+
+ /**
+ Return the component responsible of the error (client, server or connectivity).
+ See FAILURE_* constants for values.
+ */
+ get failureReason() {
+ let isNetworkError = this.code != ERROR_CODE_READ;
+
+ if (isNetworkError && this.status != 0) {
+ return FAILURE_SERVER;
+ } else if (isNetworkError && this.status == 0) {
+ return FAILURE_CONNECTIVITY;
+ } else {
+ return FAILURE_CLIENT;
+ }
+ }
+}
+
+// Convert an error message returned by DirectUpload to a proper error object.
+//
+// This function has two goals:
+// 1. Remove the file name from the DirectUpload error message
+// (because the filename confuses Sentry error grouping)
+// 2. Create each kind of error on a different line
+// (so that Sentry knows they are different kind of errors, from
+// the line they were created.)
+export function errorFromDirectUploadMessage(message) {
+ let matches = message.match(/ Status: [0-9]{1,3}/);
+ let status = (matches && parseInt(matches[0], 10)) || undefined;
+
+ // prettier-ignore
+ if (message.includes('Error reading')) {
+ return new FileUploadError('Error reading file.', status, ERROR_CODE_READ);
+ } else if (message.includes('Error creating')) {
+ return new FileUploadError('Error creating file.', status, ERROR_CODE_CREATE);
+ } else if (message.includes('Error storing')) {
+ return new FileUploadError('Error storing file.', status, ERROR_CODE_STORE);
+ } else {
+ return new FileUploadError(message, status, undefined);
+ }
+}
diff --git a/app/javascript/shared/activestorage/ujs.js b/app/javascript/shared/activestorage/ujs.js
index aa3530ec2..bdc3b5031 100644
--- a/app/javascript/shared/activestorage/ujs.js
+++ b/app/javascript/shared/activestorage/ujs.js
@@ -1,5 +1,8 @@
import ProgressBar from './progress-bar';
-import errorFromDirectUploadMessage from './errors';
+import {
+ errorFromDirectUploadMessage,
+ FAILURE_CONNECTIVITY
+} from './file-upload-error';
import { fire } from '@utils';
const INITIALIZE_EVENT = 'direct-upload:initialize';
@@ -56,7 +59,9 @@ addUploadEventListener(ERROR_EVENT, event => {
ProgressBar.error(id, errorMsg);
let error = errorFromDirectUploadMessage(errorMsg);
- fire(document, 'sentry:capture-exception', error);
+ if (error.failureReason != FAILURE_CONNECTIVITY) {
+ fire(document, 'sentry:capture-exception', error);
+ }
});
addUploadEventListener(END_EVENT, ({ detail: { id } }) => {
diff --git a/app/javascript/shared/activestorage/uploader.js b/app/javascript/shared/activestorage/uploader.js
index b303a441e..1d1dcce8b 100644
--- a/app/javascript/shared/activestorage/uploader.js
+++ b/app/javascript/shared/activestorage/uploader.js
@@ -1,35 +1,88 @@
import { DirectUpload } from '@rails/activestorage';
+import { ajax } from '@utils';
import ProgressBar from './progress-bar';
-import errorFromDirectUploadMessage from './errors';
+import FileUploadError, {
+ errorFromDirectUploadMessage,
+ ERROR_CODE_ATTACH
+} from './file-upload-error';
/**
Uploader class is a delegate for DirectUpload instance
used to track lifecycle and progress of an upload.
*/
export default class Uploader {
- constructor(input, file, directUploadUrl) {
+ constructor(input, file, directUploadUrl, autoAttachUrl) {
this.directUpload = new DirectUpload(file, directUploadUrl, this);
this.progressBar = new ProgressBar(input, this.directUpload.id, file);
+ this.autoAttachUrl = autoAttachUrl;
}
- start() {
+ /**
+ Upload (and optionally attach) the file.
+ Returns the blob signed id on success.
+ Throws a FileUploadError on failure.
+ */
+ async start() {
this.progressBar.start();
+ try {
+ let blobSignedId = await this._upload();
+
+ if (this.autoAttachUrl) {
+ await this._attach(blobSignedId);
+ }
+
+ this.progressBar.end();
+ this.progressBar.destroy();
+
+ return blobSignedId;
+ } catch (error) {
+ this.progressBar.error(error.message);
+ throw error;
+ }
+ }
+
+ /**
+ Upload the file using the DirectUpload instance, and return the blob signed_id.
+ Throws a FileUploadError on failure.
+ */
+ async _upload() {
return new Promise((resolve, reject) => {
this.directUpload.create((errorMsg, attributes) => {
if (errorMsg) {
- this.progressBar.error(errorMsg);
let error = errorFromDirectUploadMessage(errorMsg);
reject(error);
} else {
resolve(attributes.signed_id);
}
- this.progressBar.end();
- this.progressBar.destroy();
});
});
}
+ /**
+ Attach the file by sending a POST request to the autoAttachUrl.
+ Throws a FileUploadError on failure (containing the first validation
+ error message, if any).
+ */
+ async _attach(blobSignedId) {
+ const attachmentRequest = {
+ url: this.autoAttachUrl,
+ type: 'PUT',
+ data: `blob_signed_id=${blobSignedId}`
+ };
+
+ try {
+ await ajax(attachmentRequest);
+ } catch (e) {
+ let message = e.response && e.response.errors && e.response.errors[0];
+ throw new FileUploadError(
+ message || 'Error attaching file.',
+ e.xhr.status,
+ ERROR_CODE_ATTACH
+ );
+ }
+ }
+
uploadRequestDidProgress(event) {
const progress = (event.loaded / event.total) * 100;
if (progress) {
diff --git a/app/javascript/shared/carte.js b/app/javascript/shared/carte.js
deleted file mode 100644
index cd0434076..000000000
--- a/app/javascript/shared/carte.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import L from 'leaflet';
-
-const MAPS = new WeakMap();
-
-export function drawMap(element, data) {
- const map = initMap(element, data);
-
- drawCadastre(map, data);
- drawQuartiersPrioritaires(map, data);
- drawParcellesAgricoles(map, data);
- drawUserSelection(map, data);
-}
-
-function initMap(element, { position }) {
- if (MAPS.has(element)) {
- return MAPS.get(element);
- } else {
- const map = L.map(element, {
- scrollWheelZoom: false
- }).setView([position.lat, position.lon], position.zoom);
-
- const loadTilesLayer = process.env.RAILS_ENV != 'test';
- if (loadTilesLayer) {
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
- attribution:
- '© OpenStreetMap contributors'
- }).addTo(map);
- }
-
- MAPS.set(element, map);
- return map;
- }
-}
-
-function drawUserSelection(map, { selection }) {
- if (selection) {
- const layer = L.geoJSON(selection, {
- style: USER_SELECTION_POLYGON_STYLE
- });
-
- layer.addTo(map);
-
- map.fitBounds(layer.getBounds());
- }
-}
-
-function drawCadastre(map, { cadastres }) {
- drawLayer(map, cadastres, noEditStyle(CADASTRE_POLYGON_STYLE));
-}
-
-function drawQuartiersPrioritaires(map, { quartiersPrioritaires }) {
- drawLayer(map, quartiersPrioritaires, noEditStyle(QP_POLYGON_STYLE));
-}
-
-function drawParcellesAgricoles(map, { parcellesAgricoles }) {
- drawLayer(map, parcellesAgricoles, noEditStyle(RPG_POLYGON_STYLE));
-}
-
-function drawLayer(map, data, style) {
- if (Array.isArray(data) && data.length > 0) {
- const layer = new L.GeoJSON(undefined, {
- interactive: false,
- style
- });
-
- for (let { geometry } of data) {
- layer.addData(geometry);
- }
-
- layer.addTo(map);
- }
-}
-
-function noEditStyle(style) {
- return Object.assign({}, style, {
- opacity: 0.7,
- fillOpacity: 0.5,
- color: style.fillColor
- });
-}
-
-const POLYGON_STYLE = {
- weight: 2,
- opacity: 0.3,
- color: 'white',
- dashArray: '3',
- fillOpacity: 0.7
-};
-
-const CADASTRE_POLYGON_STYLE = Object.assign({}, POLYGON_STYLE, {
- fillColor: '#8a6d3b'
-});
-
-const QP_POLYGON_STYLE = Object.assign({}, POLYGON_STYLE, {
- fillColor: '#31708f'
-});
-
-const RPG_POLYGON_STYLE = Object.assign({}, POLYGON_STYLE, {
- fillColor: '#31708f'
-});
-
-const USER_SELECTION_POLYGON_STYLE = {
- color: 'red'
-};
diff --git a/app/models/champs/carte_champ.rb b/app/models/champs/carte_champ.rb
index a12792cc6..23269ac02 100644
--- a/app/models/champs/carte_champ.rb
+++ b/app/models/champs/carte_champ.rb
@@ -1,4 +1,8 @@
class Champs::CarteChamp < Champ
+ # Default map location. Center of the World, ahm, France...
+ DEFAULT_LON = 2.428462
+ DEFAULT_LAT = 46.538192
+
# We are not using scopes here as we want to access
# the following collections on unsaved records.
def cadastres
@@ -19,8 +23,10 @@ class Champs::CarteChamp < Champ
end
end
- def selection_utilisateur
- geo_areas.find(&:selection_utilisateur?)
+ def selections_utilisateur
+ geo_areas.filter do |area|
+ area.source == GeoArea.sources.fetch(:selection_utilisateur)
+ end
end
def cadastres?
@@ -39,76 +45,81 @@ class Champs::CarteChamp < Champ
if dossier.present?
dossier.geo_position
else
- lon = "2.428462"
- lat = "46.538192"
+ lon = DEFAULT_LON.to_s
+ lat = DEFAULT_LAT.to_s
zoom = "13"
{ lon: lon, lat: lat, zoom: zoom }
end
end
- def geo_json
- @geo_json ||= begin
- geo_area = selection_utilisateur
+ def bounding_box
+ factory = RGeo::Geographic.simple_mercator_factory
+ bounding_box = RGeo::Cartesian::BoundingBox.new(factory)
- if geo_area
- geo_area.geometry
- else
- geo_json_from_value
+ if geo_areas.present?
+ geo_areas.each do |area|
+ bounding_box.add(area.rgeo_geometry)
end
+ elsif dossier.present?
+ point = dossier.geo_position
+ bounding_box.add(factory.point(point[:lon], point[:lat]))
+ else
+ bounding_box.add(factory.point(DEFAULT_LON, DEFAULT_LAT))
+ end
+
+ [bounding_box.max_point, bounding_box.min_point].compact.flat_map(&:coordinates)
+ end
+
+ def to_feature_collection
+ {
+ type: 'FeatureCollection',
+ bbox: bounding_box,
+ features: (legacy_selections_utilisateur + except_selections_utilisateur).map(&:to_feature)
+ }
+ end
+
+ def geometry?
+ geo_areas.present?
+ end
+
+ def selection_utilisateur_legacy_geometry
+ if selection_utilisateur_legacy?
+ selections_utilisateur.first.geometry
+ elsif selections_utilisateur.present?
+ {
+ type: 'MultiPolygon',
+ coordinates: selections_utilisateur.filter do |selection_utilisateur|
+ selection_utilisateur.geometry['type'] == 'Polygon'
+ end.map do |selection_utilisateur|
+ selection_utilisateur.geometry['coordinates']
+ end
+ }
+ else
+ nil
end
end
- def selection_utilisateur_size
- if geo_json.present?
- geo_json['coordinates'].size
- else
- 0
+ def selection_utilisateur_legacy_geo_area
+ geometry = selection_utilisateur_legacy_geometry
+ if geometry.present?
+ GeoArea.new(
+ source: GeoArea.sources.fetch(:selection_utilisateur),
+ geometry: geometry
+ )
end
end
def to_render_data
{
position: position,
- selection: user_geo_area&.geometry,
+ selection: selection_utilisateur_legacy_geometry,
quartiersPrioritaires: quartiers_prioritaires? ? quartiers_prioritaires.as_json(except: :properties) : [],
cadastres: cadastres? ? cadastres.as_json(except: :properties) : [],
parcellesAgricoles: parcelles_agricoles? ? parcelles_agricoles.as_json(except: :properties) : []
}
end
- def user_geo_area
- geo_area = selection_utilisateur
-
- if geo_area.present?
- geo_area
- elsif geo_json_from_value.present?
- GeoArea.new(
- geometry: geo_json_from_value,
- source: GeoArea.sources.fetch(:selection_utilisateur)
- )
- end
- end
-
- def geo_json_from_value
- @geo_json_from_value ||= begin
- parsed_value = value.blank? ? nil : JSON.parse(value)
- # We used to store in the value column a json array with coordinates.
- if parsed_value.is_a?(Array)
- # Empty array is sent instead of blank to distinguish between empty and error
- if parsed_value.empty?
- nil
- else
- # If it is a coordinates array, format it as a GEO-JSON
- JSON.parse(GeojsonService.to_json_polygon_for_selection_utilisateur(parsed_value))
- end
- else
- # It is already a GEO-JSON
- parsed_value
- end
- end
- end
-
def for_api
nil
end
@@ -116,4 +127,38 @@ class Champs::CarteChamp < Champ
def for_export
nil
end
+
+ private
+
+ def selection_utilisateur_legacy?
+ if selections_utilisateur.size == 1
+ geometry = selections_utilisateur.first.geometry
+ return geometry && geometry['type'] == 'MultiPolygon'
+ end
+
+ false
+ end
+
+ def legacy_selections_utilisateur
+ if selection_utilisateur_legacy?
+ selections_utilisateur.first.geometry['coordinates'].map do |coordinates|
+ GeoArea.new(
+ geometry: {
+ type: 'Polygon',
+ coordinates: coordinates
+ },
+ properties: {},
+ source: GeoArea.sources.fetch(:selection_utilisateur)
+ )
+ end
+ else
+ selections_utilisateur
+ end
+ end
+
+ def except_selections_utilisateur
+ geo_areas.filter do |area|
+ area.source != GeoArea.sources.fetch(:selection_utilisateur)
+ end
+ end
end
diff --git a/app/models/dossier.rb b/app/models/dossier.rb
index f051c4b88..0fdba3a50 100644
--- a/app/models/dossier.rb
+++ b/app/models/dossier.rb
@@ -425,8 +425,8 @@ class Dossier < ApplicationRecord
point = Geocoder.search(etablissement.geo_adresse).first
end
- lon = "2.428462"
- lat = "46.538192"
+ lon = Champs::CarteChamp::DEFAULT_LON.to_s
+ lat = Champs::CarteChamp::DEFAULT_LAT.to_s
zoom = "13"
if point.present?
diff --git a/app/models/geo_area.rb b/app/models/geo_area.rb
index 7109d0313..6ff08e68a 100644
--- a/app/models/geo_area.rb
+++ b/app/models/geo_area.rb
@@ -27,11 +27,20 @@ class GeoArea < ApplicationRecord
selection_utilisateur: 'selection_utilisateur'
}
+ scope :selections_utilisateur, -> { where(source: sources.fetch(:selection_utilisateur)) }
scope :quartiers_prioritaires, -> { where(source: sources.fetch(:quartier_prioritaire)) }
scope :cadastres, -> { where(source: sources.fetch(:cadastre)) }
scope :parcelles_agricoles, -> { where(source: sources.fetch(:parcelle_agricole)) }
- def selection_utilisateur?
- source == self.class.sources.fetch(:selection_utilisateur)
+ def to_feature
+ {
+ type: 'Feature',
+ geometry: geometry,
+ properties: properties.merge(source: source)
+ }
+ end
+
+ def rgeo_geometry
+ RGeo::GeoJSON.decode(geometry.to_json, geo_factory: RGeo::Geographic.simple_mercator_factory)
end
end
diff --git a/app/serializers/dossier_serializer.rb b/app/serializers/dossier_serializer.rb
index ba829d3e6..b5a4beeb8 100644
--- a/app/serializers/dossier_serializer.rb
+++ b/app/serializers/dossier_serializer.rb
@@ -38,11 +38,12 @@ class DossierSerializer < ActiveModel::Serializer
end
if champ_carte.present?
- carto_champs = champ_carte.geo_areas.to_a
- if !carto_champs.find(&:selection_utilisateur?)
- carto_champs << champ_carte.user_geo_area
+ champs_geo_areas = geo_areas.filter do |geo_area|
+ geo_area.source != GeoArea.sources.fetch(:selection_utilisateur)
end
- champs += carto_champs.compact
+ champs_geo_areas << champ_carte.selection_utilisateur_legacy_geo_area
+
+ champs += champs_geo_areas.compact
end
end
diff --git a/app/services/api_carto_service.rb b/app/services/api_carto_service.rb
index 9bca82a6c..ec0f64dfe 100644
--- a/app/services/api_carto_service.rb
+++ b/app/services/api_carto_service.rb
@@ -22,10 +22,4 @@ class ApiCartoService
).results
end
end
-
- def self.generate_selection_utilisateur(coordinates)
- {
- geometry: JSON.parse(GeojsonService.to_json_polygon_for_selection_utilisateur(coordinates))
- }
- end
end
diff --git a/app/services/geojson_service.rb b/app/services/geojson_service.rb
index a5c98c9ed..0e4c04914 100644
--- a/app/services/geojson_service.rb
+++ b/app/services/geojson_service.rb
@@ -14,21 +14,4 @@ class GeojsonService
polygon.to_json
end
-
- def self.to_json_polygon_for_selection_utilisateur(coordinates)
- coordinates = coordinates.map do |lat_longs|
- outbounds = lat_longs.map do |lat_long|
- [lat_long['lng'], lat_long['lat']]
- end
-
- [outbounds]
- end
-
- polygon = {
- type: 'MultiPolygon',
- coordinates: coordinates
- }
-
- polygon.to_json
- end
end
diff --git a/app/views/dossiers/show.pdf.prawn b/app/views/dossiers/show.pdf.prawn
index 5bc0c9215..29343bb26 100644
--- a/app/views/dossiers/show.pdf.prawn
+++ b/app/views/dossiers/show.pdf.prawn
@@ -86,7 +86,7 @@ def render_single_champ(pdf, champ)
when 'Champs::ExplicationChamp'
format_in_2_lines(pdf, champ.libelle, champ.description)
when 'Champs::CarteChamp'
- format_in_2_lines(pdf, champ.libelle, champ.geo_json.to_s)
+ format_in_2_lines(pdf, champ.libelle, champ.to_feature_collection.to_json)
when 'Champs::SiretChamp'
pdf.font 'liberation serif', style: :bold, size: 12 do
pdf.text champ.libelle
diff --git a/app/views/shared/champs/carte/_geo_areas.html.haml b/app/views/shared/champs/carte/_geo_areas.html.haml
index 09f326d1b..6f3565e3d 100644
--- a/app/views/shared/champs/carte/_geo_areas.html.haml
+++ b/app/views/shared/champs/carte/_geo_areas.html.haml
@@ -3,39 +3,39 @@
.areas
- if error.present?
.error Merci de dessiner une surface plus petite afin de récupérer les quartiers prioritaires.
- - elsif champ.value.blank?
+ - elsif !champ.geometry?
Aucune zone tracée
- elsif champ.quartiers_prioritaires.blank?
- = t('errors.messages.quartiers_prioritaires_empty', count: champ.selection_utilisateur_size)
+ = t('errors.messages.quartiers_prioritaires_empty', count: champ.selections_utilisateur.size)
- else
%ul
- - champ.quartiers_prioritaires.each do |qp|
- %li #{qp.commune} : #{qp.nom}
+ - champ.quartiers_prioritaires.each do |geo_area|
+ %li= geo_area_label(geo_area)
- if champ.cadastres?
.areas-title Parcelles cadastrales
.areas
- if error.present?
.error Merci de dessiner une surface plus petite afin de récupérer les parcelles cadastrales.
- - elsif champ.value.blank?
+ - elsif !champ.geometry?
Aucune zone tracée
- elsif champ.cadastres.blank?
- = t('errors.messages.cadastres_empty', count: champ.selection_utilisateur_size)
+ = t('errors.messages.cadastres_empty', count: champ.selections_utilisateur.size)
- else
%ul
- - champ.cadastres.each do |pc|
- %li Parcelle n° #{pc.numero} - Feuille #{pc.code_arr} #{pc.section} #{pc.feuille} - #{pc.surface_parcelle.round} m2
+ - champ.cadastres.each do |geo_area|
+ %li= geo_area_label(geo_area)
- if champ.parcelles_agricoles?
.areas-title Parcelles agricoles (RPG)
.areas
- if error.present?
.error Merci de dessiner une surface plus petite afin de récupérer les parcelles agricoles.
- - elsif champ.value.blank?
+ - elsif !champ.geometry?
Aucune zone tracée
- elsif champ.parcelles_agricoles.blank?
- = t('errors.messages.parcelles_agricoles_empty', count: champ.selection_utilisateur_size)
+ = t('errors.messages.parcelles_agricoles_empty', count: champ.selections_utilisateur.size)
- else
%ul
- - champ.parcelles_agricoles.each do |pa|
- %li Culture : #{pa.culture} - Surface : #{pa.surface} ha
+ - champ.parcelles_agricoles.each do |geo_area|
+ %li= geo_area_label(geo_area)
diff --git a/app/views/shared/champs/carte/_show.html.haml b/app/views/shared/champs/carte/_show.html.haml
index 32d5b4f05..ddf078f83 100644
--- a/app/views/shared/champs/carte/_show.html.haml
+++ b/app/views/shared/champs/carte/_show.html.haml
@@ -1,4 +1,4 @@
-- if champ.to_s.present?
- = react_component("MapReader", { geoData: champ.to_render_data } )
+- if champ.geometry?
+ = react_component("MapReader", { featureCollection: champ.to_feature_collection } )
.geo-areas
= render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ, error: false }
diff --git a/lib/tasks/deployment/20200414104712_split_geo_area_selection_multipolygons.rake b/lib/tasks/deployment/20200414104712_split_geo_area_selection_multipolygons.rake
new file mode 100644
index 000000000..930a650aa
--- /dev/null
+++ b/lib/tasks/deployment/20200414104712_split_geo_area_selection_multipolygons.rake
@@ -0,0 +1,18 @@
+namespace :after_party do
+ desc 'Deployment task: split_geo_area_selection_multipolygons'
+ task split_geo_area_selection_multipolygons: :environment do
+ puts "Running deploy task 'split_geo_area_selection_multipolygons'"
+
+ Champs::CarteChamp.where.not(value: ['', '[]']).includes(:geo_areas).find_each do |champ|
+ if champ.send(:selection_utilisateur_legacy?)
+ legacy_selection_utilisateur = champ.selections_utilisateur.first
+ champ.send(:legacy_selections_utilisateur).each do |area|
+ champ.geo_areas << area
+ end
+ legacy_selection_utilisateur.destroy
+ end
+ end
+
+ AfterParty::TaskRecord.create version: '20200414104712'
+ end
+end
diff --git a/spec/controllers/champs/carte_controller_spec.rb b/spec/controllers/champs/carte_controller_spec.rb
index 43861373a..dc483d39b 100644
--- a/spec/controllers/champs/carte_controller_spec.rb
+++ b/spec/controllers/champs/carte_controller_spec.rb
@@ -13,11 +13,10 @@ describe Champs::CarteController, type: :controller do
champ_id: champ.id
}
end
- let(:geojson) { [] }
let(:champ) do
create(:type_de_champ_carte, options: {
cadastres: true
- }).champ.create(dossier: dossier, value: geojson.to_json)
+ }).champ.create(dossier: dossier)
end
describe 'POST #show' do
@@ -31,7 +30,7 @@ describe Champs::CarteController, type: :controller do
allow_any_instance_of(ApiCarto::CadastreAdapter)
.to receive(:results)
- .and_return([{ code: "QPCODE1234", surface_parcelle: 4, geometry: { type: "MultiPolygon", coordinates: [[[[2.38715792094576, 48.8723062632126], [2.38724851642619, 48.8721392348061]]]] } }])
+ .and_return([{ code: "QPCODE1234", surface_parcelle: 4, geometry: { type: "MultiPolygon", coordinates: [[[[2.38715792094576, 48.8723062632126], [2.38724851642619, 48.8721392348061], [2.38724851642620, 48.8721392348064], [2.38715792094576, 48.8723062632126]]]] } }])
post :show, params: params, format: 'js'
end
@@ -43,7 +42,7 @@ describe Champs::CarteController, type: :controller do
expect(assigns(:error)).to eq(nil)
expect(champ.reload.value).to eq(nil)
expect(champ.reload.geo_areas).to eq([])
- expect(response.body).to include("DS.fire('carte:update', {\"selector\":\".carte-1\",\"data\":{\"position\":{\"lon\":\"2.428462\",\"lat\":\"46.538192\",\"zoom\":\"13\"},\"selection\":null,\"quartiersPrioritaires\":[],\"cadastres\":[],\"parcellesAgricoles\":[]}});")
+ expect(response.body).to include("DS.fire('carte:update'")
}
end
@@ -56,7 +55,6 @@ describe Champs::CarteController, type: :controller do
end
context 'when error' do
- let(:geojson) { [[{ "lat": 48.87442541960633, "lng": 2.3859214782714844 }, { "lat": 48.87273183590832, "lng": 2.3850631713867183 }, { "lat": 48.87081237174292, "lng": 2.3809432983398438 }, { "lat": 48.8712640169951, "lng": 2.377510070800781 }, { "lat": 48.87510283703279, "lng": 2.3778533935546875 }, { "lat": 48.87544154230615, "lng": 2.382831573486328 }, { "lat": 48.87442541960633, "lng": 2.3859214782714844 }]] }
let(:value) { '' }
it {
diff --git a/spec/models/champs/carte_champ_spec.rb b/spec/models/champs/carte_champ_spec.rb
index 4de97465c..5f286e6f7 100644
--- a/spec/models/champs/carte_champ_spec.rb
+++ b/spec/models/champs/carte_champ_spec.rb
@@ -1,9 +1,19 @@
describe Champs::CarteChamp do
- let(:champ) { Champs::CarteChamp.new(value: value) }
+ let(:champ) { Champs::CarteChamp.new(geo_areas: geo_areas) }
let(:value) { '' }
- let(:coordinates) { [[{ "lat" => 48.87442541960633, "lng" => 2.3859214782714844 }, { "lat" => 48.87273183590832, "lng" => 2.3850631713867183 }, { "lat" => 48.87081237174292, "lng" => 2.3809432983398438 }, { "lat" => 48.8712640169951, "lng" => 2.377510070800781 }, { "lat" => 48.87510283703279, "lng" => 2.3778533935546875 }, { "lat" => 48.87544154230615, "lng" => 2.382831573486328 }, { "lat" => 48.87442541960633, "lng" => 2.3859214782714844 }]] }
- let(:geo_json_as_string) { GeojsonService.to_json_polygon_for_selection_utilisateur(coordinates) }
- let(:geo_json) { JSON.parse(geo_json_as_string) }
+ let(:coordinates) { [[2.3859214782714844, 48.87442541960633], [2.3850631713867183, 48.87273183590832], [2.3809432983398438, 48.87081237174292], [2.3859214782714844, 48.87442541960633]] }
+ let(:geo_json) do
+ {
+ "type" => 'Polygon',
+ "coordinates" => coordinates
+ }
+ end
+ let(:legacy_geo_json) do
+ {
+ type: 'MultiPolygon',
+ coordinates: [coordinates]
+ }
+ end
describe '#to_render_data' do
subject { champ.to_render_data }
@@ -18,78 +28,44 @@ describe Champs::CarteChamp do
}
}
- context 'when the value is nil' do
- let(:value) { nil }
-
+ context 'when has no geo_areas' do
+ let(:geo_areas) { [] }
let(:selection) { nil }
it { is_expected.to eq(render_data) }
end
- context 'when the value is blank' do
- let(:value) { '' }
-
- let(:selection) { nil }
-
- it { is_expected.to eq(render_data) }
- end
-
- context 'when the value is empty array' do
- let(:value) { '[]' }
-
- let(:selection) { nil }
-
- it { is_expected.to eq(render_data) }
- end
-
- context 'when the value is coordinates' do
- let(:value) { coordinates.to_json }
-
- let(:selection) { geo_json }
-
- it { is_expected.to eq(render_data) }
- end
-
- context 'when the value is geojson' do
- let(:value) { geo_json.to_json }
-
- let(:selection) { geo_json }
+ context 'when has one geo_area' do
+ let(:geo_areas) { [build(:geo_area, :selection_utilisateur, geometry: geo_json)] }
+ let(:selection) { legacy_geo_json }
it { is_expected.to eq(render_data) }
end
end
- describe '#selection_utilisateur_size' do
- subject { champ.selection_utilisateur_size }
+ describe '#to_feature_collection' do
+ subject { champ.to_feature_collection }
- context 'when the value is nil' do
- let(:value) { nil }
+ let(:feature_collection) {
+ {
+ type: 'FeatureCollection',
+ bbox: champ.bounding_box,
+ features: features
+ }
+ }
- it { is_expected.to eq(0) }
+ context 'when has no geo_areas' do
+ let(:geo_areas) { [] }
+ let(:features) { [] }
+
+ it { is_expected.to eq(feature_collection) }
end
- context 'when the value is blank' do
- let(:value) { '' }
+ context 'when has one geo_area' do
+ let(:geo_areas) { [build(:geo_area, :selection_utilisateur, geometry: geo_json)] }
+ let(:features) { geo_areas.map(&:to_feature) }
- it { is_expected.to eq(0) }
- end
-
- context 'when the value is empty array' do
- let(:value) { '[]' }
-
- it { is_expected.to eq(0) }
- end
-
- context 'when the value is coordinates' do
- let(:value) { coordinates.to_json }
-
- it { is_expected.to eq(1) }
- end
-
- context 'when the value is geojson' do
- let(:value) { geo_json.to_json }
-
- it { is_expected.to eq(1) }
+ it { is_expected.to eq(feature_collection) }
end
end
end
diff --git a/spec/serializers/champ_serializer_spec.rb b/spec/serializers/champ_serializer_spec.rb
index 602356312..97a6712f4 100644
--- a/spec/serializers/champ_serializer_spec.rb
+++ b/spec/serializers/champ_serializer_spec.rb
@@ -27,9 +27,13 @@ describe ChampSerializer do
let(:champ) { create(:champ_carte, value: value, geo_areas: [geo_area].compact) }
let(:value) { nil }
let(:geo_area) { create(:geo_area, geometry: geo_json) }
- let(:geo_json_as_string) { GeojsonService.to_json_polygon_for_selection_utilisateur(coordinates) }
- let(:geo_json) { JSON.parse(geo_json_as_string) }
- let(:coordinates) { [[{ "lat" => 48.87442541960633, "lng" => 2.3859214782714844 }, { "lat" => 48.87273183590832, "lng" => 2.3850631713867183 }, { "lat" => 48.87081237174292, "lng" => 2.3809432983398438 }, { "lat" => 48.8712640169951, "lng" => 2.377510070800781 }, { "lat" => 48.87510283703279, "lng" => 2.3778533935546875 }, { "lat" => 48.87544154230615, "lng" => 2.382831573486328 }, { "lat" => 48.87442541960633, "lng" => 2.3859214782714844 }]] }
+ let(:geo_json) do
+ {
+ "type" => 'MultiPolygon',
+ "coordinates" => coordinates
+ }
+ end
+ let(:coordinates) { [[[2.3859214782714844, 48.87442541960633], [2.3850631713867183, 48.87273183590832], [2.3809432983398438, 48.87081237174292], [2.3859214782714844, 48.87442541960633]]] }
let(:serialized_champ) {
{
@@ -60,19 +64,19 @@ describe ChampSerializer do
context 'when value is nil' do
let(:value) { nil }
- it { expect(champ.user_geo_area).to be_nil }
+ it { expect(champ.selection_utilisateur_legacy_geo_area).to be_nil }
end
context 'when value is empty array' do
let(:value) { '[]' }
- it { expect(champ.user_geo_area).to be_nil }
+ it { expect(champ.selection_utilisateur_legacy_geo_area).to be_nil }
end
context 'when value is blank' do
let(:value) { '' }
- it { expect(champ.user_geo_area).to be_nil }
+ it { expect(champ.selection_utilisateur_legacy_geo_area).to be_nil }
end
end
@@ -80,7 +84,7 @@ describe ChampSerializer do
let(:serialized_libelle) { "user geometry" }
let(:serialized_type_champ) { "user_geometry" }
- let(:serializable_object) { champ.user_geo_area }
+ let(:serializable_object) { champ.selection_utilisateur_legacy_geo_area }
context 'when value is coordinates' do
let(:value) { coordinates.to_json }
@@ -167,30 +171,6 @@ describe ChampSerializer do
it { expect(subject).to eq(serialized_champ) }
end
end
-
- context 'and geo_area is quartier_prioritaire' do
- let(:geo_area) { create(:geo_area, :quartier_prioritaire, geometry: geo_json) }
-
- context 'new_api' do
- it {
- expect(subject[:geo_areas].first).to include(
- source: GeoArea.sources.fetch(:quartier_prioritaire),
- geometry: geo_json,
- nom: 'XYZ',
- commune: 'Paris'
- )
- expect(subject[:geo_areas].first.key?(:numero)).to be_falsey
- }
- end
-
- context 'old_api' do
- let(:serializable_object) { champ.geo_areas.first }
- let(:serialized_libelle) { "quartier prioritaire" }
- let(:serialized_type_champ) { "quartier_prioritaire" }
-
- it { expect(subject).to eq(serialized_champ) }
- end
- end
end
context 'when type champ is siret' do