Merge branch 'main' into localize-dropdown-button
This commit is contained in:
commit
2d6ad4f2c1
71 changed files with 1874 additions and 1220 deletions
|
@ -423,7 +423,7 @@ GEM
|
|||
ruby2_keywords (~> 0.0.1)
|
||||
netrc (0.11.0)
|
||||
nio4r (2.5.7)
|
||||
nokogiri (1.11.3)
|
||||
nokogiri (1.11.4)
|
||||
mini_portile2 (~> 2.5.0)
|
||||
racc (~> 1.4)
|
||||
open4 (1.3.4)
|
||||
|
@ -471,7 +471,7 @@ GEM
|
|||
byebug (~> 11.0)
|
||||
pry (~> 0.13.0)
|
||||
public_suffix (4.0.6)
|
||||
puma (5.2.1)
|
||||
puma (5.3.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.1.0)
|
||||
activesupport (>= 3.0.0)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
@import "colors";
|
||||
|
||||
.areas-title {
|
||||
font-weight: bold;
|
||||
margin-top: 5px;
|
||||
|
@ -15,3 +17,38 @@
|
|||
.form [data-react-class='MapEditor'] [data-reach-combobox-input] {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.map-style-control {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 10px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
> div {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cadastres-selection-control {
|
||||
position: absolute;
|
||||
top: 135px;
|
||||
left: 10px;
|
||||
|
||||
button {
|
||||
&.on,
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -163,7 +163,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
input[type=text]:not([data-address='true']),
|
||||
input[type=text],
|
||||
input[type=email],
|
||||
input[type=password],
|
||||
input[type=date],
|
||||
|
@ -178,6 +178,10 @@
|
|||
&.small-margin {
|
||||
margin-bottom: $default-spacer;
|
||||
}
|
||||
|
||||
&.no-margin {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add-row {
|
||||
|
@ -475,7 +479,7 @@
|
|||
}
|
||||
|
||||
[data-react-class]:not([data-react-class="ComboMultipleDropdownList"]) {
|
||||
[data-reach-combobox-input] {
|
||||
[data-reach-combobox-input]:not(.no-margin) {
|
||||
margin-bottom: $default-fields-spacer;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,29 +4,26 @@ class Champs::CarteController < ApplicationController
|
|||
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
|
||||
@focus = params[:focus].present?
|
||||
end
|
||||
|
||||
def create
|
||||
champ = policy_scope(Champ).find(params[:champ_id])
|
||||
geo_area = champ.geo_areas.selections_utilisateur.new
|
||||
geo_area = if params_source == GeoArea.sources.fetch(:cadastre)
|
||||
champ.geo_areas.find_by("properties->>'id' = :id", id: params_feature[:properties][:id])
|
||||
end
|
||||
|
||||
if geo_area.nil?
|
||||
geo_area = champ.geo_areas.build(source: params_source, properties: {})
|
||||
save_feature!(geo_area, params_feature)
|
||||
end
|
||||
|
||||
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])
|
||||
geo_area = champ.geo_areas.find(params[:id])
|
||||
save_feature!(geo_area, params_feature)
|
||||
|
||||
head :no_content
|
||||
|
@ -34,66 +31,42 @@ class Champs::CarteController < ApplicationController
|
|||
|
||||
def destroy
|
||||
champ = policy_scope(Champ).find(params[:champ_id])
|
||||
champ.geo_areas.selections_utilisateur.find(params[:id]).destroy!
|
||||
champ.geo_areas.find(params[:id]).destroy!
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def import
|
||||
champ = policy_scope(Champ).find(params[:champ_id])
|
||||
params_features.each do |feature|
|
||||
geo_area = champ.geo_areas.selections_utilisateur.new
|
||||
save_feature!(geo_area, feature)
|
||||
end
|
||||
|
||||
render json: champ.to_feature_collection, status: :created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def params_feature
|
||||
params[:feature]
|
||||
def params_source
|
||||
params[:source]
|
||||
end
|
||||
|
||||
def params_features
|
||||
params[:features]
|
||||
def params_feature
|
||||
params.require(:feature).permit(properties: [
|
||||
:filename,
|
||||
:description,
|
||||
:arpente,
|
||||
:commune,
|
||||
:contenance,
|
||||
:created,
|
||||
:id,
|
||||
:numero,
|
||||
:prefixe,
|
||||
:section,
|
||||
:updated
|
||||
]).tap do |feature|
|
||||
feature[:geometry] = params[:feature][:geometry]
|
||||
end
|
||||
end
|
||||
|
||||
def save_feature!(geo_area, feature)
|
||||
if feature[:geometry]
|
||||
geo_area.geometry = feature[:geometry]
|
||||
end
|
||||
if feature[:properties] && feature[:properties][:description]
|
||||
geo_area.description = feature[:properties][:description]
|
||||
if feature[:properties]
|
||||
geo_area.properties.merge!(feature[:properties])
|
||||
end
|
||||
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] && 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
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
module Instructeurs
|
||||
class RechercheController < InstructeurController
|
||||
def index
|
||||
@search_terms = params[:q]
|
||||
@dossiers = DossierSearchService.matching_dossiers_for_instructeur(@search_terms, current_instructeur)
|
||||
@followed_dossiers_id = current_instructeur
|
||||
.followed_dossiers
|
||||
.where(groupe_instructeur_id: @dossiers.pluck(:groupe_instructeur_id))
|
||||
.pluck(:id)
|
||||
end
|
||||
end
|
||||
end
|
42
app/controllers/recherche_controller.rb
Normal file
42
app/controllers/recherche_controller.rb
Normal file
|
@ -0,0 +1,42 @@
|
|||
class RechercheController < ApplicationController
|
||||
before_action :authenticate_logged_user!
|
||||
ITEMS_PER_PAGE = 25
|
||||
PROJECTIONS = [
|
||||
{ "table" => 'procedure', "column" => 'libelle' },
|
||||
{ "table" => 'user', "column" => 'email' },
|
||||
{ "table" => 'procedure', "column" => 'procedure_id' }
|
||||
]
|
||||
|
||||
def index
|
||||
@search_terms = search_terms
|
||||
|
||||
@instructeur_dossiers_ids = DossierSearchService
|
||||
.matching_dossiers(current_instructeur&.dossiers, @search_terms, with_annotation: true)
|
||||
|
||||
expert_dossier_ids = DossierSearchService
|
||||
.matching_dossiers(current_expert&.dossiers, @search_terms)
|
||||
|
||||
matching_dossiers_ids = (@instructeur_dossiers_ids + expert_dossier_ids).uniq
|
||||
|
||||
@paginated_ids = Kaminari
|
||||
.paginate_array(matching_dossiers_ids)
|
||||
.page(page)
|
||||
.per(ITEMS_PER_PAGE)
|
||||
|
||||
@projected_dossiers = DossierProjectionService.project(@paginated_ids, PROJECTIONS)
|
||||
|
||||
@dossiers_count = matching_dossiers_ids.count
|
||||
@followed_dossiers_id = current_instructeur&.followed_dossiers&.where(id: @paginated_ids)&.ids || []
|
||||
@dossier_avis_ids_h = current_expert&.avis&.where(dossier_id: @paginated_ids)&.pluck(:dossier_id, :id).to_h || {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def page
|
||||
params[:page].presence || 1
|
||||
end
|
||||
|
||||
def search_terms
|
||||
params[:q]
|
||||
end
|
||||
end
|
|
@ -1467,18 +1467,21 @@ type PageInfo {
|
|||
}
|
||||
|
||||
type ParcelleCadastrale implements GeoArea {
|
||||
codeArr: String!
|
||||
codeCom: String!
|
||||
codeDep: String!
|
||||
feuille: Int!
|
||||
codeArr: String! @deprecated(reason: "Utilisez le champ `prefixe` à la place.")
|
||||
codeCom: String! @deprecated(reason: "Utilisez le champ `commune` à la place.")
|
||||
codeDep: String! @deprecated(reason: "Utilisez le champ `commune` à la place.")
|
||||
commune: String!
|
||||
feuille: Int! @deprecated(reason: "L‘information n‘est plus disponible.")
|
||||
geometry: GeoJSON!
|
||||
id: ID!
|
||||
nomCom: String!
|
||||
nomCom: String! @deprecated(reason: "Utilisez le champ `commune` à la place.")
|
||||
numero: String!
|
||||
prefixe: String!
|
||||
section: String!
|
||||
source: GeoAreaSource!
|
||||
surfaceIntersection: Float!
|
||||
surfaceParcelle: Float!
|
||||
surface: String!
|
||||
surfaceIntersection: Float! @deprecated(reason: "L‘information n‘est plus disponible.")
|
||||
surfaceParcelle: Float! @deprecated(reason: "Utilisez le champ `surface` à la place.")
|
||||
}
|
||||
|
||||
type PersonneMorale implements Demandeur {
|
||||
|
@ -1587,6 +1590,7 @@ type Revision {
|
|||
}
|
||||
|
||||
type SelectionUtilisateur implements GeoArea {
|
||||
description: String!
|
||||
geometry: GeoJSON!
|
||||
id: ID!
|
||||
source: GeoAreaSource!
|
||||
|
|
|
@ -2,14 +2,18 @@ module Types::GeoAreas
|
|||
class ParcelleCadastraleType < Types::BaseObject
|
||||
implements Types::GeoAreaType
|
||||
|
||||
field :surface_intersection, Float, null: false
|
||||
field :surface_parcelle, Float, null: false
|
||||
field :numero, String, null: false
|
||||
field :feuille, Int, null: false
|
||||
field :section, String, null: false
|
||||
field :code_dep, String, null: false
|
||||
field :nom_com, String, null: false
|
||||
field :code_com, String, null: false
|
||||
field :code_arr, String, null: false
|
||||
field :surface, String, null: false
|
||||
field :prefixe, String, null: false
|
||||
field :commune, String, null: false
|
||||
|
||||
field :code_dep, String, null: false, deprecation_reason: 'Utilisez le champ `commune` à la place.'
|
||||
field :nom_com, String, null: false, deprecation_reason: 'Utilisez le champ `commune` à la place.'
|
||||
field :code_com, String, null: false, deprecation_reason: 'Utilisez le champ `commune` à la place.'
|
||||
field :code_arr, String, null: false, deprecation_reason: 'Utilisez le champ `prefixe` à la place.'
|
||||
field :feuille, Int, null: false, deprecation_reason: 'L‘information n‘est plus disponible.'
|
||||
field :surface_intersection, Float, null: false, deprecation_reason: 'L‘information n‘est plus disponible.'
|
||||
field :surface_parcelle, Float, null: false, deprecation_reason: 'Utilisez le champ `surface` à la place.'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
module Types::GeoAreas
|
||||
class SelectionUtilisateurType < Types::BaseObject
|
||||
implements Types::GeoAreaType
|
||||
|
||||
field :description, String, null: false
|
||||
end
|
||||
end
|
||||
|
|
|
@ -36,7 +36,7 @@ module ChampHelper
|
|||
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 "Parcelle n° #{geo_area.numero} - Feuille #{geo_area.prefixe} #{geo_area.section} - #{geo_area.surface.round} m"
|
||||
concat tag.sup("2")
|
||||
end
|
||||
when GeoArea.sources.fetch(:selection_utilisateur)
|
||||
|
|
|
@ -11,13 +11,15 @@ function ComboAdresseSearch({
|
|||
hiddenFieldId,
|
||||
onChange,
|
||||
transformResult = ({ properties: { label } }) => [label, label],
|
||||
allowInputValues = true
|
||||
allowInputValues = true,
|
||||
className
|
||||
}) {
|
||||
const transformResults = useCallback((_, { features }) => features);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
required={mandatory}
|
||||
hiddenFieldId={hiddenFieldId}
|
||||
|
@ -33,6 +35,7 @@ function ComboAdresseSearch({
|
|||
}
|
||||
|
||||
ComboAdresseSearch.propTypes = {
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
mandatory: PropTypes.bool,
|
||||
hiddenFieldId: PropTypes.string,
|
||||
|
|
|
@ -19,6 +19,7 @@ import '@reach/combobox/styles.css';
|
|||
import { matchSorter } from 'match-sorter';
|
||||
import { fire } from '@utils';
|
||||
import { XIcon } from '@heroicons/react/outline';
|
||||
import isHotkey from 'is-hotkey';
|
||||
|
||||
const Context = createContext();
|
||||
|
||||
|
@ -83,7 +84,12 @@ function ComboMultipleDropdownList({
|
|||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (
|
||||
isHotkey('enter', event) ||
|
||||
isHotkey(' ', event) ||
|
||||
isHotkey(',', event) ||
|
||||
isHotkey(';', event)
|
||||
) {
|
||||
if (
|
||||
term &&
|
||||
[...extraOptions, ...options].map(([label]) => label).includes(term)
|
||||
|
@ -94,12 +100,26 @@ function ComboMultipleDropdownList({
|
|||
}
|
||||
};
|
||||
|
||||
const saveSelection = (selections) => {
|
||||
setSelections(selections);
|
||||
const onBlur = (event) => {
|
||||
if (
|
||||
acceptNewValues &&
|
||||
term &&
|
||||
[...extraOptions, ...options].map(([label]) => label).includes(term)
|
||||
) {
|
||||
event.preventDefault();
|
||||
return onSelect(term);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSelection = (fn) => {
|
||||
setSelections((selections) => {
|
||||
selections = fn(selections);
|
||||
if (hiddenField) {
|
||||
hiddenField.setAttribute('value', JSON.stringify(selections));
|
||||
fire(hiddenField, 'autosave:trigger');
|
||||
}
|
||||
return selections;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (value) => {
|
||||
|
@ -107,15 +127,15 @@ function ComboMultipleDropdownList({
|
|||
([val]) => val == value
|
||||
);
|
||||
const selectedValue = maybeValue && maybeValue[1];
|
||||
if (value) {
|
||||
if (selectedValue) {
|
||||
if (
|
||||
acceptNewValues &&
|
||||
extraOptions[0] &&
|
||||
extraOptions[0][0] == selectedValue
|
||||
) {
|
||||
setNewValues([...newValues, selectedValue]);
|
||||
setNewValues((newValues) => [...newValues, selectedValue]);
|
||||
}
|
||||
saveSelection([...selections, selectedValue]);
|
||||
saveSelection((selections) => [...selections, selectedValue]);
|
||||
}
|
||||
setTerm('');
|
||||
};
|
||||
|
@ -123,8 +143,12 @@ function ComboMultipleDropdownList({
|
|||
const onRemove = (label) => {
|
||||
const optionValue = optionValueByLabel(label);
|
||||
if (optionValue) {
|
||||
saveSelection(selections.filter((value) => value != optionValue));
|
||||
setNewValues(newValues.filter((value) => value != optionValue));
|
||||
saveSelection((selections) =>
|
||||
selections.filter((value) => value != optionValue)
|
||||
);
|
||||
setNewValues((newValues) =>
|
||||
newValues.filter((value) => value != optionValue)
|
||||
);
|
||||
}
|
||||
inputRef.current.focus();
|
||||
};
|
||||
|
@ -149,10 +173,11 @@ function ComboMultipleDropdownList({
|
|||
value={term}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
autocomplete={false}
|
||||
/>
|
||||
</ComboboxTokenLabel>
|
||||
{results && (
|
||||
{results && (results.length > 0 || !acceptNewValues) && (
|
||||
<ComboboxPopover className="shadow-popup">
|
||||
{results.length === 0 && (
|
||||
<p>
|
||||
|
|
|
@ -25,7 +25,8 @@ function ComboSearch({
|
|||
minimumInputLength,
|
||||
transformResult,
|
||||
allowInputValues = false,
|
||||
transformResults = defaultTransformResults
|
||||
transformResults = defaultTransformResults,
|
||||
className
|
||||
}) {
|
||||
const label = scope;
|
||||
const hiddenValueField = useMemo(
|
||||
|
@ -93,6 +94,7 @@ function ComboSearch({
|
|||
return (
|
||||
<Combobox aria-label={label} onSelect={handleOnSelect}>
|
||||
<ComboboxInput
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
onChange={handleOnChange}
|
||||
value={value}
|
||||
|
@ -134,7 +136,8 @@ ComboSearch.propTypes = {
|
|||
transformResult: PropTypes.func,
|
||||
transformResults: PropTypes.func,
|
||||
allowInputValues: PropTypes.bool,
|
||||
onChange: PropTypes.func
|
||||
onChange: PropTypes.func,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default ComboSearch;
|
||||
|
|
|
@ -1,257 +1,47 @@
|
|||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
useEffect
|
||||
} from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { GeoJSONLayer, ZoomControl } from 'react-mapbox-gl';
|
||||
import ReactMapboxGl, { ZoomControl } from 'react-mapbox-gl';
|
||||
import DrawControl from 'react-mapbox-gl-draw';
|
||||
import { MapIcon } from '@heroicons/react/outline';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||
|
||||
import { getJSON, ajax, fire } from '@utils';
|
||||
|
||||
import Mapbox from '../shared/mapbox/Mapbox';
|
||||
import { getMapStyle } from '../shared/mapbox/styles';
|
||||
import SwitchMapStyle from '../shared/mapbox/SwitchMapStyle';
|
||||
import MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl';
|
||||
import { FlashMessage } from '../shared/FlashMessage';
|
||||
|
||||
import ComboAdresseSearch from '../ComboAdresseSearch';
|
||||
import {
|
||||
polygonCadastresFill,
|
||||
polygonCadastresLine,
|
||||
readGeoFile
|
||||
} from './utils';
|
||||
import {
|
||||
noop,
|
||||
filterFeatureCollection,
|
||||
fitBounds,
|
||||
generateId,
|
||||
useEvent,
|
||||
findFeature
|
||||
} from '../shared/mapbox/utils';
|
||||
import { useMapboxEditor } from './useMapboxEditor';
|
||||
|
||||
function MapEditor({ featureCollection, url, preview, options }) {
|
||||
const drawControl = useRef(null);
|
||||
const [currentMap, setCurrentMap] = useState(null);
|
||||
const Mapbox = ReactMapboxGl({});
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState();
|
||||
const [style, setStyle] = useState('ortho');
|
||||
function MapEditor({ featureCollection, url, options, preview }) {
|
||||
const [cadastreEnabled, setCadastreEnabled] = useState(false);
|
||||
const [coords, setCoords] = useState([1.7, 46.9]);
|
||||
const [zoom, setZoom] = useState([5]);
|
||||
const [bbox, setBbox] = useState(featureCollection.bbox);
|
||||
const [importInputs, setImportInputs] = useState([]);
|
||||
const [cadastresFeatureCollection, setCadastresFeatureCollection] = useState(
|
||||
filterFeatureCollection(featureCollection, 'cadastre')
|
||||
);
|
||||
const mapStyle = useMemo(() => getMapStyle(style, options.layers), [
|
||||
style,
|
||||
options
|
||||
]);
|
||||
const hasCadastres = useMemo(() => options.layers.includes('cadastres'));
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setErrorMessage(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [errorMessage]);
|
||||
|
||||
const translations = [
|
||||
['.mapbox-gl-draw_line', 'Tracer une ligne'],
|
||||
['.mapbox-gl-draw_polygon', 'Dessiner un polygone'],
|
||||
['.mapbox-gl-draw_point', 'Ajouter un point'],
|
||||
['.mapbox-gl-draw_trash', 'Supprimer']
|
||||
];
|
||||
for (const [selector, translation] of translations) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
element.setAttribute('title', translation);
|
||||
}
|
||||
}
|
||||
|
||||
const onFeatureFocus = useCallback(
|
||||
({ detail }) => {
|
||||
const { id } = detail;
|
||||
const featureCollection = drawControl.current.draw.getAll();
|
||||
const feature = findFeature(featureCollection, id);
|
||||
if (feature) {
|
||||
fitBounds(currentMap, feature);
|
||||
}
|
||||
},
|
||||
[currentMap, drawControl.current]
|
||||
);
|
||||
|
||||
const onFeatureUpdate = useCallback(
|
||||
async ({ detail }) => {
|
||||
const { id, properties } = detail;
|
||||
const featureCollection = drawControl.current.draw.getAll();
|
||||
const feature = findFeature(featureCollection, id);
|
||||
|
||||
if (feature) {
|
||||
getJSON(`${url}/${id}`, { feature: { properties } }, 'patch');
|
||||
}
|
||||
},
|
||||
[url, drawControl.current]
|
||||
);
|
||||
|
||||
const updateFeaturesList = useCallback(
|
||||
async (features) => {
|
||||
const cadastres = features.find(
|
||||
({ geometry }) => geometry.type === 'Polygon'
|
||||
);
|
||||
await ajax({
|
||||
const {
|
||||
isSupported,
|
||||
error,
|
||||
inputs,
|
||||
onLoad,
|
||||
onStyleChange,
|
||||
onFileChange,
|
||||
drawRef,
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures,
|
||||
addInputFile,
|
||||
removeInputFile
|
||||
} = useMapboxEditor(featureCollection, {
|
||||
url,
|
||||
type: 'get',
|
||||
data: cadastres ? 'cadastres=update' : ''
|
||||
enabled: !preview,
|
||||
cadastreEnabled
|
||||
});
|
||||
fire(document, 'ds:page:update');
|
||||
},
|
||||
[url]
|
||||
);
|
||||
|
||||
const onCadastresUpdate = useCallback(({ detail }) => {
|
||||
setCadastresFeatureCollection(
|
||||
filterFeatureCollection(detail.featureCollection, 'cadastre')
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEvent('map:feature:focus', onFeatureFocus);
|
||||
useEvent('map:feature:update', onFeatureUpdate);
|
||||
useEvent('cadastres:update', onCadastresUpdate);
|
||||
|
||||
function setFeatureId(lid, feature) {
|
||||
const draw = drawControl.current.draw;
|
||||
draw.setFeatureProperty(lid, 'id', feature.properties.id);
|
||||
}
|
||||
|
||||
function updateImportInputs(inputs, inputId) {
|
||||
const updatedInputs = inputs.filter((input) => input.id !== inputId);
|
||||
setImportInputs(updatedInputs);
|
||||
}
|
||||
|
||||
async function onDrawCreate({ features }) {
|
||||
try {
|
||||
for (const feature of features) {
|
||||
const data = await getJSON(url, { feature }, 'post');
|
||||
setFeatureId(feature.id, data.feature);
|
||||
}
|
||||
|
||||
updateFeaturesList(features);
|
||||
} catch {
|
||||
setErrorMessage('Le polygone dessiné n’est pas valide.');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrawUpdate({ features }) {
|
||||
try {
|
||||
for (const feature of features) {
|
||||
const { id } = feature.properties;
|
||||
if (id) {
|
||||
await getJSON(`${url}/${id}`, { feature }, 'patch');
|
||||
} else {
|
||||
const data = await getJSON(url, { feature }, 'post');
|
||||
setFeatureId(feature.id, data.feature);
|
||||
}
|
||||
}
|
||||
|
||||
updateFeaturesList(features);
|
||||
} catch {
|
||||
setErrorMessage('Le polygone dessiné n’est pas valide.');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrawDelete({ features }) {
|
||||
for (const feature of features) {
|
||||
const { id } = feature.properties;
|
||||
await getJSON(`${url}/${id}`, null, 'delete');
|
||||
}
|
||||
|
||||
updateFeaturesList(features);
|
||||
}
|
||||
|
||||
function onMapLoad(map) {
|
||||
setCurrentMap(map);
|
||||
|
||||
drawControl.current.draw.set(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur')
|
||||
);
|
||||
}
|
||||
|
||||
const onFileImport = async (e, inputId) => {
|
||||
try {
|
||||
const featureCollection = await readGeoFile(e.target.files[0]);
|
||||
const resultFeatureCollection = await getJSON(
|
||||
`${url}/import`,
|
||||
featureCollection,
|
||||
'post'
|
||||
);
|
||||
let inputs = [...importInputs];
|
||||
const setInputs = inputs.map((input) => {
|
||||
if (input.id === inputId) {
|
||||
input.disabled = true;
|
||||
input.hasValue = true;
|
||||
resultFeatureCollection.features.forEach((resultFeature) => {
|
||||
featureCollection.features.forEach((feature) => {
|
||||
if (
|
||||
JSON.stringify(resultFeature.geometry) ===
|
||||
JSON.stringify(feature.geometry)
|
||||
) {
|
||||
input.featureIds.push(resultFeature.properties.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return input;
|
||||
const [style, setStyle] = useMapStyle(options.layers, {
|
||||
onStyleChange,
|
||||
cadastreEnabled
|
||||
});
|
||||
|
||||
drawControl.current.draw.set(
|
||||
filterFeatureCollection(
|
||||
resultFeatureCollection,
|
||||
'selection_utilisateur'
|
||||
)
|
||||
);
|
||||
|
||||
updateFeaturesList(resultFeatureCollection.features);
|
||||
setImportInputs(setInputs);
|
||||
setBbox(resultFeatureCollection.bbox);
|
||||
} catch {
|
||||
setErrorMessage('Le fichier importé contient des polygones invalides.');
|
||||
}
|
||||
};
|
||||
|
||||
const addInputFile = (e) => {
|
||||
e.preventDefault();
|
||||
let inputs = [...importInputs];
|
||||
inputs.push({
|
||||
id: generateId(),
|
||||
disabled: false,
|
||||
featureIds: [],
|
||||
hasValue: false
|
||||
});
|
||||
setImportInputs(inputs);
|
||||
};
|
||||
|
||||
const removeInputFile = async (e, inputId) => {
|
||||
e.preventDefault();
|
||||
const draw = drawControl.current.draw;
|
||||
const featureCollection = draw.getAll();
|
||||
let inputs = [...importInputs];
|
||||
const inputToRemove = inputs.find((input) => input.id === inputId);
|
||||
|
||||
for (const feature of featureCollection.features) {
|
||||
if (inputToRemove.featureIds.includes(feature.properties.id)) {
|
||||
const featureToRemove = draw.get(feature.id);
|
||||
await getJSON(`${url}/${feature.properties.id}`, null, 'delete');
|
||||
draw.delete(feature.id).getAll();
|
||||
updateFeaturesList([featureToRemove]);
|
||||
}
|
||||
}
|
||||
updateImportInputs(inputs, inputId);
|
||||
};
|
||||
|
||||
if (!mapboxgl.supported()) {
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<p>
|
||||
Nous ne pouvons pas afficher notre éditeur de carte car il est
|
||||
|
@ -263,9 +53,7 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
{errorMessage && (
|
||||
<FlashMessage message={errorMessage} level="alert" fixed={true} />
|
||||
)}
|
||||
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
||||
<div>
|
||||
<p style={{ marginBottom: '20px' }}>
|
||||
Besoin d'aide ?
|
||||
|
@ -278,12 +66,12 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="file-import" style={{ marginBottom: '20px' }}>
|
||||
<div className="file-import" style={{ marginBottom: '10px' }}>
|
||||
<button className="button send primary" onClick={addInputFile}>
|
||||
Ajouter un fichier GPX ou KML
|
||||
</button>
|
||||
<div>
|
||||
{importInputs.map((input) => (
|
||||
{inputs.map((input) => (
|
||||
<div key={input.id}>
|
||||
<input
|
||||
title="Choisir un fichier gpx ou kml"
|
||||
|
@ -292,7 +80,7 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
|||
type="file"
|
||||
accept=".gpx, .kml"
|
||||
disabled={input.disabled}
|
||||
onChange={(e) => onFileImport(e, input.id)}
|
||||
onChange={(e) => onFileChange(e, input.id)}
|
||||
/>
|
||||
{input.hasValue && (
|
||||
<span
|
||||
|
@ -310,10 +98,11 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
|||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '50px'
|
||||
marginBottom: '10px'
|
||||
}}
|
||||
>
|
||||
<ComboAdresseSearch
|
||||
className="no-margin"
|
||||
placeholder="Rechercher une adresse : saisissez au moins 2 caractères"
|
||||
allowInputValues={false}
|
||||
onChange={(_, { geometry: { coordinates } }) => {
|
||||
|
@ -323,28 +112,18 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
|||
/>
|
||||
</div>
|
||||
<Mapbox
|
||||
onStyleLoad={(map) => onMapLoad(map)}
|
||||
fitBounds={bbox}
|
||||
fitBoundsOptions={{ padding: 100 }}
|
||||
onStyleLoad={(map) => onLoad(map)}
|
||||
center={coords}
|
||||
zoom={zoom}
|
||||
style={mapStyle}
|
||||
containerStyle={{
|
||||
height: '500px'
|
||||
}}
|
||||
style={style}
|
||||
containerStyle={{ height: '500px' }}
|
||||
>
|
||||
{hasCadastres ? (
|
||||
<GeoJSONLayer
|
||||
data={cadastresFeatureCollection}
|
||||
fillPaint={polygonCadastresFill}
|
||||
linePaint={polygonCadastresLine}
|
||||
/>
|
||||
) : null}
|
||||
{!cadastreEnabled && (
|
||||
<DrawControl
|
||||
ref={drawControl}
|
||||
onDrawCreate={preview ? noop : onDrawCreate}
|
||||
onDrawUpdate={preview ? noop : onDrawUpdate}
|
||||
onDrawDelete={preview ? noop : onDrawDelete}
|
||||
ref={drawRef}
|
||||
onDrawCreate={createFeatures}
|
||||
onDrawUpdate={updateFeatures}
|
||||
onDrawDelete={deleteFeatures}
|
||||
displayControlsDefault={false}
|
||||
controls={{
|
||||
point: true,
|
||||
|
@ -353,8 +132,23 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
|||
trash: true
|
||||
}}
|
||||
/>
|
||||
<SwitchMapStyle style={style} setStyle={setStyle} ign={options.ign} />
|
||||
)}
|
||||
<MapStyleControl style={style.id} setStyle={setStyle} />
|
||||
<ZoomControl />
|
||||
{options.layers.includes('cadastres') && (
|
||||
<div className="cadastres-selection-control mapboxgl-ctrl-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCadastreEnabled((cadastreEnabled) => !cadastreEnabled)
|
||||
}
|
||||
title="Sélectionner les parcelles cadastrales"
|
||||
className={cadastreEnabled ? 'on' : ''}
|
||||
>
|
||||
<MapIcon className="icon-size" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Mapbox>
|
||||
</>
|
||||
);
|
||||
|
@ -363,15 +157,11 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
|||
MapEditor.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array,
|
||||
id: PropTypes.number
|
||||
features: PropTypes.array
|
||||
}),
|
||||
url: PropTypes.string,
|
||||
preview: PropTypes.bool,
|
||||
options: PropTypes.shape({
|
||||
layers: PropTypes.array,
|
||||
ign: PropTypes.bool
|
||||
})
|
||||
options: PropTypes.shape({ layers: PropTypes.array })
|
||||
};
|
||||
|
||||
export default MapEditor;
|
||||
|
|
|
@ -1,17 +1,28 @@
|
|||
import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js';
|
||||
import { generateId } from '../shared/mapbox/utils';
|
||||
|
||||
export const polygonCadastresFill = {
|
||||
'fill-color': '#EC3323',
|
||||
'fill-opacity': 0.3
|
||||
};
|
||||
export function readGeoFile(file) {
|
||||
const isGpxFile = file.name.includes('.gpx');
|
||||
const reader = new FileReader();
|
||||
|
||||
export const polygonCadastresLine = {
|
||||
'line-color': 'rgba(255, 0, 0, 1)',
|
||||
'line-width': 4,
|
||||
'line-dasharray': [1, 1]
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
reader.onload = (event) => {
|
||||
const xml = new DOMParser().parseFromString(
|
||||
event.target.result,
|
||||
'text/xml'
|
||||
);
|
||||
const featureCollection = normalizeFeatureCollection(
|
||||
isGpxFile ? gpx(xml) : kml(xml),
|
||||
file.name
|
||||
);
|
||||
|
||||
export function normalizeFeatureCollection(featureCollection) {
|
||||
resolve(featureCollection);
|
||||
};
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFeatureCollection(featureCollection, filename) {
|
||||
const features = [];
|
||||
for (const feature of featureCollection.features) {
|
||||
switch (feature.geometry.type) {
|
||||
|
@ -65,26 +76,13 @@ export function normalizeFeatureCollection(featureCollection) {
|
|||
}
|
||||
}
|
||||
|
||||
featureCollection.features = features;
|
||||
featureCollection.filename = `${generateId()}-${filename}`;
|
||||
featureCollection.features = features.map((feature) => ({
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
filename: featureCollection.filename
|
||||
}
|
||||
}));
|
||||
return featureCollection;
|
||||
}
|
||||
|
||||
export function readGeoFile(file) {
|
||||
const isGpxFile = file.name.includes('.gpx');
|
||||
const reader = new FileReader();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
reader.onload = (event) => {
|
||||
const xml = new DOMParser().parseFromString(
|
||||
event.target.result,
|
||||
'text/xml'
|
||||
);
|
||||
const featureCollection = normalizeFeatureCollection(
|
||||
isGpxFile ? gpx(xml) : kml(xml)
|
||||
);
|
||||
|
||||
resolve(featureCollection);
|
||||
};
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
});
|
||||
}
|
553
app/javascript/components/MapEditor/useMapboxEditor.js
Normal file
553
app/javascript/components/MapEditor/useMapboxEditor.js
Normal file
|
@ -0,0 +1,553 @@
|
|||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { getJSON, ajax, fire } from '@utils';
|
||||
|
||||
import { readGeoFile } from './readGeoFile';
|
||||
import {
|
||||
filterFeatureCollection,
|
||||
generateId,
|
||||
findFeature,
|
||||
getBounds,
|
||||
defer
|
||||
} from '../shared/mapbox/utils';
|
||||
|
||||
const SOURCE_SELECTION_UTILISATEUR = 'selection_utilisateur';
|
||||
const SOURCE_CADASTRE = 'cadastre';
|
||||
|
||||
export function useMapboxEditor(
|
||||
featureCollection,
|
||||
{ url, enabled = true, cadastreEnabled = true }
|
||||
) {
|
||||
const [isLoaded, setLoaded] = useState(false);
|
||||
const mapRef = useRef();
|
||||
const drawRef = useRef();
|
||||
const loadedRef = useRef(defer());
|
||||
const selectedCadastresRef = useRef(() => new Set());
|
||||
const isSupported = useMemo(() => mapboxgl.supported());
|
||||
|
||||
useEffect(() => {
|
||||
const translations = [
|
||||
['.mapbox-gl-draw_line', 'Tracer une ligne'],
|
||||
['.mapbox-gl-draw_polygon', 'Dessiner un polygone'],
|
||||
['.mapbox-gl-draw_point', 'Ajouter un point'],
|
||||
['.mapbox-gl-draw_trash', 'Supprimer']
|
||||
];
|
||||
for (const [selector, translation] of translations) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
element.setAttribute('title', translation);
|
||||
}
|
||||
}
|
||||
}, [isLoaded]);
|
||||
|
||||
const addEventListener = useCallback((eventName, target, callback) => {
|
||||
loadedRef.current.promise.then(() => {
|
||||
mapRef.current.on(eventName, target, callback);
|
||||
});
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.off(eventName, target, callback);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const highlightFeature = useCallback((cid, highlight) => {
|
||||
if (highlight) {
|
||||
selectedCadastresRef.current.add(cid);
|
||||
} else {
|
||||
selectedCadastresRef.current.delete(cid);
|
||||
}
|
||||
if (selectedCadastresRef.current.size == 0) {
|
||||
mapRef.current.setFilter('parcelle-highlighted', ['in', 'id', '']);
|
||||
} else {
|
||||
mapRef.current.setFilter('parcelle-highlighted', [
|
||||
'in',
|
||||
'id',
|
||||
...selectedCadastresRef.current
|
||||
]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fitBounds = useCallback((bbox) => {
|
||||
mapRef.current.fitBounds(bbox, { padding: 100 });
|
||||
}, []);
|
||||
|
||||
const hoverFeature = useCallback((feature, hover) => {
|
||||
if (!selectedCadastresRef.current.has(feature.properties.id)) {
|
||||
mapRef.current.setFeatureState(
|
||||
{
|
||||
source: 'cadastre',
|
||||
sourceLayer: 'parcelles',
|
||||
id: feature.id
|
||||
},
|
||||
{ hover }
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addFeatures = useCallback((features, external) => {
|
||||
for (const feature of features) {
|
||||
if (feature.lid) {
|
||||
drawRef.current?.draw.setFeatureProperty(
|
||||
feature.lid,
|
||||
'id',
|
||||
feature.properties.id
|
||||
);
|
||||
delete feature.lid;
|
||||
}
|
||||
if (external) {
|
||||
if (feature.properties.source == SOURCE_SELECTION_UTILISATEUR) {
|
||||
drawRef.current?.draw.add({ id: feature.properties.id, ...feature });
|
||||
} else {
|
||||
highlightFeature(feature.properties.cid, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeFeatures = useCallback((features, external) => {
|
||||
if (external) {
|
||||
for (const feature of features) {
|
||||
if (feature.properties.source == SOURCE_SELECTION_UTILISATEUR) {
|
||||
drawRef.current?.draw.delete(feature.id);
|
||||
} else {
|
||||
highlightFeature(feature.properties.cid, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onLoad = useCallback(
|
||||
(map) => {
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = map;
|
||||
mapRef.current.fitBounds(props.featureCollection.bbox, {
|
||||
padding: 100
|
||||
});
|
||||
onStyleChange();
|
||||
setLoaded(true);
|
||||
loadedRef.current.resolve();
|
||||
}
|
||||
},
|
||||
[featureCollection]
|
||||
);
|
||||
|
||||
const addEventListeners = useCallback((events) => {
|
||||
const unsubscribe = Object.entries(
|
||||
events
|
||||
).map(([eventName, [target, callback]]) =>
|
||||
addEventListener(eventName, target, callback)
|
||||
);
|
||||
return () => unsubscribe.map((unsubscribe) => unsubscribe());
|
||||
}, []);
|
||||
|
||||
const {
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures,
|
||||
...props
|
||||
} = useFeatureCollection(featureCollection, {
|
||||
url,
|
||||
enabled: isSupported && enabled,
|
||||
addFeatures,
|
||||
removeFeatures
|
||||
});
|
||||
|
||||
const onStyleChange = useCallback(() => {
|
||||
if (mapRef.current) {
|
||||
const featureCollection = props.featureCollection;
|
||||
if (!cadastreEnabled) {
|
||||
drawRef.current?.draw.set(
|
||||
filterFeatureCollection(
|
||||
featureCollection,
|
||||
SOURCE_SELECTION_UTILISATEUR
|
||||
)
|
||||
);
|
||||
}
|
||||
selectedCadastresRef.current = new Set(
|
||||
filterFeatureCollection(
|
||||
featureCollection,
|
||||
SOURCE_CADASTRE
|
||||
).features.map(({ properties }) => properties.cid)
|
||||
);
|
||||
if (selectedCadastresRef.current.size > 0) {
|
||||
mapRef.current.setFilter('parcelle-highlighted', [
|
||||
'in',
|
||||
'id',
|
||||
...selectedCadastresRef.current
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, [props.featureCollection, cadastreEnabled]);
|
||||
|
||||
useExternalEvents(props.featureCollection, {
|
||||
fitBounds,
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures
|
||||
});
|
||||
useCadastres(props.featureCollection, {
|
||||
addEventListeners,
|
||||
hoverFeature,
|
||||
createFeatures,
|
||||
deleteFeatures,
|
||||
enabled: cadastreEnabled
|
||||
});
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
onLoad,
|
||||
onStyleChange,
|
||||
drawRef,
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures,
|
||||
...props,
|
||||
...useImportFiles(props.featureCollection, {
|
||||
createFeatures,
|
||||
deleteFeatures
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function useFeatureCollection(
|
||||
initialFeatureCollection,
|
||||
{ url, addFeatures, removeFeatures, enabled = true }
|
||||
) {
|
||||
const [error, onError] = useError();
|
||||
const [featureCollection, setFeatureCollection] = useState(
|
||||
initialFeatureCollection
|
||||
);
|
||||
const updateFeatureCollection = useCallback(
|
||||
(callback) => {
|
||||
setFeatureCollection(({ features }) => ({
|
||||
type: 'FeatureCollection',
|
||||
features: callback(features)
|
||||
}));
|
||||
ajax({ url, type: 'GET' })
|
||||
.then(() => fire(document, 'ds:page:update'))
|
||||
.catch(() => {});
|
||||
},
|
||||
[setFeatureCollection]
|
||||
);
|
||||
|
||||
const createFeatures = useCallback(
|
||||
async ({ features, source = SOURCE_SELECTION_UTILISATEUR, external }) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newFeatures = [];
|
||||
for (const feature of features) {
|
||||
const data = await getJSON(url, { feature, source }, 'post');
|
||||
if (data) {
|
||||
if (source == SOURCE_SELECTION_UTILISATEUR) {
|
||||
data.feature.lid = feature.id;
|
||||
}
|
||||
newFeatures.push(data.feature);
|
||||
}
|
||||
}
|
||||
addFeatures(newFeatures, external);
|
||||
updateFeatureCollection(
|
||||
(features) => [...features, ...newFeatures],
|
||||
external
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
onError('Le polygone dessiné n’est pas valide.');
|
||||
}
|
||||
},
|
||||
[enabled, url, updateFeatureCollection, addFeatures]
|
||||
);
|
||||
|
||||
const updateFeatures = useCallback(
|
||||
async ({ features, source = SOURCE_SELECTION_UTILISATEUR, external }) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newFeatures = [];
|
||||
for (const feature of features) {
|
||||
const { id } = feature.properties;
|
||||
if (id) {
|
||||
await getJSON(`${url}/${id}`, { feature }, 'patch');
|
||||
} else {
|
||||
const data = await getJSON(url, { feature, source }, 'post');
|
||||
if (data) {
|
||||
if (source == SOURCE_SELECTION_UTILISATEUR) {
|
||||
data.feature.lid = feature.id;
|
||||
}
|
||||
newFeatures.push(data.feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newFeatures.length > 0) {
|
||||
addFeatures(newFeatures, external);
|
||||
updateFeatureCollection((features) => [...features, ...newFeatures]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
onError('Le polygone dessiné n’est pas valide.');
|
||||
}
|
||||
},
|
||||
[enabled, url, updateFeatureCollection, addFeatures]
|
||||
);
|
||||
|
||||
const deleteFeatures = useCallback(
|
||||
async ({ features, external }) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const deletedFeatures = [];
|
||||
for (const feature of features) {
|
||||
const { id } = feature.properties;
|
||||
await getJSON(`${url}/${id}`, null, 'delete');
|
||||
deletedFeatures.push(feature);
|
||||
}
|
||||
removeFeatures(deletedFeatures, external);
|
||||
const deletedFeatureIds = deletedFeatures.map(
|
||||
({ properties }) => properties.id
|
||||
);
|
||||
updateFeatureCollection(
|
||||
(features) =>
|
||||
features.filter(
|
||||
({ properties }) => !deletedFeatureIds.includes(properties.id)
|
||||
),
|
||||
external
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
onError('Le polygone n’a pas pu être supprimé.');
|
||||
}
|
||||
},
|
||||
[enabled, url, updateFeatureCollection, removeFeatures]
|
||||
);
|
||||
|
||||
return {
|
||||
featureCollection,
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
function useImportFiles(featureCollection, { createFeatures, deleteFeatures }) {
|
||||
const [inputs, setInputs] = useState([]);
|
||||
const addInput = useCallback(
|
||||
(input) => {
|
||||
setInputs((inputs) => [...inputs, input]);
|
||||
},
|
||||
[setInputs]
|
||||
);
|
||||
const removeInput = useCallback(
|
||||
(inputId) => {
|
||||
setInputs((inputs) => inputs.filter((input) => input.id !== inputId));
|
||||
},
|
||||
[setInputs]
|
||||
);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
async (event, inputId) => {
|
||||
const { features, filename } = await readGeoFile(event.target.files[0]);
|
||||
createFeatures({ features, external: true });
|
||||
setInputs((inputs) => {
|
||||
return inputs.map((input) => {
|
||||
if (input.id === inputId) {
|
||||
return { ...input, disabled: true, hasValue: true, filename };
|
||||
}
|
||||
return input;
|
||||
});
|
||||
});
|
||||
},
|
||||
[setInputs, createFeatures, featureCollection]
|
||||
);
|
||||
|
||||
const addInputFile = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
addInput({
|
||||
id: generateId(),
|
||||
disabled: false,
|
||||
hasValue: false,
|
||||
filename: ''
|
||||
});
|
||||
},
|
||||
[addInput]
|
||||
);
|
||||
|
||||
const removeInputFile = useCallback(
|
||||
(event, inputId) => {
|
||||
event.preventDefault();
|
||||
const { filename } = inputs.find((input) => input.id === inputId);
|
||||
const features = featureCollection.features.filter(
|
||||
(feature) => feature.properties.filename == filename
|
||||
);
|
||||
deleteFeatures({ features, external: true });
|
||||
removeInput(inputId);
|
||||
},
|
||||
[removeInput, deleteFeatures, featureCollection]
|
||||
);
|
||||
|
||||
return {
|
||||
inputs,
|
||||
onFileChange,
|
||||
addInputFile,
|
||||
removeInputFile
|
||||
};
|
||||
}
|
||||
|
||||
function useExternalEvents(
|
||||
featureCollection,
|
||||
{ fitBounds, createFeatures, updateFeatures, deleteFeatures }
|
||||
) {
|
||||
const onFeatureFocus = useCallback(
|
||||
({ detail }) => {
|
||||
const { id, bbox } = detail;
|
||||
if (id) {
|
||||
const feature = findFeature(featureCollection, id);
|
||||
if (feature) {
|
||||
fitBounds(getBounds(feature.geometry));
|
||||
}
|
||||
} else if (bbox) {
|
||||
fitBounds(bbox);
|
||||
}
|
||||
},
|
||||
[featureCollection, fitBounds]
|
||||
);
|
||||
|
||||
const onFeatureCreate = useCallback(
|
||||
({ detail }) => {
|
||||
const { geometry, properties } = detail;
|
||||
|
||||
if (geometry) {
|
||||
createFeatures({
|
||||
features: [{ geometry, properties }],
|
||||
external: true
|
||||
});
|
||||
}
|
||||
},
|
||||
[createFeatures]
|
||||
);
|
||||
|
||||
const onFeatureUpdate = useCallback(
|
||||
({ detail }) => {
|
||||
const { id, properties } = detail;
|
||||
const feature = findFeature(featureCollection, id);
|
||||
|
||||
if (feature) {
|
||||
feature.properties = { ...feature.properties, ...properties };
|
||||
updateFeatures({ features: [feature], external: true });
|
||||
}
|
||||
},
|
||||
[featureCollection, updateFeatures]
|
||||
);
|
||||
|
||||
const onFeatureDelete = useCallback(
|
||||
({ detail }) => {
|
||||
const { id } = detail;
|
||||
const feature = findFeature(featureCollection, id);
|
||||
|
||||
if (feature) {
|
||||
deleteFeatures({ features: [feature], external: true });
|
||||
}
|
||||
},
|
||||
[featureCollection, deleteFeatures]
|
||||
);
|
||||
|
||||
useEvent('map:feature:focus', onFeatureFocus);
|
||||
useEvent('map:feature:create', onFeatureCreate);
|
||||
useEvent('map:feature:update', onFeatureUpdate);
|
||||
useEvent('map:feature:delete', onFeatureDelete);
|
||||
}
|
||||
|
||||
function useCadastres(
|
||||
featureCollection,
|
||||
{
|
||||
addEventListeners,
|
||||
hoverFeature,
|
||||
createFeatures,
|
||||
deleteFeatures,
|
||||
enabled = true
|
||||
}
|
||||
) {
|
||||
const hoveredFeature = useRef();
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event) => {
|
||||
if (event.features.length > 0) {
|
||||
const feature = event.features[0];
|
||||
if (hoveredFeature.current?.id != feature.id) {
|
||||
if (hoveredFeature.current) {
|
||||
hoverFeature(hoveredFeature.current, false);
|
||||
}
|
||||
hoveredFeature.current = feature;
|
||||
hoverFeature(feature, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[hoverFeature]
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
if (hoveredFeature.current) {
|
||||
hoverFeature(hoveredFeature.current, false);
|
||||
}
|
||||
hoveredFeature.current = null;
|
||||
}, [hoverFeature]);
|
||||
|
||||
const onClick = useCallback(
|
||||
async (event) => {
|
||||
if (event.features.length > 0) {
|
||||
const currentId = event.features[0].properties.id;
|
||||
const feature = findFeature(
|
||||
filterFeatureCollection(featureCollection, SOURCE_CADASTRE),
|
||||
currentId,
|
||||
'cid'
|
||||
);
|
||||
if (feature) {
|
||||
deleteFeatures({
|
||||
features: [feature],
|
||||
source: SOURCE_CADASTRE,
|
||||
external: true
|
||||
});
|
||||
} else {
|
||||
createFeatures({
|
||||
features: event.features,
|
||||
source: SOURCE_CADASTRE,
|
||||
external: true
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[featureCollection, createFeatures, deleteFeatures]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
return addEventListeners({
|
||||
click: ['parcelles-fill', onClick],
|
||||
mousemove: ['parcelles-fill', onMouseMove],
|
||||
mouseleave: ['parcelles-fill', onMouseLeave]
|
||||
});
|
||||
}
|
||||
}, [onClick, onMouseMove, onMouseLeave, enabled]);
|
||||
}
|
||||
|
||||
function useError() {
|
||||
const [error, onError] = useState();
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onError(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error]);
|
||||
|
||||
return [error, onError];
|
||||
}
|
||||
|
||||
export function useEvent(eventName, callback) {
|
||||
return useEffect(() => {
|
||||
addEventListener(eventName, callback);
|
||||
return () => removeEventListener(eventName, callback);
|
||||
}, [eventName, callback]);
|
||||
}
|
|
@ -1,141 +1,28 @@
|
|||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { ZoomControl, GeoJSONLayer } from 'react-mapbox-gl';
|
||||
import mapboxgl, { Popup } from 'mapbox-gl';
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactMapboxGl, { ZoomControl, GeoJSONLayer } from 'react-mapbox-gl';
|
||||
import PropTypes from 'prop-types';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
import Mapbox from '../shared/mapbox/Mapbox';
|
||||
import { getMapStyle } from '../shared/mapbox/styles';
|
||||
import SwitchMapStyle from '../shared/mapbox/SwitchMapStyle';
|
||||
import MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl';
|
||||
import {
|
||||
filterFeatureCollection,
|
||||
filterFeatureCollectionByGeometryType,
|
||||
useEvent,
|
||||
findFeature,
|
||||
fitBounds,
|
||||
getCenter
|
||||
filterFeatureCollectionByGeometryType
|
||||
} from '../shared/mapbox/utils';
|
||||
import { useMapbox } from './useMapbox';
|
||||
|
||||
const Mapbox = ReactMapboxGl({});
|
||||
|
||||
const MapReader = ({ featureCollection, options }) => {
|
||||
const [currentMap, setCurrentMap] = useState(null);
|
||||
const [style, setStyle] = useState('ortho');
|
||||
const cadastresFeatureCollection = useMemo(
|
||||
() => filterFeatureCollection(featureCollection, 'cadastre'),
|
||||
[featureCollection]
|
||||
);
|
||||
const selectionsUtilisateurFeatureCollection = useMemo(
|
||||
() => filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
[featureCollection]
|
||||
);
|
||||
const selectionsLineFeatureCollection = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
selectionsUtilisateurFeatureCollection,
|
||||
'LineString'
|
||||
),
|
||||
[selectionsUtilisateurFeatureCollection]
|
||||
);
|
||||
const selectionsPolygonFeatureCollection = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
selectionsUtilisateurFeatureCollection,
|
||||
'Polygon'
|
||||
),
|
||||
[selectionsUtilisateurFeatureCollection]
|
||||
);
|
||||
const selectionsPointFeatureCollection = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
selectionsUtilisateurFeatureCollection,
|
||||
'Point'
|
||||
),
|
||||
[selectionsUtilisateurFeatureCollection]
|
||||
);
|
||||
const hasCadastres = useMemo(() => options.layers.includes('cadastres'));
|
||||
const mapStyle = useMemo(() => getMapStyle(style, options.layers), [
|
||||
style,
|
||||
options
|
||||
]);
|
||||
const popup = useMemo(
|
||||
() =>
|
||||
new Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false
|
||||
})
|
||||
);
|
||||
const {
|
||||
isSupported,
|
||||
onLoad,
|
||||
onStyleChange,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
} = useMapbox(featureCollection);
|
||||
const [style, setStyle] = useMapStyle(options.layers, { onStyleChange });
|
||||
|
||||
const onMouseEnter = useCallback(
|
||||
(event) => {
|
||||
const feature = event.features[0];
|
||||
if (feature.properties && feature.properties.description) {
|
||||
const coordinates = getCenter(feature.geometry, event.lngLat);
|
||||
const description = feature.properties.description;
|
||||
currentMap.getCanvas().style.cursor = 'pointer';
|
||||
popup.setLngLat(coordinates).setHTML(description).addTo(currentMap);
|
||||
} else {
|
||||
popup.remove();
|
||||
}
|
||||
},
|
||||
[currentMap, popup]
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
currentMap.getCanvas().style.cursor = '';
|
||||
popup.remove();
|
||||
}, [currentMap, popup]);
|
||||
|
||||
const onFeatureFocus = useCallback(
|
||||
({ detail }) => {
|
||||
const feature = findFeature(featureCollection, detail.id);
|
||||
if (feature) {
|
||||
fitBounds(currentMap, feature);
|
||||
}
|
||||
},
|
||||
[currentMap, featureCollection]
|
||||
);
|
||||
|
||||
useEvent('map:feature:focus', onFeatureFocus);
|
||||
|
||||
const [a1, a2, b1, b2] = featureCollection.bbox;
|
||||
const boundData = [
|
||||
[a1, a2],
|
||||
[b1, b2]
|
||||
];
|
||||
|
||||
const polygonSelectionFill = {
|
||||
'fill-color': '#EC3323',
|
||||
'fill-opacity': 0.5
|
||||
};
|
||||
|
||||
const polygonSelectionLine = {
|
||||
'line-color': 'rgba(255, 0, 0, 1)',
|
||||
'line-width': 4
|
||||
};
|
||||
|
||||
const lineStringSelectionLine = {
|
||||
'line-color': 'rgba(55, 42, 127, 1.00)',
|
||||
'line-width': 3
|
||||
};
|
||||
|
||||
const pointSelectionFill = {
|
||||
'circle-color': '#EC3323'
|
||||
};
|
||||
|
||||
const polygonCadastresFill = {
|
||||
'fill-color': '#FAD859',
|
||||
'fill-opacity': 0.5
|
||||
};
|
||||
|
||||
const polygonCadastresLine = {
|
||||
'line-color': 'rgba(156, 160, 144, 255)',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [1, 1]
|
||||
};
|
||||
|
||||
function onMapLoad(map) {
|
||||
setCurrentMap(map);
|
||||
}
|
||||
|
||||
if (!mapboxgl.supported()) {
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<p>
|
||||
Nous ne pouvons pas afficher la carte car elle est imcompatible avec
|
||||
|
@ -147,58 +34,155 @@ const MapReader = ({ featureCollection, options }) => {
|
|||
|
||||
return (
|
||||
<Mapbox
|
||||
onStyleLoad={(map) => onMapLoad(map)}
|
||||
fitBounds={boundData}
|
||||
fitBoundsOptions={{ padding: 100 }}
|
||||
style={mapStyle}
|
||||
containerStyle={{
|
||||
height: '400px',
|
||||
width: '100%'
|
||||
}}
|
||||
onStyleLoad={(map) => onLoad(map)}
|
||||
style={style}
|
||||
containerStyle={{ height: '400px' }}
|
||||
>
|
||||
<GeoJSONLayer
|
||||
data={selectionsPolygonFeatureCollection}
|
||||
fillPaint={polygonSelectionFill}
|
||||
linePaint={polygonSelectionLine}
|
||||
fillOnMouseEnter={onMouseEnter}
|
||||
fillOnMouseLeave={onMouseLeave}
|
||||
<SelectionUtilisateurPolygonLayer
|
||||
featureCollection={featureCollection}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
<GeoJSONLayer
|
||||
data={selectionsLineFeatureCollection}
|
||||
linePaint={lineStringSelectionLine}
|
||||
lineOnMouseEnter={onMouseEnter}
|
||||
lineOnMouseLeave={onMouseLeave}
|
||||
<SelectionUtilisateurLineLayer
|
||||
featureCollection={featureCollection}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
<GeoJSONLayer
|
||||
data={selectionsPointFeatureCollection}
|
||||
circlePaint={pointSelectionFill}
|
||||
circleOnMouseEnter={onMouseEnter}
|
||||
circleOnMouseLeave={onMouseLeave}
|
||||
<SelectionUtilisateurPointLayer
|
||||
featureCollection={featureCollection}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
{hasCadastres ? (
|
||||
<GeoJSONLayer
|
||||
data={cadastresFeatureCollection}
|
||||
fillPaint={polygonCadastresFill}
|
||||
linePaint={polygonCadastresLine}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SwitchMapStyle style={style} setStyle={setStyle} ign={options.ign} />
|
||||
<MapStyleControl style={style.id} setStyle={setStyle} />
|
||||
<ZoomControl />
|
||||
</Mapbox>
|
||||
);
|
||||
};
|
||||
|
||||
MapReader.propTypes = {
|
||||
const polygonSelectionFill = {
|
||||
'fill-color': '#EC3323',
|
||||
'fill-opacity': 0.5
|
||||
};
|
||||
const polygonSelectionLine = {
|
||||
'line-color': 'rgba(255, 0, 0, 1)',
|
||||
'line-width': 4
|
||||
};
|
||||
const lineStringSelectionLine = {
|
||||
'line-color': 'rgba(55, 42, 127, 1.00)',
|
||||
'line-width': 3
|
||||
};
|
||||
const pointSelectionFill = {
|
||||
'circle-color': '#EC3323'
|
||||
};
|
||||
|
||||
function SelectionUtilisateurPolygonLayer({
|
||||
featureCollection,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'Polygon'
|
||||
),
|
||||
[featureCollection]
|
||||
);
|
||||
|
||||
return (
|
||||
<GeoJSONLayer
|
||||
data={data}
|
||||
fillPaint={polygonSelectionFill}
|
||||
linePaint={polygonSelectionLine}
|
||||
fillOnMouseEnter={onMouseEnter}
|
||||
fillOnMouseLeave={onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionUtilisateurLineLayer({
|
||||
featureCollection,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'LineString'
|
||||
),
|
||||
[featureCollection]
|
||||
);
|
||||
return (
|
||||
<GeoJSONLayer
|
||||
data={data}
|
||||
linePaint={lineStringSelectionLine}
|
||||
lineOnMouseEnter={onMouseEnter}
|
||||
lineOnMouseLeave={onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionUtilisateurPointLayer({
|
||||
featureCollection,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'Point'
|
||||
),
|
||||
[featureCollection]
|
||||
);
|
||||
return (
|
||||
<GeoJSONLayer
|
||||
data={data}
|
||||
circlePaint={pointSelectionFill}
|
||||
circleOnMouseEnter={onMouseEnter}
|
||||
circleOnMouseLeave={onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SelectionUtilisateurPolygonLayer.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
options: PropTypes.shape({
|
||||
layers: PropTypes.array,
|
||||
ign: PropTypes.bool
|
||||
})
|
||||
onMouseEnter: PropTypes.func,
|
||||
onMouseLeave: PropTypes.func
|
||||
};
|
||||
|
||||
SelectionUtilisateurLineLayer.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
onMouseEnter: PropTypes.func,
|
||||
onMouseLeave: PropTypes.func
|
||||
};
|
||||
|
||||
SelectionUtilisateurPointLayer.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
onMouseEnter: PropTypes.func,
|
||||
onMouseLeave: PropTypes.func
|
||||
};
|
||||
|
||||
MapReader.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
options: PropTypes.shape({ layers: PropTypes.array })
|
||||
};
|
||||
|
||||
export default MapReader;
|
||||
|
|
104
app/javascript/components/MapReader/useMapbox.js
Normal file
104
app/javascript/components/MapReader/useMapbox.js
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import mapboxgl, { Popup } from 'mapbox-gl';
|
||||
|
||||
import {
|
||||
filterFeatureCollection,
|
||||
findFeature,
|
||||
getBounds,
|
||||
getCenter
|
||||
} from '../shared/mapbox/utils';
|
||||
|
||||
const SOURCE_CADASTRE = 'cadastre';
|
||||
|
||||
export function useMapbox(featureCollection) {
|
||||
const mapRef = useRef();
|
||||
const selectedCadastresRef = useRef(() => new Set());
|
||||
const isSupported = useMemo(() => mapboxgl.supported());
|
||||
|
||||
const fitBounds = useCallback((bbox) => {
|
||||
mapRef.current.fitBounds(bbox, { padding: 100 });
|
||||
}, []);
|
||||
|
||||
const onLoad = useCallback(
|
||||
(map) => {
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = map;
|
||||
mapRef.current.fitBounds(featureCollection.bbox, { padding: 100 });
|
||||
onStyleChange();
|
||||
}
|
||||
},
|
||||
[featureCollection]
|
||||
);
|
||||
|
||||
const onStyleChange = useCallback(() => {
|
||||
if (mapRef.current) {
|
||||
selectedCadastresRef.current = new Set(
|
||||
filterFeatureCollection(
|
||||
featureCollection,
|
||||
SOURCE_CADASTRE
|
||||
).features.map(({ properties }) => properties.cid)
|
||||
);
|
||||
if (selectedCadastresRef.current.size > 0) {
|
||||
mapRef.current.setFilter('parcelle-highlighted', [
|
||||
'in',
|
||||
'id',
|
||||
...selectedCadastresRef.current
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, [featureCollection]);
|
||||
|
||||
const popup = useMemo(
|
||||
() =>
|
||||
new Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false
|
||||
})
|
||||
);
|
||||
|
||||
const onMouseEnter = useCallback(
|
||||
(event) => {
|
||||
const feature = event.features[0];
|
||||
if (feature.properties && feature.properties.description) {
|
||||
const coordinates = getCenter(feature.geometry, event.lngLat);
|
||||
const description = feature.properties.description;
|
||||
mapRef.current.getCanvas().style.cursor = 'pointer';
|
||||
popup.setLngLat(coordinates).setHTML(description).addTo(mapRef.current);
|
||||
} else {
|
||||
popup.remove();
|
||||
}
|
||||
},
|
||||
[popup]
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
mapRef.current.getCanvas().style.cursor = '';
|
||||
popup.remove();
|
||||
}, [popup]);
|
||||
|
||||
useExternalEvents(featureCollection, { fitBounds });
|
||||
|
||||
return { isSupported, onLoad, onStyleChange, onMouseEnter, onMouseLeave };
|
||||
}
|
||||
|
||||
function useExternalEvents(featureCollection, { fitBounds }) {
|
||||
const onFeatureFocus = useCallback(
|
||||
({ detail }) => {
|
||||
const { id } = detail;
|
||||
const feature = findFeature(featureCollection, id);
|
||||
if (feature) {
|
||||
fitBounds(getBounds(feature.geometry));
|
||||
}
|
||||
},
|
||||
[featureCollection, fitBounds]
|
||||
);
|
||||
|
||||
useEvent('map:feature:focus', onFeatureFocus);
|
||||
}
|
||||
|
||||
export function useEvent(eventName, callback) {
|
||||
return useEffect(() => {
|
||||
addEventListener(eventName, callback);
|
||||
return () => removeEventListener(eventName, callback);
|
||||
}, [eventName, callback]);
|
||||
}
|
69
app/javascript/components/shared/mapbox/MapStyleControl.jsx
Normal file
69
app/javascript/components/shared/mapbox/MapStyleControl.jsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getMapStyle } from './styles';
|
||||
import ortho from './styles/images/preview-ortho.png';
|
||||
import vector from './styles/images/preview-vector.png';
|
||||
|
||||
const STYLES = {
|
||||
ortho: {
|
||||
title: 'Satellite',
|
||||
preview: ortho,
|
||||
color: '#fff'
|
||||
},
|
||||
vector: {
|
||||
title: 'Vectoriel',
|
||||
preview: vector,
|
||||
color: '#000'
|
||||
},
|
||||
ign: {
|
||||
title: 'Carte IGN',
|
||||
preview: vector,
|
||||
color: '#000'
|
||||
}
|
||||
};
|
||||
|
||||
function getNextStyle(style) {
|
||||
const styleNames = Object.keys(STYLES);
|
||||
const index = styleNames.indexOf(style) + 1;
|
||||
if (index === styleNames.length) {
|
||||
return styleNames[0];
|
||||
}
|
||||
return styleNames[index];
|
||||
}
|
||||
|
||||
export function useMapStyle(
|
||||
optionalLayers,
|
||||
{ onStyleChange, cadastreEnabled }
|
||||
) {
|
||||
const [styleId, setStyle] = useState('ortho');
|
||||
const style = useMemo(() => getMapStyle(styleId, optionalLayers), [
|
||||
styleId,
|
||||
optionalLayers
|
||||
]);
|
||||
|
||||
useEffect(() => onStyleChange(), [styleId, cadastreEnabled]);
|
||||
|
||||
return [style, setStyle];
|
||||
}
|
||||
|
||||
function MapStyleControl({ style, setStyle }) {
|
||||
const nextStyle = getNextStyle(style);
|
||||
const { title, preview, color } = STYLES[nextStyle];
|
||||
|
||||
return (
|
||||
<div className="map-style-control">
|
||||
<button type="button" onClick={() => setStyle(nextStyle)}>
|
||||
<img alt={title} src={preview} />
|
||||
<div style={{ color }}>{title}</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MapStyleControl.propTypes = {
|
||||
style: PropTypes.string,
|
||||
setStyle: PropTypes.func
|
||||
};
|
||||
|
||||
export default MapStyleControl;
|
|
@ -1,3 +0,0 @@
|
|||
import ReactMapboxGl from 'react-mapbox-gl';
|
||||
|
||||
export default ReactMapboxGl({});
|
|
@ -1,81 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ortho from './styles/images/preview-ortho.png';
|
||||
import vector from './styles/images/preview-vector.png';
|
||||
|
||||
const STYLES = {
|
||||
ortho: {
|
||||
title: 'Satellite',
|
||||
preview: ortho,
|
||||
color: '#fff'
|
||||
},
|
||||
vector: {
|
||||
title: 'Vectoriel',
|
||||
preview: vector,
|
||||
color: '#000'
|
||||
}
|
||||
};
|
||||
|
||||
const IGN_STYLES = {
|
||||
...STYLES,
|
||||
ign: {
|
||||
title: 'Carte IGN',
|
||||
preview: vector,
|
||||
color: '#000'
|
||||
}
|
||||
};
|
||||
|
||||
function getNextStyle(style, ign) {
|
||||
const styles = Object.keys(ign ? IGN_STYLES : STYLES);
|
||||
let index = styles.indexOf(style) + 1;
|
||||
if (index === styles.length) {
|
||||
return styles[0];
|
||||
}
|
||||
return styles[index];
|
||||
}
|
||||
|
||||
function SwitchMapStyle({ style, setStyle, ign }) {
|
||||
const nextStyle = getNextStyle(style, ign);
|
||||
const { title, preview, color } = (ign ? IGN_STYLES : STYLES)[nextStyle];
|
||||
|
||||
const imgStyle = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: 'pointer'
|
||||
};
|
||||
|
||||
const textStyle = {
|
||||
position: 'relative',
|
||||
bottom: '26px',
|
||||
left: '4px',
|
||||
color
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="style-switch"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0
|
||||
}}
|
||||
onClick={() => setStyle(nextStyle)}
|
||||
>
|
||||
<div className="switch-style mapboxgl-ctrl-swith-map-style">
|
||||
<img alt={title} style={imgStyle} src={preview} />
|
||||
<div className="text" style={textStyle}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SwitchMapStyle.propTypes = {
|
||||
style: PropTypes.string,
|
||||
setStyle: PropTypes.func,
|
||||
ign: PropTypes.bool
|
||||
};
|
||||
|
||||
export default SwitchMapStyle;
|
|
@ -1,4 +1,4 @@
|
|||
import cadastreLayers from './cadastre-layers';
|
||||
import cadastreLayers from './layers/cadastre';
|
||||
|
||||
const IGN_TOKEN = 'rc1egnbeoss72hxvd143tbyk';
|
||||
|
||||
|
@ -138,7 +138,16 @@ function rasterSource(tiles, attribution) {
|
|||
};
|
||||
}
|
||||
|
||||
export function buildLayers(ids) {
|
||||
function rasterLayer(source) {
|
||||
return {
|
||||
id: source,
|
||||
source,
|
||||
type: 'raster',
|
||||
paint: { 'raster-resampling': 'linear' }
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOptionalLayers(ids) {
|
||||
return OPTIONAL_LAYERS.filter(({ id }) => ids.includes(id))
|
||||
.flatMap(({ layers }) => layers)
|
||||
.flatMap(([, code]) =>
|
||||
|
@ -148,15 +157,6 @@ export function buildLayers(ids) {
|
|||
);
|
||||
}
|
||||
|
||||
export function rasterLayer(source) {
|
||||
return {
|
||||
id: source,
|
||||
source,
|
||||
type: 'raster',
|
||||
paint: { 'raster-resampling': 'linear' }
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
version: 8,
|
||||
metadat: {
|
||||
|
|
|
@ -1,29 +1,27 @@
|
|||
import baseStyle, { rasterLayer, buildLayers } from './base';
|
||||
import orthoStyle from './ortho-style';
|
||||
import vectorStyle from './vector-style';
|
||||
import baseStyle, { buildOptionalLayers } from './base';
|
||||
import orthoStyle from './layers/ortho';
|
||||
import vectorStyle from './layers/vector';
|
||||
import ignLayers from './layers/ign';
|
||||
|
||||
export function getMapStyle(style, optionalLayers) {
|
||||
const mapStyle = { ...baseStyle };
|
||||
export function getMapStyle(id, optionalLayers) {
|
||||
const style = { ...baseStyle, id };
|
||||
|
||||
switch (style) {
|
||||
switch (id) {
|
||||
case 'ortho':
|
||||
mapStyle.layers = orthoStyle;
|
||||
mapStyle.id = 'ortho';
|
||||
mapStyle.name = 'Photographies aériennes';
|
||||
style.layers = orthoStyle;
|
||||
style.name = 'Photographies aériennes';
|
||||
break;
|
||||
case 'vector':
|
||||
mapStyle.layers = vectorStyle;
|
||||
mapStyle.id = 'vector';
|
||||
mapStyle.name = 'Carte OSM';
|
||||
style.layers = vectorStyle;
|
||||
style.name = 'Carte OSM';
|
||||
break;
|
||||
case 'ign':
|
||||
mapStyle.layers = [rasterLayer('plan-ign')];
|
||||
mapStyle.id = 'ign';
|
||||
mapStyle.name = 'Carte IGN';
|
||||
style.layers = ignLayers;
|
||||
style.name = 'Carte IGN';
|
||||
break;
|
||||
}
|
||||
|
||||
mapStyle.layers = mapStyle.layers.concat(buildLayers(optionalLayers));
|
||||
style.layers = style.layers.concat(buildOptionalLayers(optionalLayers));
|
||||
|
||||
return mapStyle;
|
||||
return style;
|
||||
}
|
||||
|
|
|
@ -82,7 +82,7 @@ export default [
|
|||
type: 'fill',
|
||||
source: 'cadastre',
|
||||
'source-layer': 'parcelles',
|
||||
filter: ['==', 'id', ''],
|
||||
filter: ['in', 'id', ''],
|
||||
paint: {
|
||||
'fill-color': 'rgba(1, 129, 0, 1)',
|
||||
'fill-opacity': 0.7
|
|
@ -0,0 +1,8 @@
|
|||
export default [
|
||||
{
|
||||
id: 'ign',
|
||||
source: 'plan-ign',
|
||||
type: 'raster',
|
||||
paint: { 'raster-resampling': 'linear' }
|
||||
}
|
||||
];
|
|
@ -1,5 +1,4 @@
|
|||
import { LngLatBounds } from 'mapbox-gl';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function getBounds(geometry) {
|
||||
const bbox = new LngLatBounds();
|
||||
|
@ -18,15 +17,9 @@ export function getBounds(geometry) {
|
|||
return bbox;
|
||||
}
|
||||
|
||||
export function fitBounds(map, feature) {
|
||||
if (map) {
|
||||
map.fitBounds(getBounds(feature.geometry), { padding: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
export function findFeature(featureCollection, id) {
|
||||
export function findFeature(featureCollection, value, property = 'id') {
|
||||
return featureCollection.features.find(
|
||||
(feature) => feature.properties.id === id
|
||||
(feature) => feature.properties[property] === value
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -48,19 +41,10 @@ export function filterFeatureCollectionByGeometryType(featureCollection, type) {
|
|||
};
|
||||
}
|
||||
|
||||
export function noop() {}
|
||||
|
||||
export function generateId() {
|
||||
return Math.random().toString(20).substr(2, 6);
|
||||
}
|
||||
|
||||
export function useEvent(eventName, callback) {
|
||||
return useEffect(() => {
|
||||
addEventListener(eventName, callback);
|
||||
return () => removeEventListener(eventName, callback);
|
||||
}, [eventName, callback]);
|
||||
}
|
||||
|
||||
export function getCenter(geometry, lngLat) {
|
||||
const bbox = new LngLatBounds();
|
||||
|
||||
|
@ -76,3 +60,13 @@ export function getCenter(geometry, lngLat) {
|
|||
return bbox.getCenter();
|
||||
}
|
||||
}
|
||||
|
||||
export function defer() {
|
||||
const deferred = {};
|
||||
const promise = new Promise(function (resolve, reject) {
|
||||
deferred.resolve = resolve;
|
||||
deferred.reject = reject;
|
||||
});
|
||||
deferred.promise = promise;
|
||||
return deferred;
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import '../new_design/procedure-context';
|
|||
import '../new_design/procedure-form';
|
||||
import '../new_design/spinner';
|
||||
import '../new_design/support';
|
||||
import '../new_design/messagerie';
|
||||
import '../new_design/dossiers/auto-save';
|
||||
import '../new_design/dossiers/auto-upload';
|
||||
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
class APICarto::API
|
||||
class ResourceNotFound < StandardError
|
||||
end
|
||||
|
||||
def self.search_cadastre(geojson)
|
||||
url = [API_CARTO_URL, "cadastre", "geometrie"].join("/")
|
||||
call(url, geojson)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.call(url, geojson)
|
||||
response = Typhoeus.post(url, body: geojson.to_s, headers: { 'content-type' => 'application/json' })
|
||||
|
||||
if response.success?
|
||||
response.body
|
||||
else
|
||||
message = response.code == 0 ? response.return_message : response.code.to_s
|
||||
Rails.logger.error "[APICarto] Error on #{url}: #{message}"
|
||||
raise ResourceNotFound
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,29 +0,0 @@
|
|||
class APICarto::CadastreAdapter
|
||||
def initialize(coordinates)
|
||||
@coordinates = GeojsonService.to_json_polygon_for_cadastre(coordinates)
|
||||
end
|
||||
|
||||
def data_source
|
||||
@data_source ||= JSON.parse(APICarto::API.search_cadastre(@coordinates), symbolize_names: true)
|
||||
end
|
||||
|
||||
def results
|
||||
data_source[:features].map do |feature|
|
||||
filter_properties(feature[:properties]).merge({ geometry: feature[:geometry] })
|
||||
end
|
||||
end
|
||||
|
||||
def filter_properties(properties)
|
||||
properties.slice(
|
||||
:surface_intersection,
|
||||
:surface_parcelle,
|
||||
:numero,
|
||||
:feuille,
|
||||
:section,
|
||||
:code_dep,
|
||||
:nom_com,
|
||||
:code_com,
|
||||
:code_arr
|
||||
)
|
||||
end
|
||||
end
|
|
@ -62,10 +62,7 @@ class Champs::CarteChamp < Champ
|
|||
end
|
||||
|
||||
def render_options
|
||||
{
|
||||
ign: Flipper.enabled?(:carte_ign, procedure),
|
||||
layers: optional_layers
|
||||
}
|
||||
{ layers: optional_layers }
|
||||
end
|
||||
|
||||
def position
|
||||
|
|
|
@ -196,12 +196,14 @@ module TagsSubstitutionConcern
|
|||
tags.filter { |tag| tag[:available_for_states].include?(self.class::DOSSIER_STATE) }
|
||||
end
|
||||
|
||||
def champ_public_tags
|
||||
types_de_champ_tags(procedure.types_de_champ, Dossier::SOUMIS)
|
||||
def champ_public_tags(dossier: nil)
|
||||
types_de_champ = (dossier || procedure.active_revision).types_de_champ
|
||||
types_de_champ_tags(types_de_champ, Dossier::SOUMIS)
|
||||
end
|
||||
|
||||
def champ_private_tags
|
||||
types_de_champ_tags(procedure.types_de_champ_private, Dossier::INSTRUCTION_COMMENCEE)
|
||||
def champ_private_tags(dossier: nil)
|
||||
types_de_champ = (dossier || procedure.active_revision).types_de_champ_private
|
||||
types_de_champ_tags(types_de_champ, Dossier::INSTRUCTION_COMMENCEE)
|
||||
end
|
||||
|
||||
def types_de_champ_tags(types_de_champ, available_for_states)
|
||||
|
@ -217,9 +219,11 @@ module TagsSubstitutionConcern
|
|||
return ''
|
||||
end
|
||||
|
||||
text = normalize_tags(text)
|
||||
|
||||
tags_and_datas = [
|
||||
[champ_public_tags, dossier.champs],
|
||||
[champ_private_tags, dossier.champs_private],
|
||||
[champ_public_tags(dossier: dossier), dossier.champs],
|
||||
[champ_private_tags(dossier: dossier), dossier.champs_private],
|
||||
[dossier_tags, dossier],
|
||||
[ROUTAGE_TAGS, dossier],
|
||||
[INDIVIDUAL_TAGS, dossier.individual],
|
||||
|
@ -242,7 +246,7 @@ module TagsSubstitutionConcern
|
|||
end
|
||||
|
||||
def replace_tag(text, tag, data)
|
||||
libelle = Regexp.quote(tag[:libelle])
|
||||
libelle = Regexp.quote(tag[:id].presence || tag[:libelle])
|
||||
|
||||
# allow any kind of space (non-breaking or other) in the tag’s libellé to match any kind of space in the template
|
||||
# (the '\\ |' is there because plain ASCII spaces were escaped by preceding Regexp.quote)
|
||||
|
@ -256,4 +260,19 @@ module TagsSubstitutionConcern
|
|||
|
||||
text.gsub(/--#{libelle}--/, value.to_s)
|
||||
end
|
||||
|
||||
def normalize_tags(text)
|
||||
tags = types_de_champ_tags(procedure.types_de_champ_for_tags, Dossier::SOUMIS) + types_de_champ_tags(procedure.types_de_champ_private_for_tags, Dossier::INSTRUCTION_COMMENCEE)
|
||||
filter_tags(tags).reduce(text) { |text, tag| normalize_tag(text, tag) }
|
||||
end
|
||||
|
||||
def normalize_tag(text, tag)
|
||||
libelle = Regexp.quote(tag[:libelle])
|
||||
|
||||
# allow any kind of space (non-breaking or other) in the tag’s libellé to match any kind of space in the template
|
||||
# (the '\\ |' is there because plain ASCII spaces were escaped by preceding Regexp.quote)
|
||||
libelle.gsub!(/\\ |[[:blank:]]/, "[[:blank:]]")
|
||||
|
||||
text.gsub(/--#{libelle}--/, "--#{tag[:id]}--")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -63,7 +63,6 @@ class Dossier < ApplicationRecord
|
|||
has_one :etablissement, dependent: :destroy
|
||||
has_one :individual, validate: false, dependent: :destroy
|
||||
has_one :attestation, dependent: :destroy
|
||||
has_one :france_connect_information, through: :user
|
||||
|
||||
# FIXME: some dossiers have more than one attestation
|
||||
has_many :attestations, dependent: :destroy
|
||||
|
@ -88,6 +87,7 @@ class Dossier < ApplicationRecord
|
|||
belongs_to :groupe_instructeur, optional: true
|
||||
belongs_to :revision, class_name: 'ProcedureRevision', optional: false
|
||||
belongs_to :user, optional: true
|
||||
has_one :france_connect_information, through: :user
|
||||
|
||||
has_one :procedure, through: :revision
|
||||
has_many :types_de_champ, through: :revision
|
||||
|
@ -340,7 +340,7 @@ class Dossier < ApplicationRecord
|
|||
accepts_nested_attributes_for :individual
|
||||
|
||||
delegate :siret, :siren, to: :etablissement, allow_nil: true
|
||||
delegate :france_connect_information, to: :user
|
||||
delegate :france_connect_information, to: :user, allow_nil: true
|
||||
|
||||
before_save :build_default_champs, if: Proc.new { revision_id_was.nil? }
|
||||
before_save :update_search_terms
|
||||
|
@ -535,15 +535,11 @@ class Dossier < ApplicationRecord
|
|||
end
|
||||
|
||||
def avis_for_expert(expert)
|
||||
if expert.dossiers.include?(self)
|
||||
avis.order(created_at: :asc)
|
||||
else
|
||||
avis
|
||||
.where(confidentiel: false)
|
||||
.or(avis.where(claimant: expert))
|
||||
Avis
|
||||
.where(dossier_id: id, confidentiel: false)
|
||||
.or(Avis.where(id: expert.avis, dossier_id: id))
|
||||
.order(created_at: :asc)
|
||||
end
|
||||
end
|
||||
|
||||
def owner_name
|
||||
if etablissement.present?
|
||||
|
|
|
@ -14,25 +14,33 @@
|
|||
class GeoArea < ApplicationRecord
|
||||
belongs_to :champ, optional: false
|
||||
|
||||
store :properties, accessors: [
|
||||
:description,
|
||||
:surface_intersection,
|
||||
:surface_parcelle,
|
||||
:numero,
|
||||
:feuille,
|
||||
:section,
|
||||
:code_dep,
|
||||
:nom_com,
|
||||
:code_com,
|
||||
:code_arr,
|
||||
:code,
|
||||
:nom,
|
||||
:commune,
|
||||
:culture,
|
||||
:code_culture,
|
||||
:surface,
|
||||
:bio
|
||||
]
|
||||
# FIXME: once geo_areas are migrated to not use YAML serialization we can enable store_accessor
|
||||
# store_accessor :properties, :description, :numero, :section
|
||||
|
||||
def properties
|
||||
value = read_attribute(:properties)
|
||||
if value.is_a? String
|
||||
ActiveRecord::Coders::YAMLColumn.new(:properties).load(value)
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def description
|
||||
properties['description']
|
||||
end
|
||||
|
||||
def numero
|
||||
properties['numero']
|
||||
end
|
||||
|
||||
def section
|
||||
properties['section']
|
||||
end
|
||||
|
||||
def filename
|
||||
properties['filename']
|
||||
end
|
||||
|
||||
enum source: {
|
||||
cadastre: 'cadastre',
|
||||
|
@ -48,10 +56,12 @@ class GeoArea < ApplicationRecord
|
|||
{
|
||||
type: 'Feature',
|
||||
geometry: safe_geometry,
|
||||
properties: properties.symbolize_keys.merge(
|
||||
properties: cadastre_properties.merge(
|
||||
source: source,
|
||||
area: area,
|
||||
length: length,
|
||||
description: description,
|
||||
filename: filename,
|
||||
id: id,
|
||||
champ_id: champ.stable_id,
|
||||
dossier_id: champ.dossier_id
|
||||
|
@ -69,16 +79,6 @@ class GeoArea < ApplicationRecord
|
|||
nil
|
||||
end
|
||||
|
||||
def self.from_feature_collection(feature_collection)
|
||||
feature_collection[:features].map do |feature|
|
||||
GeoArea.new(
|
||||
source: feature[:properties].delete(:source),
|
||||
properties: feature[:properties],
|
||||
geometry: feature[:geometry]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def area
|
||||
if polygon?
|
||||
GeojsonService.area(geometry.deep_symbolize_keys).round(1)
|
||||
|
@ -108,4 +108,107 @@ class GeoArea < ApplicationRecord
|
|||
def point?
|
||||
geometry['type'] == 'Point'
|
||||
end
|
||||
|
||||
def legacy_cadastre?
|
||||
cadastre? && properties['surface_intersection'].present?
|
||||
end
|
||||
|
||||
def cadastre?
|
||||
source == GeoArea.sources.fetch(:cadastre)
|
||||
end
|
||||
|
||||
def cadastre_properties
|
||||
if cadastre?
|
||||
{
|
||||
cid: cid,
|
||||
numero: numero,
|
||||
section: section,
|
||||
prefixe: prefixe,
|
||||
commune: commune,
|
||||
surface: surface
|
||||
}
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def code_dep
|
||||
if legacy_cadastre?
|
||||
properties['code_dep']
|
||||
else
|
||||
properties['commune'][0..1]
|
||||
end
|
||||
end
|
||||
|
||||
def code_com
|
||||
if legacy_cadastre?
|
||||
properties['code_com']
|
||||
else
|
||||
properties['commune'][2...commune.size]
|
||||
end
|
||||
end
|
||||
|
||||
def nom_com
|
||||
if legacy_cadastre?
|
||||
properties['nom_com']
|
||||
else
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
def surface_intersection
|
||||
if legacy_cadastre?
|
||||
properties['surface_intersection']
|
||||
else
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
def feuille
|
||||
if legacy_cadastre?
|
||||
properties['feuille']
|
||||
else
|
||||
1
|
||||
end
|
||||
end
|
||||
|
||||
def code_arr
|
||||
prefixe
|
||||
end
|
||||
|
||||
def surface_parcelle
|
||||
surface
|
||||
end
|
||||
|
||||
def surface
|
||||
if legacy_cadastre?
|
||||
properties['surface_parcelle']
|
||||
else
|
||||
properties['contenance']
|
||||
end
|
||||
end
|
||||
|
||||
def prefixe
|
||||
if legacy_cadastre?
|
||||
properties['code_arr']
|
||||
else
|
||||
properties['prefixe']
|
||||
end
|
||||
end
|
||||
|
||||
def commune
|
||||
if legacy_cadastre?
|
||||
"#{properties['code_dep']}#{properties['code_com']}"
|
||||
else
|
||||
properties['commune']
|
||||
end
|
||||
end
|
||||
|
||||
def cid
|
||||
if legacy_cadastre?
|
||||
"#{code_dep}#{code_com}#{code_arr}#{section}#{numero}"
|
||||
else
|
||||
properties['id']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -87,6 +87,30 @@ class Procedure < ApplicationRecord
|
|||
brouillon? ? draft_types_de_champ_private : published_types_de_champ_private
|
||||
end
|
||||
|
||||
def types_de_champ_for_tags
|
||||
if brouillon?
|
||||
draft_types_de_champ
|
||||
else
|
||||
TypeDeChamp.root
|
||||
.public_only
|
||||
.where(revision: revisions - [draft_revision])
|
||||
.order(:created_at)
|
||||
.uniq
|
||||
end
|
||||
end
|
||||
|
||||
def types_de_champ_private_for_tags
|
||||
if brouillon?
|
||||
draft_types_de_champ_private
|
||||
else
|
||||
TypeDeChamp.root
|
||||
.private_only
|
||||
.where(revision: revisions - [draft_revision])
|
||||
.order(:created_at)
|
||||
.uniq
|
||||
end
|
||||
end
|
||||
|
||||
def types_de_champ_for_export
|
||||
types_de_champ.reject(&:exclude_from_export?)
|
||||
end
|
||||
|
|
|
@ -6,26 +6,24 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas
|
|||
|
||||
def tags_for_template
|
||||
tags = super
|
||||
tdc = @type_de_champ
|
||||
stable_id = @type_de_champ.stable_id
|
||||
tags.push(
|
||||
{
|
||||
libelle: "#{libelle}/primaire",
|
||||
id: "tdc#{stable_id}/primaire",
|
||||
description: "#{description} (menu primaire)",
|
||||
lambda: -> (champs) {
|
||||
champs
|
||||
.find { |champ| champ.type_de_champ == tdc }
|
||||
&.primary_value
|
||||
champs.find { |champ| champ.stable_id == stable_id }&.primary_value
|
||||
}
|
||||
}
|
||||
)
|
||||
tags.push(
|
||||
{
|
||||
libelle: "#{libelle}/secondaire",
|
||||
id: "tdc#{stable_id}/secondaire",
|
||||
description: "#{description} (menu secondaire)",
|
||||
lambda: -> (champs) {
|
||||
champs
|
||||
.find { |champ| champ.type_de_champ == tdc }
|
||||
&.secondary_value
|
||||
champs.find { |champ| champ.stable_id == stable_id }&.secondary_value
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -8,13 +8,14 @@ class TypesDeChamp::TypeDeChampBase
|
|||
end
|
||||
|
||||
def tags_for_template
|
||||
tdc = @type_de_champ
|
||||
stable_id = @type_de_champ.stable_id
|
||||
[
|
||||
{
|
||||
libelle: libelle,
|
||||
id: "tdc#{stable_id}",
|
||||
description: description,
|
||||
lambda: -> (champs) {
|
||||
champs.find { |champ| champ.type_de_champ == tdc }&.for_tag
|
||||
champs.find { |champ| champ.stable_id == stable_id }&.for_tag
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
class APICartoService
|
||||
def self.generate_qp(coordinates)
|
||||
coordinates.flat_map do |coordinate|
|
||||
APICarto::QuartiersPrioritairesAdapter.new(
|
||||
coordinate.map { |element| [element['lng'], element['lat']] }
|
||||
).results
|
||||
end
|
||||
end
|
||||
|
||||
def self.generate_cadastre(coordinates)
|
||||
coordinates.flat_map do |coordinate|
|
||||
APICarto::CadastreAdapter.new(
|
||||
coordinate.map { |element| [element['lng'], element['lat']] }
|
||||
).results
|
||||
end
|
||||
end
|
||||
|
||||
def self.generate_rpg(coordinates)
|
||||
coordinates.flat_map do |coordinate|
|
||||
ApiGeo::RPGAdapter.new(
|
||||
coordinate.map { |element| [element['lng'], element['lat']] }
|
||||
).results
|
||||
end
|
||||
end
|
||||
end
|
|
@ -33,7 +33,7 @@ class DossierProjectionService
|
|||
types_de_champ: { stable_id: fields.map { |f| f[COLUMN] } },
|
||||
dossier_id: dossiers_ids
|
||||
)
|
||||
.select(:dossier_id, :value, :type_de_champ_id, :stable_id) # we cannot pluck :value, as we need the champ.to_s method
|
||||
.select(:dossier_id, :value, :type_de_champ_id, :stable_id, :type, :data) # we cannot pluck :value, as we need the champ.to_s method
|
||||
.group_by(&:stable_id) # the champs are redispatched to their respective fields
|
||||
.map do |stable_id, champs|
|
||||
field = fields.find { |f| f[COLUMN] == stable_id.to_s }
|
||||
|
@ -74,6 +74,12 @@ class DossierProjectionService
|
|||
.where(id: dossiers_ids)
|
||||
.pluck('dossiers.id, groupe_instructeurs.label')
|
||||
.to_h
|
||||
when 'procedure'
|
||||
Dossier
|
||||
.joins(:procedure)
|
||||
.where(id: dossiers_ids)
|
||||
.pluck(:id, *fields.map { |f| f[COLUMN].to_sym })
|
||||
.each { |id, *columns| fields.zip(columns).each { |field, value| field[:id_value_h][id] = value } }
|
||||
when 'followers_instructeurs'
|
||||
fields[0][:id_value_h] = Follow
|
||||
.active
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
class DossierSearchService
|
||||
def self.matching_dossiers_for_instructeur(search_terms, instructeur)
|
||||
dossier_by_exact_id_for_instructeur(search_terms, instructeur)
|
||||
.presence || dossier_by_full_text_for_instructeur(search_terms, instructeur)
|
||||
def self.matching_dossiers(dossiers, search_terms, with_annotations = false)
|
||||
if dossiers.nil?
|
||||
[]
|
||||
else
|
||||
dossier_by_exact_id(dossiers, search_terms)
|
||||
.presence || dossier_by_full_text(dossiers, search_terms, with_annotations)
|
||||
end
|
||||
end
|
||||
|
||||
def self.matching_dossiers_for_user(search_terms, user)
|
||||
|
@ -11,24 +15,24 @@ class DossierSearchService
|
|||
|
||||
private
|
||||
|
||||
def self.dossier_by_exact_id_for_instructeur(search_terms, instructeur)
|
||||
def self.dossier_by_exact_id(dossiers, search_terms)
|
||||
id = search_terms.to_i
|
||||
if id != 0 && id_compatible?(id) # Sometimes instructeur is searching dossiers with a big number (ex: SIRET), ActiveRecord can't deal with them and throws ActiveModel::RangeError. id_compatible? prevents this.
|
||||
dossiers_by_id(id, instructeur)
|
||||
dossiers.where(id: id).ids
|
||||
else
|
||||
Dossier.none
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def self.dossiers_by_id(id, instructeur)
|
||||
instructeur.dossiers.where(id: id).uniq
|
||||
end
|
||||
def self.dossier_by_full_text(dossiers, search_terms, with_annotations)
|
||||
ts_vector = "to_tsvector('french', #{with_annotations ? 'dossiers.search_terms || dossiers.private_search_terms' : 'dossiers.search_terms'})"
|
||||
ts_query = "to_tsquery('french', #{Dossier.connection.quote(to_tsquery(search_terms))})"
|
||||
|
||||
def self.id_compatible?(number)
|
||||
ActiveRecord::Type::Integer.new.serialize(number)
|
||||
true
|
||||
rescue ActiveModel::RangeError
|
||||
false
|
||||
dossiers
|
||||
.where("#{ts_vector} @@ #{ts_query}")
|
||||
.order(Arel.sql("COALESCE(ts_rank(#{ts_vector}, #{ts_query}), 0) DESC"))
|
||||
.pluck('id')
|
||||
.uniq
|
||||
end
|
||||
|
||||
def self.dossier_by_full_text_for_user(search_terms, dossiers)
|
||||
|
@ -49,15 +53,11 @@ class DossierSearchService
|
|||
end
|
||||
end
|
||||
|
||||
def self.dossier_by_full_text_for_instructeur(search_terms, instructeur)
|
||||
ts_vector = "to_tsvector('french', search_terms || private_search_terms)"
|
||||
ts_query = "to_tsquery('french', #{Dossier.connection.quote(to_tsquery(search_terms))})"
|
||||
|
||||
instructeur
|
||||
.dossiers
|
||||
.state_not_brouillon
|
||||
.where("#{ts_vector} @@ #{ts_query}")
|
||||
.order(Arel.sql("COALESCE(ts_rank(#{ts_vector}, #{ts_query}), 0) DESC"))
|
||||
def self.id_compatible?(number)
|
||||
ActiveRecord::Type::Integer.new.serialize(number)
|
||||
true
|
||||
rescue ActiveModel::RangeError
|
||||
false
|
||||
end
|
||||
|
||||
def self.to_tsquery(search_terms)
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
partial: 'shared/champs/carte/geo_areas',
|
||||
locals: { champ: @champ, editing: true }) %>
|
||||
|
||||
<% if @update_cadastres %>
|
||||
<%= fire_event('cadastres:update', { featureCollection: @champ.to_feature_collection }.to_json) %>
|
||||
<% if @focus %>
|
||||
<%= fire_event('map:feature:focus', { bbox: @champ.bounding_box }.to_json) %>
|
||||
<% end %>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
%h2 Identité du demandeur
|
||||
|
||||
= render partial: "shared/dossiers/user_infos", locals: { user: @dossier.user }
|
||||
= render partial: "shared/dossiers/user_infos", locals: { user_deleted: @dossier.user_deleted?, email: @dossier.user_email_for(:display) }
|
||||
|
||||
- if @dossier.etablissement.present?
|
||||
= render partial: "shared/dossiers/identite_entreprise", locals: { etablissement: @dossier.etablissement, profile: 'instructeur' }
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
- content_for(:title, "Recherche : #{@search_terms}")
|
||||
|
||||
.container
|
||||
.page-title
|
||||
Résultat de la recherche :
|
||||
= t('pluralize.dossier_trouve', count: @dossiers.count)
|
||||
|
||||
- if @dossiers.present?
|
||||
%table.table.dossiers-table.hoverable
|
||||
%thead
|
||||
%tr
|
||||
%th.notification-col
|
||||
%th.number-col Nº dossier
|
||||
%th Démarche
|
||||
%th Demandeur
|
||||
%th.status-col Statut
|
||||
%th.action-col.follow-col
|
||||
%tbody
|
||||
- @dossiers.each do |dossier|
|
||||
/ # FIXME: here we have a n+1, we fire a request
|
||||
/ (due to dossier_linked_path) per result
|
||||
%tr
|
||||
%td.folder-col
|
||||
= link_to(dossier_linked_path(current_instructeur, dossier), class: 'cell-link') do
|
||||
%span.icon.folder
|
||||
%td.number-col
|
||||
= link_to(dossier_linked_path(current_instructeur, dossier), class: 'cell-link') do
|
||||
= dossier.id
|
||||
%td= link_to(dossier.procedure.libelle, dossier_linked_path(current_instructeur, dossier), class: 'cell-link')
|
||||
%td= link_to(dossier.user_email_for(:display), dossier_linked_path(current_instructeur, dossier), class: 'cell-link')
|
||||
%td.status-col
|
||||
= link_to(dossier_linked_path(current_instructeur, dossier), class: 'cell-link') do
|
||||
= status_badge(dossier.state)
|
||||
%td.action-col.follow-col= render partial: "instructeurs/procedures/dossier_actions",
|
||||
locals: { procedure_id: dossier.procedure.id,
|
||||
dossier_id: dossier.id,
|
||||
state: dossier.state,
|
||||
archived: dossier.archived,
|
||||
dossier_is_followed: @followed_dossiers_id.include?(dossier.id) }
|
||||
|
||||
- else
|
||||
%h2 Aucun dossier correspondant à votre recherche n'a été trouvé
|
|
@ -50,9 +50,15 @@
|
|||
= active_link_to "Dossiers", dossiers_path, active: :inclusive, class: 'tab-link'
|
||||
|
||||
%ul.header-right-content
|
||||
- if params[:controller] == 'recherche'
|
||||
= render partial: 'layouts/search_dossiers_form', locals: { search_endpoint: recherche_index_path }
|
||||
|
||||
- if nav_bar_profile == :instructeur && instructeur_signed_in?
|
||||
%li
|
||||
= render partial: 'layouts/search_dossiers_form', locals: { search_endpoint: instructeur_recherche_path }
|
||||
= render partial: 'layouts/search_dossiers_form', locals: { search_endpoint: recherche_index_path }
|
||||
|
||||
- if nav_bar_profile == :expert && expert_signed_in?
|
||||
= render partial: 'layouts/search_dossiers_form', locals: { search_endpoint: recherche_index_path }
|
||||
|
||||
- if nav_bar_profile == :user && user_signed_in? && current_user.dossiers.count > 2
|
||||
%li
|
||||
|
|
92
app/views/recherche/index.html.haml
Normal file
92
app/views/recherche/index.html.haml
Normal file
|
@ -0,0 +1,92 @@
|
|||
- content_for(:title, "Recherche : #{@search_terms}")
|
||||
- pagination = paginate @paginated_ids
|
||||
|
||||
.container
|
||||
.page-title
|
||||
Résultat de la recherche :
|
||||
= t('pluralize.dossier_trouve', count: @dossiers_count)
|
||||
|
||||
= pagination
|
||||
|
||||
- if @projected_dossiers.present?
|
||||
%table.table.dossiers-table.hoverable
|
||||
%thead
|
||||
%tr
|
||||
%th.notification-col
|
||||
%th.number-col Nº dossier
|
||||
%th Démarche
|
||||
%th Demandeur
|
||||
%th.status-col Statut
|
||||
%th.action-col.follow-col
|
||||
%tbody
|
||||
- @projected_dossiers.each do |p|
|
||||
- procedure_libelle, user_email, procedure_id = p.columns
|
||||
- instructeur_dossier = @instructeur_dossiers_ids.include?(p.dossier_id)
|
||||
- expert_dossier = @dossier_avis_ids_h[p.dossier_id].present?
|
||||
- instructeur_and_expert_dossier = instructeur_dossier && expert_dossier
|
||||
- path = instructeur_dossier ? instructeur_dossier_path(procedure_id, p.dossier_id) : expert_avis_path(procedure_id, @dossier_avis_ids_h[p.dossier_id])
|
||||
|
||||
%tr
|
||||
- if instructeur_and_expert_dossier
|
||||
%td.folder-col.cell-link
|
||||
%span.icon.folder
|
||||
%td.number-col
|
||||
.cell-link= p.dossier_id
|
||||
%td
|
||||
.cell-link= procedure_libelle
|
||||
%td
|
||||
.cell-link= user_email
|
||||
%td.status-col
|
||||
.cell-link= status_badge(p.state)
|
||||
|
||||
- else
|
||||
%td.folder-col
|
||||
%a.cell-link{ href: path }
|
||||
%span.icon.folder
|
||||
|
||||
%td.number-col
|
||||
%a.cell-link{ href: path }= p.dossier_id
|
||||
|
||||
%td
|
||||
%a.cell-link{ href: path }= procedure_libelle
|
||||
|
||||
%td
|
||||
%a.cell-link{ href: path }= user_email
|
||||
|
||||
%td.status-col
|
||||
%a.cell-link{ href: path }= status_badge(p.state)
|
||||
|
||||
|
||||
- if instructeur_dossier && expert_dossier
|
||||
%td.action-col.follow-col
|
||||
.dropdown
|
||||
.button.dropdown-button
|
||||
Actions
|
||||
.dropdown-content.fade-in-down
|
||||
%ul.dropdown-items
|
||||
%li
|
||||
= link_to(instructeur_dossier_path(procedure_id, p.dossier_id)) do
|
||||
%span.icon.in-progress>
|
||||
.dropdown-description
|
||||
Voir le dossier
|
||||
%li
|
||||
= link_to(expert_avis_path(procedure_id, @dossier_avis_ids_h[p.dossier_id])) do
|
||||
%span.icon.in-progress>
|
||||
.dropdown-description
|
||||
Donner mon avis
|
||||
|
||||
- elsif instructeur_dossier
|
||||
%td.action-col.follow-col= render partial: "instructeurs/procedures/dossier_actions",
|
||||
locals: { procedure_id: procedure_id,
|
||||
dossier_id: p.dossier_id,
|
||||
state: p.state,
|
||||
archived: p.archived,
|
||||
dossier_is_followed: @followed_dossiers_id.include?(p.dossier_id) }
|
||||
|
||||
- else
|
||||
%td
|
||||
|
||||
= pagination
|
||||
|
||||
- else
|
||||
%h2 Aucun dossier correspondant à votre recherche n'a été trouvé
|
|
@ -7,7 +7,7 @@
|
|||
- if editing
|
||||
= link_to '#', data: { geo_area: geo_area.id } do
|
||||
= geo_area_label(geo_area)
|
||||
= text_field_tag :description, geo_area.description, data: { geo_area: geo_area.id }, placeholder: 'Description de la sélection'
|
||||
= text_field_tag :description, geo_area.description, data: { geo_area: geo_area.id }, placeholder: 'Description de la sélection', class: 'no-margin'
|
||||
- else
|
||||
= link_to '#', data: { geo_area: geo_area.id } do
|
||||
= geo_area_label(geo_area)
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
.card
|
||||
- if dossier.france_connect_information.present?
|
||||
= render partial: "shared/dossiers/france_connect_informations", locals: { user_information: dossier.france_connect_information }
|
||||
= render partial: "shared/dossiers/user_infos", locals: { user: dossier.user }
|
||||
= render partial: "shared/dossiers/user_infos", locals: { user_deleted: dossier.user_deleted?, email: dossier.user_email_for(:display) }
|
||||
|
||||
- if dossier.etablissement.present?
|
||||
= render partial: "shared/dossiers/identite_entreprise", locals: { etablissement: dossier.etablissement, profile: profile }
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
%tbody
|
||||
%tr
|
||||
%th.libelle Email :
|
||||
%td= user.email
|
||||
%td= user_deleted ? "#{email} (l‘usager a supprimé son compte)" : email
|
||||
|
|
|
@ -38,12 +38,7 @@ module TPS
|
|||
config.assets.paths << Rails.root.join('app', 'assets', 'fonts')
|
||||
config.assets.precompile += ['.woff']
|
||||
|
||||
# The default list used to be accessible through `ActionView::Base.sanitized_allowed_tags`,
|
||||
# but a regression in Rails 6.0 makes it unavailable.
|
||||
# It should be fixed in Rails 6.1.
|
||||
# See https://github.com/rails/rails/issues/39586
|
||||
# default_allowed_tags = ActionView::Base.sanitized_allowed_tags
|
||||
default_allowed_tags = ['strong', 'em', 'b', 'i', 'p', 'code', 'pre', 'tt', 'samp', 'kbd', 'var', 'sub', 'sup', 'dfn', 'cite', 'big', 'small', 'address', 'hr', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'abbr', 'acronym', 'a', 'img', 'blockquote', 'del', 'ins']
|
||||
default_allowed_tags = ActionView::Base.sanitized_allowed_tags
|
||||
config.action_view.sanitized_allowed_tags = default_allowed_tags + ['u']
|
||||
|
||||
# Some mobile browsers have a behaviour where, although they will delete the session
|
||||
|
|
|
@ -27,7 +27,6 @@ end
|
|||
features = [
|
||||
:administrateur_routage,
|
||||
:administrateur_web_hook,
|
||||
:carte_ign,
|
||||
:dossier_pdf_vide,
|
||||
:expert_not_allowed_to_invite,
|
||||
:hide_instructeur_email,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
# rubocop:disable DS/ApplicationName
|
||||
# API URLs
|
||||
API_CARTO_URL = ENV.fetch("API_CARTO_URL", "https://sandbox.geo.api.gouv.fr/apicarto")
|
||||
API_ENTREPRISE_URL = ENV.fetch("API_ENTREPRISE_URL", "https://entreprise.api.gouv.fr/v2")
|
||||
API_EDUCATION_URL = ENV.fetch("API_EDUCATION_URL", "https://data.education.gouv.fr/api/records/1.0")
|
||||
HELPSCOUT_API_URL = ENV.fetch("HELPSCOUT_API_URL", "https://api.helpscout.net/v2")
|
||||
|
|
|
@ -147,6 +147,7 @@ Rails.application.routes.draw do
|
|||
end
|
||||
|
||||
resources :attachments, only: [:show, :destroy]
|
||||
resources :recherche, only: [:index]
|
||||
|
||||
get "patron" => "root#patron"
|
||||
get "suivi" => "root#suivi"
|
||||
|
@ -295,7 +296,6 @@ Rails.application.routes.draw do
|
|||
#
|
||||
scope module: 'experts', as: 'expert' do
|
||||
get 'avis', to: 'avis#index', as: 'all_avis'
|
||||
|
||||
# this redirections are ephemeral, to ensure that emails sent to experts before are still valid
|
||||
# TODO : they will be removed in September, 2020
|
||||
get 'avis/:id', to: redirect('/procedures/old/avis/%{id}')
|
||||
|
@ -388,7 +388,6 @@ Rails.application.routes.draw do
|
|||
resources :archives, only: [:index, :create, :show], controller: 'archives'
|
||||
end
|
||||
end
|
||||
get "recherche" => "recherche#index"
|
||||
end
|
||||
|
||||
#
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
namespace :after_party do
|
||||
desc 'Deployment task: use_jsonb_in_geo_areas_properties'
|
||||
task use_jsonb_in_geo_areas_properties: :environment do
|
||||
puts "Running deploy task 'use_jsonb_in_geo_areas_properties'"
|
||||
|
||||
geo_areas = GeoArea.where("properties::text LIKE ?", "%--- !ruby%")
|
||||
progress = ProgressReport.new(geo_areas.count)
|
||||
geo_areas.find_each do |geo_area|
|
||||
geo_area.properties = geo_area.properties
|
||||
if !geo_area.save
|
||||
geo_area.destroy
|
||||
end
|
||||
progress.inc
|
||||
end
|
||||
progress.finish
|
||||
|
||||
# Update task as completed. If you remove the line below, the task will
|
||||
# run with every deploy (or every time you call after_party:run).
|
||||
AfterParty::TaskRecord
|
||||
.create version: AfterParty::TaskRecorder.new(__FILE__).timestamp
|
||||
end
|
||||
end
|
|
@ -19,6 +19,7 @@
|
|||
"email-butler": "^1.0.13",
|
||||
"highcharts": "^8.1.1",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"is-hotkey": "^0.2.0",
|
||||
"mapbox-gl": "^1.3.0",
|
||||
"match-sorter": "^6.2.0",
|
||||
"prop-types": "^15.7.2",
|
||||
|
|
|
@ -25,7 +25,8 @@ describe Champs::CarteController, type: :controller do
|
|||
let(:params) do
|
||||
{
|
||||
champ_id: champ.id,
|
||||
feature: feature
|
||||
feature: feature,
|
||||
source: GeoArea.sources.fetch(:selection_utilisateur)
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -98,67 +99,37 @@ describe Champs::CarteController, type: :controller do
|
|||
it { expect(response.status).to eq 204 }
|
||||
end
|
||||
|
||||
describe 'POST #import' do
|
||||
describe 'GET #index' do
|
||||
render_views
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
champ_id: champ.id,
|
||||
features: [feature]
|
||||
|
||||
}
|
||||
{ champ_id: champ.id }
|
||||
end
|
||||
|
||||
before do
|
||||
post :import, params: params
|
||||
end
|
||||
|
||||
it {
|
||||
expect(response.status).to eq 201
|
||||
expect(response.body).to include("bbox")
|
||||
}
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
render_views
|
||||
|
||||
before do
|
||||
request.accept = "application/javascript"
|
||||
request.content_type = "application/javascript"
|
||||
get :index, params: params
|
||||
end
|
||||
|
||||
context 'with cadastres update' do
|
||||
context "update list" do
|
||||
it {
|
||||
expect(response.body).not_to include("DS.fire('map:feature:focus'")
|
||||
expect(response.status).to eq 200
|
||||
}
|
||||
end
|
||||
|
||||
context "update list and focus" do
|
||||
let(:params) do
|
||||
{
|
||||
champ_id: champ.id,
|
||||
cadastres: 'update'
|
||||
focus: true
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
get :index, params: params
|
||||
end
|
||||
|
||||
it {
|
||||
expect(response.body).to include("DS.fire('map:feature:focus'")
|
||||
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
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
describe Instructeurs::RechercheController, type: :controller do
|
||||
let(:dossier) { create(:dossier, :en_construction) }
|
||||
let(:dossier2) { create(:dossier, :en_construction, procedure: dossier.procedure) }
|
||||
let(:instructeur) { create(:instructeur) }
|
||||
|
||||
before { instructeur.groupe_instructeurs << dossier2.procedure.defaut_groupe_instructeur }
|
||||
|
||||
describe 'GET #index' do
|
||||
before { sign_in(instructeur.user) }
|
||||
|
||||
subject { get :index, params: { q: query } }
|
||||
|
||||
describe 'by id' do
|
||||
context 'when instructeur own the dossier' do
|
||||
let(:query) { dossier.id }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns the expected dossier' do
|
||||
subject
|
||||
expect(assigns(:dossiers).count).to eq(1)
|
||||
expect(assigns(:dossiers).first.id).to eq(dossier.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when instructeur do not own the dossier' do
|
||||
let(:dossier3) { create(:dossier, :en_construction) }
|
||||
let(:query) { dossier3.id }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'does not return the dossier' do
|
||||
subject
|
||||
expect(assigns(:dossiers).count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an id out of range' do
|
||||
let(:query) { 123456789876543234567 }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'does not return the dossier' do
|
||||
subject
|
||||
expect(assigns(:dossiers).count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no query param it does not crash' do
|
||||
subject { get :index, params: {} }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns 0 dossier' do
|
||||
subject
|
||||
expect(assigns(:dossiers).count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
111
spec/controllers/recherche_controller_spec.rb
Normal file
111
spec/controllers/recherche_controller_spec.rb
Normal file
|
@ -0,0 +1,111 @@
|
|||
describe RechercheController, type: :controller do
|
||||
let(:dossier) { create(:dossier, :en_construction, :with_all_annotations) }
|
||||
let(:dossier2) { create(:dossier, :en_construction, procedure: dossier.procedure) }
|
||||
let(:instructeur) { create(:instructeur) }
|
||||
|
||||
let(:dossier_with_expert) { avis.dossier }
|
||||
let(:avis) { create(:avis, dossier: create(:dossier, :en_construction, :with_all_annotations)) }
|
||||
|
||||
let(:user) { instructeur.user }
|
||||
|
||||
before do
|
||||
instructeur.assign_to_procedure(dossier.procedure)
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
before { sign_in(user) }
|
||||
|
||||
subject { get :index, params: { q: query } }
|
||||
|
||||
describe 'by id' do
|
||||
context 'when instructeur own the dossier' do
|
||||
let(:query) { dossier.id }
|
||||
|
||||
before { subject }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns the expected dossier' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(1)
|
||||
expect(assigns(:projected_dossiers).first.dossier_id).to eq(dossier.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when expert own the dossier' do
|
||||
let(:user) { avis.experts_procedure.expert.user }
|
||||
let(:query) { dossier_with_expert.id }
|
||||
|
||||
before { subject }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns the expected dossier' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(1)
|
||||
expect(assigns(:projected_dossiers).first.dossier_id).to eq(dossier_with_expert.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when instructeur do not own the dossier' do
|
||||
let(:dossier3) { create(:dossier, :en_construction) }
|
||||
let(:query) { dossier3.id }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'does not return the dossier' do
|
||||
subject
|
||||
expect(assigns(:projected_dossiers).count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an id out of range' do
|
||||
let(:query) { 123456789876543234567 }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'does not return the dossier' do
|
||||
subject
|
||||
expect(assigns(:projected_dossiers).count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'by private annotations' do
|
||||
context 'when instructeur search by private annotations' do
|
||||
let(:query) { dossier.private_search_terms }
|
||||
|
||||
before { subject }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns the expected dossier' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(1)
|
||||
expect(assigns(:projected_dossiers).first.dossier_id).to eq(dossier.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when expert search by private annotations' do
|
||||
let(:user) { avis.experts_procedure.expert.user }
|
||||
let(:query) { dossier_with_expert.private_search_terms }
|
||||
|
||||
before { subject }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns 0 dossiers' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no query param it does not crash' do
|
||||
subject { get :index, params: {} }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns 0 dossier' do
|
||||
subject
|
||||
expect(assigns(:projected_dossiers).count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,17 +1,16 @@
|
|||
FactoryBot.define do
|
||||
factory :geo_area do
|
||||
association :champ
|
||||
properties { {} }
|
||||
|
||||
trait :cadastre do
|
||||
source { GeoArea.sources.fetch(:cadastre) }
|
||||
numero { '42' }
|
||||
feuille { 'A11' }
|
||||
properties { { numero: '42', section: 'A11', prefixe: '000', commune: '75127', contenance: '1234', id: '75127000A1142' } }
|
||||
end
|
||||
|
||||
trait :quartier_prioritaire do
|
||||
source { GeoArea.sources.fetch(:quartier_prioritaire) }
|
||||
nom { 'XYZ' }
|
||||
commune { 'Paris' }
|
||||
trait :legacy_cadastre do
|
||||
source { GeoArea.sources.fetch(:cadastre) }
|
||||
properties { { numero: '42', section: 'A11', code_com: '127', code_dep: '75', code_arr: '000', surface_parcelle: '1234', surface_intersection: 1234 } }
|
||||
end
|
||||
|
||||
trait :selection_utilisateur do
|
||||
|
|
|
@ -193,6 +193,12 @@ FactoryBot.define do
|
|||
end
|
||||
end
|
||||
|
||||
trait :with_address do
|
||||
after(:build) do |procedure, _evaluator|
|
||||
build(:type_de_champ_address, procedure: procedure)
|
||||
end
|
||||
end
|
||||
|
||||
trait :with_explication do
|
||||
after(:build) do |procedure, _evaluator|
|
||||
build(:type_de_champ_explication, procedure: procedure)
|
||||
|
|
|
@ -67,11 +67,49 @@ feature 'Inviting an expert:' do
|
|||
expect(page).to have_text('1 avis donné')
|
||||
end
|
||||
|
||||
# TODO
|
||||
# scenario 'I can read other experts advices' do
|
||||
# end
|
||||
|
||||
# scenario 'I can invite other experts' do
|
||||
# end
|
||||
end
|
||||
|
||||
context 'when there are two experts' do
|
||||
let(:expert_1) { create(:expert) }
|
||||
let(:expert_2) { create(:expert) }
|
||||
let(:instructeur) { create(:instructeur) }
|
||||
let(:procedure) { create(:procedure, :published, instructeurs: [instructeur]) }
|
||||
let(:experts_procedure_1) { create(:experts_procedure, expert: expert_1, procedure: procedure) }
|
||||
let(:experts_procedure_2) { create(:experts_procedure, expert: expert_2, procedure: procedure) }
|
||||
let(:dossier) { create(:dossier, :en_construction, :with_dossier_link, procedure: procedure) }
|
||||
let!(:avis_1) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure_1, confidentiel: true) }
|
||||
let!(:avis_2) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure_2, confidentiel: false) }
|
||||
|
||||
scenario 'As a expert_1, I can read expert_2 advice because it is not confidential' do
|
||||
login_as expert_1.user, scope: :user
|
||||
|
||||
visit expert_all_avis_path
|
||||
expect(page).to have_text('1 avis à donner')
|
||||
expect(page).to have_text('0 avis donnés')
|
||||
|
||||
click_on '1 avis à donner'
|
||||
click_on avis_1.dossier.user.email
|
||||
within('.tabs') { click_on 'Avis' }
|
||||
expect(page).to have_text("Demandeur : #{avis_1.claimant.email}")
|
||||
expect(page).to have_text("Vous")
|
||||
expect(page).to have_text(avis_2.expert.email.to_s)
|
||||
end
|
||||
|
||||
scenario 'As a expert_2, I cannot read expert_1 advice because it is confidential' do
|
||||
login_as expert_2.user, scope: :user
|
||||
|
||||
visit expert_all_avis_path
|
||||
expect(page).to have_text('1 avis à donner')
|
||||
expect(page).to have_text('0 avis donnés')
|
||||
|
||||
click_on '1 avis à donner'
|
||||
click_on avis_2.dossier.user.email
|
||||
within('.tabs') { click_on 'Avis' }
|
||||
expect(page).to have_text("Demandeur : #{avis_2.claimant.email}")
|
||||
expect(page).to have_text("Vous")
|
||||
expect(page).not_to have_text(avis_1.expert.email.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
describe APICarto::API do
|
||||
describe '.search_cadastre' do
|
||||
subject { described_class.search_cadastre(geojson) }
|
||||
|
||||
before do
|
||||
stub_request(:post, "https://sandbox.geo.api.gouv.fr/apicarto/cadastre/geometrie")
|
||||
.with(:body => /.*/,
|
||||
:headers => { 'Content-Type' => 'application/json' })
|
||||
.to_return(status: status, body: body)
|
||||
end
|
||||
context 'when geojson is empty' do
|
||||
let(:geojson) { '' }
|
||||
let(:status) { 404 }
|
||||
let(:body) { '' }
|
||||
|
||||
it 'raises APICarto::API::ResourceNotFound' do
|
||||
expect { subject }.to raise_error(APICarto::API::ResourceNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when geojson exist' do
|
||||
let(:geojson) { File.read('spec/fixtures/files/api_carto/request_cadastre.json') }
|
||||
let(:status) { 200 }
|
||||
let(:body) { 'toto' }
|
||||
|
||||
it 'returns response body' do
|
||||
expect(subject).to eq(body)
|
||||
end
|
||||
|
||||
context 'when geojson is at format JSON' do
|
||||
let(:geojson) { JSON.parse(File.read('spec/fixtures/files/api_carto/request_cadastre.json')) }
|
||||
|
||||
it 'returns response body' do
|
||||
expect(subject).to eq(body)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,63 +0,0 @@
|
|||
describe APICarto::CadastreAdapter do
|
||||
subject { described_class.new(coordinates).results }
|
||||
|
||||
before do
|
||||
stub_request(:post, "https://sandbox.geo.api.gouv.fr/apicarto/cadastre/geometrie")
|
||||
.with(:body => /.*/,
|
||||
:headers => { 'Content-Type' => 'application/json' })
|
||||
.to_return(status: status, body: body)
|
||||
end
|
||||
|
||||
context 'coordinates are filled' do
|
||||
let(:coordinates) { '[[2.252728, 43.27151][2.323223, 32.835332]]' }
|
||||
let(:status) { 200 }
|
||||
let(:body) { File.read('spec/fixtures/files/api_carto/response_cadastre.json') }
|
||||
|
||||
it { expect(subject).to be_a_instance_of(Array) }
|
||||
it { expect(subject.size).to eq(16) }
|
||||
|
||||
describe 'Attribut filter' do
|
||||
let(:adapter) { described_class.new(coordinates) }
|
||||
subject { adapter.filter_properties(adapter.data_source[:features].first[:properties]) }
|
||||
|
||||
it { expect(subject.size).to eq(9) }
|
||||
it do
|
||||
expect(subject.keys).to eq([
|
||||
:surface_intersection,
|
||||
:surface_parcelle,
|
||||
:numero,
|
||||
:feuille,
|
||||
:section,
|
||||
:code_dep,
|
||||
:nom_com,
|
||||
:code_com,
|
||||
:code_arr
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Attributes' do
|
||||
subject { super().first }
|
||||
|
||||
it { expect(subject[:surface_intersection]).to eq('0.0202') }
|
||||
it { expect(subject[:surface_parcelle]).to eq(220.0664659755941) }
|
||||
it { expect(subject[:numero]).to eq('0082') }
|
||||
it { expect(subject[:feuille]).to eq(1) }
|
||||
it { expect(subject[:section]).to eq('0J') }
|
||||
it { expect(subject[:code_dep]).to eq('94') }
|
||||
it { expect(subject[:nom_com]).to eq('Maisons-Alfort') }
|
||||
it { expect(subject[:code_com]).to eq('046') }
|
||||
it { expect(subject[:code_arr]).to eq('000') }
|
||||
|
||||
it { expect(subject[:geometry]).to eq({ type: "MultiPolygon", coordinates: [[[[2.4362443, 48.8092078], [2.436384, 48.8092043], [2.4363802, 48.8091414]]]] }) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'coordinates are empty' do
|
||||
let(:coordinates) { '' }
|
||||
let(:status) { 404 }
|
||||
let(:body) { '' }
|
||||
|
||||
it { expect { subject }.to raise_error(APICarto::API::ResourceNotFound) }
|
||||
end
|
||||
end
|
|
@ -8,6 +8,7 @@ describe TagsSubstitutionConcern, type: :model do
|
|||
|
||||
let(:procedure) do
|
||||
create(:procedure,
|
||||
:published,
|
||||
libelle: 'Une magnifique démarche',
|
||||
types_de_champ: types_de_champ,
|
||||
types_de_champ_private: types_de_champ_private,
|
||||
|
@ -389,6 +390,33 @@ describe TagsSubstitutionConcern, type: :model do
|
|||
is_expected.to eq('--motivation-- --date de décision--')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when procedure has revisions' do
|
||||
let(:types_de_champ) { [build(:type_de_champ, libelle: 'mon ancien libellé')] }
|
||||
let(:draft_type_de_champ) { procedure.draft_revision.find_or_clone_type_de_champ(types_de_champ[0].stable_id) }
|
||||
|
||||
before do
|
||||
draft_type_de_champ.update(libelle: 'mon nouveau libellé')
|
||||
dossier.champs.first.update(value: 'valeur')
|
||||
procedure.update!(draft_revision: procedure.create_new_revision, published_revision: procedure.draft_revision)
|
||||
end
|
||||
|
||||
context "when using the champ's original label" do
|
||||
let(:template) { '--mon ancien libellé--' }
|
||||
|
||||
it "replaces the tag" do
|
||||
is_expected.to eq('valeur')
|
||||
end
|
||||
end
|
||||
|
||||
context "when using the champ's revised label" do
|
||||
let(:template) { '--mon nouveau libellé--' }
|
||||
|
||||
it "replaces the tag" do
|
||||
is_expected.to eq('valeur')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tags' do
|
||||
|
|
|
@ -337,20 +337,22 @@ describe Dossier do
|
|||
it { expect(dossier.avis_for_expert(expert_2)).not_to match([avis]) }
|
||||
end
|
||||
|
||||
context 'when there is a public advice asked from one expert to another' do
|
||||
let!(:avis) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure_2, confidentiel: false) }
|
||||
context 'when there is a public advice asked from one instructeur to an expert' do
|
||||
let!(:avis_1) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure, confidentiel: false) }
|
||||
let!(:avis_2) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure_2, confidentiel: false) }
|
||||
|
||||
it { expect(dossier.avis_for_instructeur(instructeur)).to match([avis]) }
|
||||
it { expect(dossier.avis_for_expert(expert_1)).to match([avis]) }
|
||||
it { expect(dossier.avis_for_expert(expert_2)).to match([avis]) }
|
||||
it { expect(dossier.avis_for_instructeur(instructeur)).to match([avis_1, avis_2]) }
|
||||
it { expect(dossier.avis_for_expert(expert_1)).to match([avis_1, avis_2]) }
|
||||
it { expect(dossier.avis_for_expert(expert_2)).to match([avis_1, avis_2]) }
|
||||
end
|
||||
|
||||
context 'when there is a private advice asked from one expert to another' do
|
||||
let!(:avis) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure_2, confidentiel: true) }
|
||||
context 'when there is a private advice asked from one instructeur to an expert' do
|
||||
let!(:avis_1) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure, confidentiel: true) }
|
||||
let!(:avis_2) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure_2, confidentiel: true) }
|
||||
|
||||
it { expect(dossier.avis_for_instructeur(instructeur)).to match([avis]) }
|
||||
it { expect(dossier.avis_for_expert(expert_1)).not_to match([avis]) }
|
||||
it { expect(dossier.avis_for_expert(expert_2)).to match([avis]) }
|
||||
it { expect(dossier.avis_for_instructeur(instructeur)).to match([avis_1, avis_2]) }
|
||||
it { expect(dossier.avis_for_expert(expert_1)).to match([avis_1]) }
|
||||
it { expect(dossier.avis_for_expert(expert_2)).to match([avis_2]) }
|
||||
end
|
||||
|
||||
context 'when they are a lot of advice' do
|
||||
|
@ -361,6 +363,12 @@ describe Dossier do
|
|||
it { expect(dossier.avis_for_instructeur(instructeur)).to match([avis_2, avis_1, avis_3]) }
|
||||
it { expect(dossier.avis_for_expert(expert_1)).to match([avis_2, avis_1, avis_3]) }
|
||||
end
|
||||
|
||||
context 'when they are a advice published on another dossier' do
|
||||
let!(:avis) { create(:avis, dossier: create(:dossier, procedure: procedure), claimant: instructeur, experts_procedure: experts_procedure, confidentiel: false, created_at: Time.zone.parse('9/01/2010')) }
|
||||
|
||||
it { expect(dossier.avis_for_expert(expert_1)).to match([]) }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_state_dates' do
|
||||
|
|
|
@ -80,4 +80,24 @@ RSpec.describe GeoArea, type: :model do
|
|||
it { expect(geo_area.valid?).to be_falsey }
|
||||
end
|
||||
end
|
||||
|
||||
describe "cadastre properties" do
|
||||
let(:geo_area) { build(:geo_area, :cadastre) }
|
||||
let(:legacy_geo_area) { build(:geo_area, :legacy_cadastre) }
|
||||
|
||||
it "should be backward compatible" do
|
||||
expect("#{geo_area.code_dep}#{geo_area.code_com}").to eq(geo_area.commune)
|
||||
expect(geo_area.code_arr).to eq(geo_area.prefixe)
|
||||
expect(geo_area.surface_parcelle).to eq(geo_area.surface)
|
||||
end
|
||||
|
||||
context "(legacy)" do
|
||||
it "should be forward compatible" do
|
||||
expect("#{legacy_geo_area.code_dep}#{legacy_geo_area.code_com}").to eq(legacy_geo_area.commune)
|
||||
expect(legacy_geo_area.code_arr).to eq(legacy_geo_area.prefixe)
|
||||
expect(legacy_geo_area.surface_parcelle).to eq(legacy_geo_area.surface)
|
||||
expect(legacy_geo_area.cid).to eq(geo_area.cid)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -348,6 +348,7 @@ describe User, type: :model do
|
|||
expect(dossier_termine.user).to be_nil
|
||||
expect(dossier_termine.user_email_for(:display)).to eq(user.email)
|
||||
expect(dossier_termine.valid?).to be_truthy
|
||||
expect(dossier_termine.france_connect_information).to be_nil
|
||||
expect { dossier_termine.user_email_for(:notification) }.to raise_error(RuntimeError)
|
||||
|
||||
expect(User.find_by(id: user.id)).to be_nil
|
||||
|
|
|
@ -85,7 +85,7 @@ describe ChampSerializer do
|
|||
source: GeoArea.sources.fetch(:cadastre),
|
||||
geometry: geo_json,
|
||||
numero: '42',
|
||||
feuille: 'A11'
|
||||
section: 'A11'
|
||||
)
|
||||
expect(subject[:geo_areas].first.key?(:nom)).to be_falsey
|
||||
}
|
||||
|
|
|
@ -162,6 +162,28 @@ describe DossierProjectionService do
|
|||
|
||||
it { is_expected.to eq('quinoa') }
|
||||
end
|
||||
|
||||
context 'for type_de_champ table and value to.s' do
|
||||
let(:table) { 'type_de_champ' }
|
||||
let(:procedure) { create(:procedure, :with_yes_no) }
|
||||
let(:dossier) { create(:dossier, procedure: procedure) }
|
||||
let(:column) { dossier.procedure.types_de_champ.first.stable_id.to_s }
|
||||
|
||||
before { dossier.champs.first.update(value: 'true') }
|
||||
|
||||
it { is_expected.to eq('Oui') }
|
||||
end
|
||||
|
||||
context 'for type_de_champ table and value to.s which needs data field' do
|
||||
let(:table) { 'type_de_champ' }
|
||||
let(:procedure) { create(:procedure, :with_address) }
|
||||
let(:dossier) { create(:dossier, procedure: procedure) }
|
||||
let(:column) { dossier.procedure.types_de_champ.first.stable_id.to_s }
|
||||
|
||||
before { dossier.champs.first.update(data: { 'label' => '18 a la bonne rue', 'departement' => 'd' }) }
|
||||
|
||||
it { is_expected.to eq('18 a la bonne rue') }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
describe DossierSearchService do
|
||||
describe '#matching_dossiers_for_instructeur' do
|
||||
describe '#matching_dossiers' do
|
||||
subject { liste_dossiers }
|
||||
|
||||
let(:liste_dossiers) do
|
||||
described_class.matching_dossiers_for_instructeur(terms, instructeur_1)
|
||||
described_class.matching_dossiers(instructeur_1.dossiers, terms)
|
||||
end
|
||||
|
||||
let(:administrateur_1) { create(:administrateur) }
|
||||
|
|
95
yarn.lock
95
yarn.lock
|
@ -2925,14 +2925,15 @@ browserify-zlib@^0.2.0:
|
|||
pako "~1.0.5"
|
||||
|
||||
browserslist@^4.0.0, browserslist@^4.11.1, browserslist@^4.6.4, browserslist@^4.8.5:
|
||||
version "4.12.0"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.12.0.tgz#06c6d5715a1ede6c51fc39ff67fd647f740b656d"
|
||||
integrity sha512-UH2GkcEDSI0k/lRkuDSzFl9ZZ87skSy9w2XAn1MsZnL+4c4rqbBd3e82UWHbYDpztABrPBhZsTEeuxVfHppqDg==
|
||||
version "4.16.6"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2"
|
||||
integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==
|
||||
dependencies:
|
||||
caniuse-lite "^1.0.30001043"
|
||||
electron-to-chromium "^1.3.413"
|
||||
node-releases "^1.1.53"
|
||||
pkg-up "^2.0.0"
|
||||
caniuse-lite "^1.0.30001219"
|
||||
colorette "^1.2.2"
|
||||
electron-to-chromium "^1.3.723"
|
||||
escalade "^3.1.1"
|
||||
node-releases "^1.1.71"
|
||||
|
||||
btoa-lite@^1.0.0:
|
||||
version "1.0.0"
|
||||
|
@ -3175,10 +3176,10 @@ caniuse-api@^3.0.0:
|
|||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001043:
|
||||
version "1.0.30001055"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001055.tgz#7b52c3537f7a8c0408aca867e83d2b04268b54cd"
|
||||
integrity sha512-MbwsBmKrBSKIWldfdIagO5OJWZclpJtS4h0Jrk/4HFrXJxTdVdH23Fd+xCiHriVGvYcWyW8mR/CPsYajlH8Iuw==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001219:
|
||||
version "1.0.30001228"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz#bfdc5942cd3326fa51ee0b42fbef4da9d492a7fa"
|
||||
integrity sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A==
|
||||
|
||||
cardinal@^2.1.1:
|
||||
version "2.1.1"
|
||||
|
@ -3582,6 +3583,11 @@ color@^3.0.0:
|
|||
color-convert "^1.9.1"
|
||||
color-string "^1.5.2"
|
||||
|
||||
colorette@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
|
||||
integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
|
||||
|
||||
colors@^1.1.2, colors@^1.2.1:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
|
||||
|
@ -4720,10 +4726,10 @@ ejs@^2.6.1:
|
|||
resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
|
||||
integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
|
||||
|
||||
electron-to-chromium@^1.3.413:
|
||||
version "1.3.435"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.435.tgz#22a7008e8f5a317a6d2d80802bddacebb19ae025"
|
||||
integrity sha512-BVXnq+NCefidU7GOFPx4CPBfPcccLCRBKZYSbvBJMSn2kwGD7ML+eUA9tqfHAumRqy3oX5zaeTI1Bpt7qVat0Q==
|
||||
electron-to-chromium@^1.3.723:
|
||||
version "1.3.737"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.737.tgz#196f2e9656f4f3c31930750e1899c091b72d36b5"
|
||||
integrity sha512-P/B84AgUSQXaum7a8m11HUsYL8tj9h/Pt5f7Hg7Ty6bm5DxlFq+e5+ouHUoNQMsKDJ7u4yGfI8mOErCmSH9wyg==
|
||||
|
||||
elf-tools@^1.1.1:
|
||||
version "1.1.2"
|
||||
|
@ -4915,6 +4921,11 @@ es-to-primitive@^1.2.1:
|
|||
is-date-object "^1.0.1"
|
||||
is-symbol "^1.0.2"
|
||||
|
||||
escalade@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
|
||||
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
|
||||
|
||||
escape-goat@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
|
||||
|
@ -5547,13 +5558,6 @@ find-up@^1.0.0:
|
|||
path-exists "^2.0.0"
|
||||
pinkie-promise "^2.0.0"
|
||||
|
||||
find-up@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
|
||||
integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c=
|
||||
dependencies:
|
||||
locate-path "^2.0.0"
|
||||
|
||||
find-up@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
|
||||
|
@ -6982,6 +6986,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1:
|
|||
dependencies:
|
||||
is-extglob "^2.1.1"
|
||||
|
||||
is-hotkey@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.2.0.tgz#1835a68171a91e5c9460869d96336947c8340cef"
|
||||
integrity sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==
|
||||
|
||||
is-installed-globally@^0.3.1:
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141"
|
||||
|
@ -7588,14 +7597,6 @@ loader-utils@^2.0.0:
|
|||
emojis-list "^3.0.0"
|
||||
json5 "^2.1.2"
|
||||
|
||||
locate-path@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
|
||||
integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=
|
||||
dependencies:
|
||||
p-locate "^2.0.0"
|
||||
path-exists "^3.0.0"
|
||||
|
||||
locate-path@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
|
||||
|
@ -8572,10 +8573,10 @@ node-libs-browser@^2.2.1:
|
|||
util "^0.11.0"
|
||||
vm-browserify "^1.0.1"
|
||||
|
||||
node-releases@^1.1.53:
|
||||
version "1.1.55"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.55.tgz#8af23b7c561d8e2e6e36a46637bab84633b07cee"
|
||||
integrity sha512-H3R3YR/8TjT5WPin/wOoHOUPHgvj8leuU/Keta/rwelEQN9pA/S2Dx8/se4pZ2LBxSd0nAGzsNzhqwa77v7F1w==
|
||||
node-releases@^1.1.71:
|
||||
version "1.1.72"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe"
|
||||
integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==
|
||||
|
||||
node-sass@^4.13.1:
|
||||
version "4.14.1"
|
||||
|
@ -9088,13 +9089,6 @@ p-is-promise@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e"
|
||||
integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==
|
||||
|
||||
p-limit@^1.1.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
|
||||
integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==
|
||||
dependencies:
|
||||
p-try "^1.0.0"
|
||||
|
||||
p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
|
||||
|
@ -9102,13 +9096,6 @@ p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0:
|
|||
dependencies:
|
||||
p-try "^2.0.0"
|
||||
|
||||
p-locate@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
|
||||
integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=
|
||||
dependencies:
|
||||
p-limit "^1.1.0"
|
||||
|
||||
p-locate@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
|
||||
|
@ -9161,11 +9148,6 @@ p-timeout@^3.0.0, p-timeout@^3.1.0:
|
|||
dependencies:
|
||||
p-finally "^1.0.0"
|
||||
|
||||
p-try@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
|
||||
integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=
|
||||
|
||||
p-try@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
|
||||
|
@ -9442,13 +9424,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0:
|
|||
dependencies:
|
||||
find-up "^4.0.0"
|
||||
|
||||
pkg-up@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f"
|
||||
integrity sha1-yBmscoBZpGHKscOImivjxJoATX8=
|
||||
dependencies:
|
||||
find-up "^2.1.0"
|
||||
|
||||
pnp-webpack-plugin@^1.6.4:
|
||||
version "1.6.4"
|
||||
resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149"
|
||||
|
|
Loading…
Reference in a new issue