From 05e408225b29b221cee5a05e078b7d44ae3b91be Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 5 May 2020 15:09:29 +0200 Subject: [PATCH] Implement atomic operations on MapEditor --- app/controllers/champs/carte_controller.rb | 71 +++++++++ .../components/MapEditor/MapEditor.js | 148 ++++++++---------- app/javascript/components/MapEditor/utils.js | 8 - app/javascript/shared/utils.js | 2 +- app/views/champs/carte/index.js.erb | 9 ++ app/views/champs/carte/show.js.erb | 6 +- .../dossiers/editable_champs/_carte.html.haml | 10 +- config/routes.rb | 6 + .../champs/carte_controller_spec.rb | 99 ++++++++++++ 9 files changed, 258 insertions(+), 101 deletions(-) create mode 100644 app/views/champs/carte/index.js.erb diff --git a/app/controllers/champs/carte_controller.rb b/app/controllers/champs/carte_controller.rb index 9a22d821a..6004c2676 100644 --- a/app/controllers/champs/carte_controller.rb +++ b/app/controllers/champs/carte_controller.rb @@ -40,6 +40,44 @@ class Champs::CarteController < ApplicationController response.status = 503 end + def index + @selector = ".carte-#{params[:champ_id]}" + @champ = policy_scope(Champ).find(params[:champ_id]) + @update_cadastres = params[:cadastres] + + if @champ.cadastres? && @update_cadastres + @champ.geo_areas.cadastres.destroy_all + @champ.geo_areas += GeoArea.from_feature_collection(cadastres_features_collection(@champ.to_feature_collection)) + @champ.save! + end + rescue ApiCarto::API::ResourceNotFound + flash.alert = 'Les données cartographiques sont temporairement indisponibles. Réessayez dans un instant.' + response.status = 503 + end + + def create + champ = policy_scope(Champ).find(params[:champ_id]) + geo_area = champ.geo_areas.selections_utilisateur.new + save_geometry!(geo_area) + + render json: { feature: geo_area.to_feature }, status: :created + end + + def update + champ = policy_scope(Champ).find(params[:champ_id]) + geo_area = champ.geo_areas.selections_utilisateur.find(params[:id]) + save_geometry!(geo_area) + + head :no_content + end + + def destroy + champ = policy_scope(Champ).find(params[:champ_id]) + champ.geo_areas.selections_utilisateur.find(params[:id]).destroy! + + head :no_content + end + private def populate_cadastres(feature_collection) @@ -61,4 +99,37 @@ class Champs::CarteController < ApplicationController end end end + + def save_geometry!(geo_area) + geo_area.geometry = params[:feature][:geometry] + geo_area.save! + end + + def cadastres_features_collection(feature_collection) + coordinates = feature_collection[:features].filter do |feature| + feature[:properties][:source] == GeoArea.sources.fetch(:selection_utilisateur) && feature[:geometry]['type'] == 'Polygon' + end.map do |feature| + feature[:geometry]['coordinates'][0].map { |(lng, lat)| { 'lng' => lng, 'lat' => lat } } + end + + if coordinates.present? + cadastres = ApiCartoService.generate_cadastre(coordinates) + + { + type: 'FeatureCollection', + features: cadastres.map do |cadastre| + { + type: 'Feature', + geometry: cadastre.delete(:geometry), + properties: cadastre.merge(source: GeoArea.sources.fetch(:cadastre)) + } + end + } + else + { + type: 'FeatureCollection', + features: [] + } + end + end end diff --git a/app/javascript/components/MapEditor/MapEditor.js b/app/javascript/components/MapEditor/MapEditor.js index 6a219fa62..56b33d08f 100644 --- a/app/javascript/components/MapEditor/MapEditor.js +++ b/app/javascript/components/MapEditor/MapEditor.js @@ -3,116 +3,99 @@ import PropTypes from 'prop-types'; import mapboxgl from 'mapbox-gl'; import ReactMapboxGl, { GeoJSONLayer, ZoomControl } from 'react-mapbox-gl'; import DrawControl from 'react-mapbox-gl-draw'; -import area from '@turf/area'; import SwitchMapStyle from './SwitchMapStyle'; import SearchInput from './SearchInput'; -import { fire } from '@utils'; +import { getJSON, ajax } from '@utils'; import ortho from './styles/ortho.json'; import vector from './styles/vector.json'; -import { - createFeatureCollection, - polygonCadastresFill, - polygonCadastresLine, - ERROR_GEO_JSON -} from './utils'; +import { polygonCadastresFill, polygonCadastresLine } from './utils'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; const Map = ReactMapboxGl({}); -const MapEditor = ({ featureCollection: { features, bbox, id } }) => { +function filterFeatureCollection(featureCollection, source) { + return { + type: 'FeatureCollection', + features: featureCollection.features.filter( + feature => feature.properties.source === source + ) + }; +} + +const MapEditor = ({ featureCollection, url }) => { const drawControl = useRef(null); const [style, setStyle] = useState('ortho'); const [coords, setCoords] = useState([1.7, 46.9]); const [zoom, setZoom] = useState([5]); const [currentMap, setCurrentMap] = useState({}); - let input = document.querySelector( - `input[data-feature-collection-id="${id}"]` - ); - - let userSelections = features.filter( - feature => feature.properties.source === 'selection_utilisateur' - ); - - let cadastresFeatureCollection = { - type: 'FeatureCollection', - features: [] - }; - - const constructCadastresFeatureCollection = features => { - for (let feature of features) { - switch (feature.properties.source) { - case 'cadastre': - cadastresFeatureCollection.features.push(feature); - break; - } - } - }; - constructCadastresFeatureCollection(features); const mapStyle = style === 'ortho' ? ortho : vector; + const bbox = featureCollection.bbox; + const cadastresFeatureCollection = filterFeatureCollection( + featureCollection, + 'cadastre' + ); - const saveFeatureCollection = featuresToSave => { - const featuresCollection = createFeatureCollection(featuresToSave); - if (area(featuresCollection) < 300000) { - input.value = JSON.stringify(featuresCollection); - } else { - input.value = ERROR_GEO_JSON; - } - fire(input, 'change'); - }; - - const onDrawCreate = ({ features }) => { - const draw = drawControl.current.draw; - const featureId = features[0].id; - draw.setFeatureProperty(featureId, 'id', featureId); - draw.setFeatureProperty(featureId, 'source', 'selection_utilisateur'); - userSelections.push(draw.get(featureId)); - saveFeatureCollection(userSelections); - }; - - const onDrawUpdate = ({ features }) => { - let featureId = features[0].properties.id; - userSelections = userSelections.map(selection => { - if (selection.properties.id === featureId) { - selection = features[0]; - } - return selection; - }); - saveFeatureCollection(userSelections); - }; - - const onDrawDelete = ({ features }) => { - userSelections = userSelections.filter( - selection => selection.properties.id !== features[0].properties.id + function updateFeaturesList(features) { + const cadastres = features.find( + ({ geometry }) => geometry.type === 'Polygon' ); - saveFeatureCollection(userSelections); - }; + ajax({ url, type: 'get', data: cadastres ? 'cadastres=update' : '' }); + } + + function setFeatureId(lid, feature) { + const draw = drawControl.current.draw; + draw.setFeatureProperty(lid, 'id', feature.properties.id); + } + + async function onDrawCreate({ features }) { + for (const feature of features) { + const data = await getJSON(url, { feature }, 'post'); + setFeatureId(feature.id, data.feature); + } + + updateFeaturesList(features); + } + + async function onDrawUpdate({ features }) { + for (const feature of features) { + let { id } = feature.properties; + await getJSON(`${url}/${id}`, { feature }, 'patch'); + } + + updateFeaturesList(features); + } + + async function onDrawDelete({ features }) { + for (const feature of features) { + const { id } = feature.properties; + await getJSON(`${url}/${id}`, null, 'delete'); + } + + updateFeaturesList(features); + } const onMapLoad = map => { setCurrentMap(map); - if (userSelections.length > 0) { - userSelections.map((selection, index) => { - selection.properties.id = index + 1; - drawControl.current.draw.add(selection); - }); - } + + drawControl.current.draw.set( + filterFeatureCollection(featureCollection, 'selection_utilisateur') + ); }; - const onMapUpdate = evt => { + const onCadastresUpdate = evt => { if (currentMap) { - cadastresFeatureCollection.features = []; - constructCadastresFeatureCollection( - evt.detail.featureCollection.features - ); currentMap .getSource('cadastres-layer') - .setData(cadastresFeatureCollection); + .setData( + filterFeatureCollection(evt.detail.featureCollection, 'cadastre') + ); } }; useEffect(() => { - addEventListener('map:update', onMapUpdate); - return () => removeEventListener('map:update', onMapUpdate); + addEventListener('cadastres:update', onCadastresUpdate); + return () => removeEventListener('cadastres:update', onCadastresUpdate); }); if (!mapboxgl.supported()) { @@ -193,7 +176,8 @@ MapEditor.propTypes = { bbox: PropTypes.array, features: PropTypes.array, id: PropTypes.number - }) + }), + url: PropTypes.string }; export default MapEditor; diff --git a/app/javascript/components/MapEditor/utils.js b/app/javascript/components/MapEditor/utils.js index 9be234855..30311a836 100644 --- a/app/javascript/components/MapEditor/utils.js +++ b/app/javascript/components/MapEditor/utils.js @@ -1,11 +1,3 @@ -export const ERROR_GEO_JSON = ''; -export const createFeatureCollection = selectionsUtilisateur => { - return { - type: 'FeatureCollection', - features: selectionsUtilisateur - }; -}; - export const polygonCadastresFill = { 'fill-color': '#EC3323', 'fill-opacity': 0.3 diff --git a/app/javascript/shared/utils.js b/app/javascript/shared/utils.js index d360af45f..ac1d3246f 100644 --- a/app/javascript/shared/utils.js +++ b/app/javascript/shared/utils.js @@ -68,7 +68,7 @@ export function ajax(options) { } export function getJSON(url, data, method = 'get') { - data = method !== 'get' ? JSON.stringify(data) : data; + data = method !== 'get' && data ? JSON.stringify(data) : data; return Promise.resolve( $.ajax({ method, diff --git a/app/views/champs/carte/index.js.erb b/app/views/champs/carte/index.js.erb new file mode 100644 index 000000000..43157bbf2 --- /dev/null +++ b/app/views/champs/carte/index.js.erb @@ -0,0 +1,9 @@ +<%= render_flash(timeout: 5000, fixed: true) %> + +<%= render_to_element("#{@selector} + .geo-areas", + partial: 'shared/champs/carte/geo_areas', + locals: { champ: @champ, error: @error }) %> + +<% if @update_cadastres %> + <%= fire_event('cadastres:update', { featureCollection: @champ.to_feature_collection }.to_json) %> +<% end %> diff --git a/app/views/champs/carte/show.js.erb b/app/views/champs/carte/show.js.erb index f36252efc..7b80491fe 100644 --- a/app/views/champs/carte/show.js.erb +++ b/app/views/champs/carte/show.js.erb @@ -4,8 +4,4 @@ partial: 'shared/champs/carte/geo_areas', locals: { champ: @champ, error: @error }) %> -<% if feature_enabled?(:new_map_editor) %> - <%= fire_event('map:update', { featureCollection: @champ.to_feature_collection }.to_json) %> -<% else %> - <%= fire_event('carte:update', { selector: @selector, data: @champ.to_render_data }.to_json) %> -<% end %> +<%= fire_event('carte:update', { selector: @selector, data: @champ.to_render_data }.to_json) %> diff --git a/app/views/shared/dossiers/editable_champs/_carte.html.haml b/app/views/shared/dossiers/editable_champs/_carte.html.haml index c7c058056..4464306f9 100644 --- a/app/views/shared/dossiers/editable_champs/_carte.html.haml +++ b/app/views/shared/dossiers/editable_champs/_carte.html.haml @@ -1,13 +1,13 @@ - - if feature_enabled?(:new_map_editor) - = react_component("MapEditor", { featureCollection: champ.to_feature_collection }, class: "carte-#{form.index}") + = react_component("MapEditor", { featureCollection: champ.to_feature_collection, url: champs_carte_features_path(champ) }, class: "carte-#{champ.id}") - else .toolbar %button.button.primary.new-area Ajouter une zone %select.select2.adresse{ data: { address: true }, placeholder: 'Saisissez une adresse ou positionner la carte' } .carte.edit{ data: { geo: geo_data(champ) }, class: "carte-#{form.index}" } + + = form.hidden_field :value, + data: { remote: true, feature_collection_id: champ.stable_id, url: champs_carte_path(form.index), params: champ_carte_params(champ).to_query, method: 'post' } + .geo-areas = render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ, error: false } - -= form.hidden_field :value, - data: { remote: true, feature_collection_id: champ.stable_id, url: champs_carte_path(form.index), params: champ_carte_params(champ).to_query, method: 'post' } diff --git a/config/routes.rb b/config/routes.rb index c84893b20..0595c6014 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -120,6 +120,12 @@ Rails.application.routes.draw do get ':position/siret', to: 'siret#show', as: :siret get ':position/dossier_link', to: 'dossier_link#show', as: :dossier_link post ':position/carte', to: 'carte#show', as: :carte + + get ':champ_id/carte/features', to: 'carte#index', as: :carte_features + post ':champ_id/carte/features', to: 'carte#create' + patch ':champ_id/carte/features/:id', to: 'carte#update' + delete ':champ_id/carte/features/:id', to: 'carte#destroy' + post ':position/repetition', to: 'repetition#show', as: :repetition put 'piece_justificative/:champ_id', to: 'piece_justificative#update', as: :piece_justificative end diff --git a/spec/controllers/champs/carte_controller_spec.rb b/spec/controllers/champs/carte_controller_spec.rb index 69493fd05..14173e150 100644 --- a/spec/controllers/champs/carte_controller_spec.rb +++ b/spec/controllers/champs/carte_controller_spec.rb @@ -18,6 +18,105 @@ describe Champs::CarteController, type: :controller do cadastres: true }).champ.create(dossier: dossier) end + describe 'features' do + let(:feature) { attributes_for(:geo_area, :polygon) } + let(:geo_area) { create(:geo_area, :selection_utilisateur, :polygon, champ: champ) } + let(:params) do + { + champ_id: champ.id, + feature: feature + } + end + + before do + sign_in user + request.accept = "application/json" + request.content_type = "application/json" + end + + describe 'POST #create' do + before do + post :create, params: params + end + + it { expect(response.status).to eq 201 } + end + + describe 'PATCH #update' do + let(:params) do + { + champ_id: champ.id, + id: geo_area.id, + feature: feature + } + end + + before do + patch :update, params: params + end + + it { expect(response.status).to eq 204 } + end + + describe 'DELETE #destroy' do + let(:params) do + { + champ_id: champ.id, + id: geo_area.id + } + end + + before do + delete :destroy, params: params + end + + it { expect(response.status).to eq 204 } + end + + describe 'GET #index' do + render_views + + before do + request.accept = "application/javascript" + request.content_type = "application/javascript" + end + + context 'with cadastres update' do + let(:params) do + { + champ_id: champ.id, + cadastres: 'update' + } + end + + before do + get :index, params: params + end + + it { + expect(response.status).to eq 200 + expect(response.body).to include("DS.fire('cadastres:update'") + } + end + + context 'without cadastres update' do + let(:params) do + { + champ_id: champ.id + } + end + + before do + get :index, params: params + end + + it { + expect(response.status).to eq 200 + expect(response.body).not_to include("DS.fire('cadastres:update'") + } + end + end + end describe 'POST #show' do render_views