diff --git a/.eslintrc.js b/.eslintrc.js index d697ee9c7..e9ea62e88 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,18 +6,24 @@ module.exports = { sourceType: 'module' }, globals: { - 'process': true, - 'gon': true + process: true, + gon: true }, plugins: ['prettier', 'react-hooks'], - extends: ['eslint:recommended', 'prettier', 'plugin:react/recommended'], + extends: [ + 'eslint:recommended', + 'prettier', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended' + ], env: { es6: true, browser: true }, rules: { 'prettier/prettier': 'error', - 'react-hooks/rules-of-hooks': 'error' + 'react-hooks/rules-of-hooks': 'error', + 'react/prop-types': 'off' }, settings: { react: { @@ -26,10 +32,26 @@ module.exports = { }, overrides: [ { - files: ['config/webpack/**/*.js', 'babel.config.js', 'postcss.config.js'], + files: [ + '.eslintrc.js', + 'config/webpack/**/*.js', + 'babel.config.js', + 'postcss.config.js' + ], env: { node: true } + }, + { + files: ['**/*.ts', '**/*.tsx'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'prettier' + ] } ] }; diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss index 33f065583..46809bd99 100644 --- a/app/assets/stylesheets/carte.scss +++ b/app/assets/stylesheets/carte.scss @@ -41,7 +41,7 @@ } .map-style-panel { - z-index: 99; + z-index: 1; padding: $default-spacer; margin-bottom: $default-spacer; @@ -60,6 +60,7 @@ } .cadastres-selection-control { + z-index: 1; position: absolute; top: 135px; left: 10px; diff --git a/app/assets/stylesheets/labels.scss b/app/assets/stylesheets/labels.scss index 50be851d2..1281fb21e 100644 --- a/app/assets/stylesheets/labels.scss +++ b/app/assets/stylesheets/labels.scss @@ -58,7 +58,7 @@ // Labels that we only want for screen readers // https://www.coolfields.co.uk/2016/05/text-for-screen-readers-only-updated/ -.screen-reader-text { +.sr-only { border: none; clip: rect(1px, 1px, 1px, 1px); clip-path: inset(50%); diff --git a/app/javascript/components/ComboAdresseSearch.jsx b/app/javascript/components/ComboAdresseSearch.jsx deleted file mode 100644 index 5691d76e0..000000000 --- a/app/javascript/components/ComboAdresseSearch.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { useCallback } from 'react'; -import { QueryClientProvider } from 'react-query'; -import PropTypes from 'prop-types'; - -import ComboSearch from './ComboSearch'; -import { queryClient } from './shared/queryClient'; - -function ComboAdresseSearch({ - transformResult = ({ properties: { label } }) => [label, label], - allowInputValues = true, - ...props -}) { - const transformResults = useCallback((_, { features }) => features); - - return ( - - - - ); -} - -ComboAdresseSearch.propTypes = { - transformResult: PropTypes.func, - allowInputValues: PropTypes.bool -}; - -export default ComboAdresseSearch; diff --git a/app/javascript/components/ComboAdresseSearch.tsx b/app/javascript/components/ComboAdresseSearch.tsx new file mode 100644 index 000000000..b9c91afc3 --- /dev/null +++ b/app/javascript/components/ComboAdresseSearch.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { QueryClientProvider } from 'react-query'; +import type { FeatureCollection, Geometry } from 'geojson'; + +import ComboSearch, { ComboSearchProps } from './ComboSearch'; +import { queryClient } from './shared/queryClient'; + +type RawResult = FeatureCollection; +type AdresseResult = RawResult['features'][0]; +type ComboAdresseSearchProps = Omit< + ComboSearchProps, + 'minimumInputLength' | 'transformResult' | 'transformResults' | 'scope' +>; + +export default function ComboAdresseSearch({ + allowInputValues = true, + ...props +}: ComboAdresseSearchProps) { + return ( + + + {...props} + allowInputValues={allowInputValues} + scope="adresse" + minimumInputLength={2} + transformResult={({ properties: { label } }) => [label, label, label]} + transformResults={(_, result) => (result as RawResult).features} + /> + + ); +} diff --git a/app/javascript/components/ComboSearch.jsx b/app/javascript/components/ComboSearch.tsx similarity index 72% rename from app/javascript/components/ComboSearch.jsx rename to app/javascript/components/ComboSearch.tsx index 647bf5d9d..4e322c453 100644 --- a/app/javascript/components/ComboSearch.jsx +++ b/app/javascript/components/ComboSearch.tsx @@ -1,7 +1,6 @@ import React, { useState, useCallback, useRef } from 'react'; import { useDebounce } from 'use-debounce'; import { useQuery } from 'react-query'; -import PropTypes from 'prop-types'; import { Combobox, ComboboxInput, @@ -14,11 +13,33 @@ import invariant from 'tiny-invariant'; import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks'; -function defaultTransformResults(_, results) { - return results; -} +type TransformResults = (term: string, results: unknown) => Result[]; +type TransformResult = ( + result: Result +) => [key: string, value: string, label: string]; -function ComboSearch({ +export type ComboSearchProps = { + onChange?: (value: string | null, result?: Result) => void; + value?: string; + scope: string; + scopeExtra?: string; + minimumInputLength: number; + transformResults: TransformResults; + transformResult: TransformResult; + allowInputValues?: boolean; + id?: string; + describedby?: string; + className?: string; + placeholder?: string; +}; + +type QueryKey = readonly [ + scope: string, + term: string, + extra: string | undefined +]; + +function ComboSearch({ onChange, value: controlledValue, scope, @@ -26,26 +47,28 @@ function ComboSearch({ minimumInputLength, transformResult, allowInputValues = false, - transformResults = defaultTransformResults, + transformResults = (_, results) => results as Result[], id, describedby, ...props -}) { +}: ComboSearchProps) { invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required'); - const group = !onChange ? groupId(id) : null; + const group = !onChange && id ? groupId(id) : undefined; const [externalValue, setExternalValue, hiddenField] = useHiddenField(group); const [, setExternalId] = useHiddenField(group, 'external_id'); const initialValue = externalValue ? externalValue : controlledValue; const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm] = useDebounce(searchTerm, 300); const [value, setValue] = useState(initialValue); - const resultsMap = useRef({}); - const getLabel = (result) => { + const resultsMap = useRef< + Record + >({}); + const getLabel = (result: Result) => { const [, value, label] = transformResult(result); return label ?? value; }; - const setExternalValueAndId = useCallback((label) => { + const setExternalValueAndId = useCallback((label: string) => { const { key, value, result } = resultsMap.current[label]; if (onChange) { onChange(value, result); @@ -77,22 +100,22 @@ function ComboSearch({ [minimumInputLength] ); - const handleOnSelect = useCallback((value) => { + const handleOnSelect = useCallback((value: string) => { setExternalValueAndId(value); setValue(value); setSearchTerm(''); awaitFormSubmit.done(); }, []); - const { isSuccess, data } = useQuery( + const { isSuccess, data } = useQuery( [scope, debouncedSearchTerm, scopeExtra], { enabled: !!debouncedSearchTerm, - notifyOnStatusChange: false, refetchOnMount: false } ); - const results = isSuccess ? transformResults(debouncedSearchTerm, data) : []; + const results = + isSuccess && data ? transformResults(debouncedSearchTerm, data) : []; const onBlur = useCallback(() => { if (!allowInputValues && isSuccess && results[0]) { @@ -136,18 +159,4 @@ function ComboSearch({ ); } -ComboSearch.propTypes = { - value: PropTypes.string, - scope: PropTypes.string, - minimumInputLength: PropTypes.number, - transformResult: PropTypes.func, - transformResults: PropTypes.func, - allowInputValues: PropTypes.bool, - onChange: PropTypes.func, - scopeExtra: PropTypes.string, - mandatory: PropTypes.bool, - id: PropTypes.string, - describedby: PropTypes.string -}; - export default ComboSearch; diff --git a/app/javascript/components/MapEditor/components/AddressInput.tsx b/app/javascript/components/MapEditor/components/AddressInput.tsx new file mode 100644 index 000000000..9becf7034 --- /dev/null +++ b/app/javascript/components/MapEditor/components/AddressInput.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import type { Point } from 'geojson'; + +import ComboAdresseSearch from '../../ComboAdresseSearch'; +import { useFlyTo } from '../../shared/maplibre/hooks'; + +export function AddressInput() { + const flyTo = useFlyTo(); + + return ( +
+ { + const geometry = result?.geometry as Point; + flyTo(17, geometry.coordinates as [number, number]); + }} + /> +
+ ); +} diff --git a/app/javascript/components/MapEditor/components/CadastreLayer.tsx b/app/javascript/components/MapEditor/components/CadastreLayer.tsx new file mode 100644 index 000000000..4ebb14523 --- /dev/null +++ b/app/javascript/components/MapEditor/components/CadastreLayer.tsx @@ -0,0 +1,164 @@ +import { useCallback, useRef } from 'react'; +import type { Feature, FeatureCollection } from 'geojson'; + +import { useMapLibre } from '../../shared/maplibre/MapLibre'; +import { + useEvent, + useMapEvent, + EventHandler +} from '../../shared/maplibre/hooks'; +import { + filterFeatureCollection, + findFeature +} from '../../shared/maplibre/utils'; + +import { SOURCE_CADASTRE, CreateFeatures, DeleteFeatures } from '../hooks'; + +export function CadastreLayer({ + featureCollection, + createFeatures, + deleteFeatures, + enabled +}: { + featureCollection: FeatureCollection; + createFeatures: CreateFeatures; + deleteFeatures: DeleteFeatures; + enabled: boolean; +}) { + const map = useMapLibre(); + const selectedCadastresRef = useRef(new Set()); + + const highlightFeature = useCallback((cid: string, highlight: boolean) => { + if (highlight) { + selectedCadastresRef.current.add(cid); + } else { + selectedCadastresRef.current.delete(cid); + } + if (selectedCadastresRef.current.size == 0) { + map.setFilter('parcelle-highlighted', ['in', 'id', '']); + } else { + map.setFilter('parcelle-highlighted', [ + 'in', + 'id', + ...selectedCadastresRef.current + ]); + } + }, []); + + const hoverFeature = useCallback((feature: Feature, hover: boolean) => { + if (!selectedCadastresRef.current.has(feature.properties?.id)) { + map.setFeatureState( + { + source: 'cadastre', + sourceLayer: 'parcelles', + id: String(feature.id) + }, + { hover } + ); + } + }, []); + + useCadastres(featureCollection, { + hoverFeature, + createFeatures, + deleteFeatures, + enabled + }); + + useMapEvent('styledata', () => { + selectedCadastresRef.current = new Set( + filterFeatureCollection(featureCollection, SOURCE_CADASTRE).features.map( + ({ properties }) => properties?.cid + ) + ); + if (selectedCadastresRef.current.size > 0) { + map.setFilter('parcelle-highlighted', [ + 'in', + 'id', + ...selectedCadastresRef.current + ]); + } + }); + + const onHighlight = useCallback( + ({ detail }) => { + highlightFeature(detail.cid, detail.highlight); + }, + [highlightFeature] + ); + + useEvent('map:internal:cadastre:highlight', onHighlight); + + return null; +} + +function useCadastres( + featureCollection: FeatureCollection, + { + enabled, + hoverFeature, + createFeatures, + deleteFeatures + }: { + enabled: boolean; + hoverFeature: (feature: Feature, flag: boolean) => void; + createFeatures: CreateFeatures; + deleteFeatures: DeleteFeatures; + } +) { + const hoveredFeature = useRef(); + + const onMouseMove = useCallback( + (event) => { + if (enabled && event.features && 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); + } + } + }, + [enabled, hoverFeature] + ); + + const onMouseLeave = useCallback(() => { + if (hoveredFeature.current) { + hoverFeature(hoveredFeature.current, false); + } + hoveredFeature.current = null; + }, [hoverFeature]); + + const onClick = useCallback( + async (event) => { + if (enabled && event.features && 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 + }); + } + } + }, + [enabled, featureCollection, createFeatures, deleteFeatures] + ); + + useMapEvent('click', onClick, 'parcelles-fill'); + useMapEvent('mousemove', onMouseMove, 'parcelles-fill'); + useMapEvent('mouseleave', onMouseLeave, 'parcelles-fill'); +} diff --git a/app/javascript/components/MapEditor/components/DrawLayer.tsx b/app/javascript/components/MapEditor/components/DrawLayer.tsx new file mode 100644 index 000000000..f5292da9a --- /dev/null +++ b/app/javascript/components/MapEditor/components/DrawLayer.tsx @@ -0,0 +1,183 @@ +import { useCallback, useRef, useEffect } from 'react'; +import type { LngLatBoundsLike } from 'maplibre-gl'; +import DrawControl from '@mapbox/mapbox-gl-draw'; +import type { FeatureCollection } from 'geojson'; + +import { useMapLibre } from '../../shared/maplibre/MapLibre'; +import { + useFitBounds, + useEvent, + useMapEvent +} from '../../shared/maplibre/hooks'; +import { + filterFeatureCollection, + findFeature, + getBounds +} from '../../shared/maplibre/utils'; +import { + SOURCE_SELECTION_UTILISATEUR, + CreateFeatures, + UpdateFatures, + DeleteFeatures +} from '../hooks'; + +export function DrawLayer({ + featureCollection, + createFeatures, + updateFeatures, + deleteFeatures, + enabled +}: { + featureCollection: FeatureCollection; + createFeatures: CreateFeatures; + updateFeatures: UpdateFatures; + deleteFeatures: DeleteFeatures; + enabled: boolean; +}) { + const map = useMapLibre(); + const drawRef = useRef(); + + useEffect(() => { + if (!drawRef.current && enabled) { + const draw = new DrawControl({ + displayControlsDefault: false, + controls: { + point: true, + line_string: true, + polygon: true, + trash: true + } + }); + map.addControl(draw as any, 'top-left'); + draw.set( + filterFeatureCollection(featureCollection, SOURCE_SELECTION_UTILISATEUR) + ); + drawRef.current = draw; + + for (const [selector, translation] of translations) { + const element = document.querySelector(selector); + if (element) { + element.setAttribute('title', translation); + } + } + } + + return () => { + if (drawRef.current) { + map.removeControl(drawRef.current as any); + drawRef.current = null; + } + }; + }, [enabled]); + + const onSetId = useCallback(({ detail }) => { + drawRef.current?.setFeatureProperty(detail.lid, 'id', detail.id); + }, []); + const onAddFeature = useCallback(({ detail }) => { + drawRef.current?.add(detail.feature); + }, []); + const onDeleteFature = useCallback(({ detail }) => { + drawRef.current?.delete(detail.id); + }, []); + + useMapEvent('draw.create', createFeatures); + useMapEvent('draw.update', updateFeatures); + useMapEvent('draw.delete', deleteFeatures); + + useEvent('map:internal:draw:setId', onSetId); + useEvent('map:internal:draw:add', onAddFeature); + useEvent('map:internal:draw:delete', onDeleteFature); + + useExternalEvents(featureCollection, { + createFeatures, + updateFeatures, + deleteFeatures + }); + + return null; +} + +function useExternalEvents( + featureCollection: FeatureCollection, + { + createFeatures, + updateFeatures, + deleteFeatures + }: { + createFeatures: CreateFeatures; + updateFeatures: UpdateFatures; + deleteFeatures: DeleteFeatures; + } +) { + const fitBounds = useFitBounds(); + + 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: [{ type: 'Feature', 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] + ); + + useEffect(() => { + fitBounds(featureCollection.bbox as LngLatBoundsLike); + }, []); + + useEvent('map:feature:focus', onFeatureFocus); + useEvent('map:feature:create', onFeatureCreate); + useEvent('map:feature:update', onFeatureUpdate); + useEvent('map:feature:delete', onFeatureDelete); +} + +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'] +]; diff --git a/app/javascript/components/MapEditor/components/ImportFileInput.tsx b/app/javascript/components/MapEditor/components/ImportFileInput.tsx new file mode 100644 index 000000000..9abd79c62 --- /dev/null +++ b/app/javascript/components/MapEditor/components/ImportFileInput.tsx @@ -0,0 +1,132 @@ +import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react'; +import type { FeatureCollection } from 'geojson'; +import invariant from 'tiny-invariant'; + +import { readGeoFile } from '../readGeoFile'; +import { generateId } from '../../shared/maplibre/utils'; +import { CreateFeatures, DeleteFeatures } from '../hooks'; + +export function ImportFileInput({ + featureCollection, + createFeatures, + deleteFeatures +}: { + featureCollection: FeatureCollection; + createFeatures: CreateFeatures; + deleteFeatures: DeleteFeatures; +}) { + const { inputs, addInputFile, removeInputFile, onFileChange } = + useImportFiles(featureCollection, { createFeatures, deleteFeatures }); + + return ( +
+ +
+ {inputs.map((input) => ( +
+ onFileChange(e, input.id)} + /> + {input.hasValue && ( + removeInputFile(e, input.id)} + > + )} +
+ ))} +
+
+ ); +} + +type FileInput = { + id: string; + disabled: boolean; + hasValue: boolean; + filename: string; +}; + +function useImportFiles( + featureCollection: FeatureCollection, + { + createFeatures, + deleteFeatures + }: { createFeatures: CreateFeatures; deleteFeatures: DeleteFeatures } +) { + const [inputs, setInputs] = useState([]); + const addInput = useCallback( + (input: FileInput) => { + setInputs((inputs) => [...inputs, input]); + }, + [setInputs] + ); + const removeInput = useCallback( + (inputId: string) => { + setInputs((inputs) => inputs.filter((input) => input.id !== inputId)); + }, + [setInputs] + ); + + const onFileChange = useCallback( + async (event: ChangeEvent, inputId: string) => { + invariant(event.target.files, ''); + 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] + ); + + const addInputFile = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + addInput({ + id: generateId(), + disabled: false, + hasValue: false, + filename: '' + }); + }, + [addInput] + ); + + const removeInputFile = useCallback( + (event: MouseEvent, inputId: string) => { + event.preventDefault(); + const filename = inputs.find((input) => input.id === inputId)?.filename; + const features = featureCollection.features.filter( + (feature) => feature.properties?.filename == filename + ); + deleteFeatures({ features, external: true }); + removeInput(inputId); + }, + [inputs, removeInput, deleteFeatures, featureCollection] + ); + + return { + inputs, + onFileChange, + addInputFile, + removeInputFile + }; +} diff --git a/app/javascript/components/MapEditor/components/PointInput.tsx b/app/javascript/components/MapEditor/components/PointInput.tsx new file mode 100644 index 000000000..22a4f9ea0 --- /dev/null +++ b/app/javascript/components/MapEditor/components/PointInput.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { fire } from '@utils'; +import type { Feature } from 'geojson'; +import { PlusIcon, LocationMarkerIcon } from '@heroicons/react/outline'; +import { useId } from '@reach/auto-id'; +import CoordinateInput from 'react-coordinate-input'; + +import { useFlyTo } from '../../shared/maplibre/hooks'; + +export function PointInput() { + const flyTo = useFlyTo(); + + const inputId = useId(); + const [value, setValue] = useState(''); + const [feature, setFeature] = useState(null); + const getCurrentPosition = () => { + navigator.geolocation && + navigator.geolocation.getCurrentPosition(({ coords }) => { + setValue( + `${coords.latitude.toPrecision(6)}, ${coords.longitude.toPrecision( + 6 + )}` + ); + }); + }; + const addPoint = () => { + if (feature) { + fire(document, 'map:feature:create', feature); + setValue(''); + setFeature(null); + } + }; + + return ( + <> + +
+ {navigator.geolocation ? ( + + ) : null} + { + setValue(value); + if (dd.length) { + const coordinates: [number, number] = [dd[1], dd[0]]; + setFeature({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates + }, + properties: {} + }); + flyTo(17, coordinates); + } else { + setFeature(null); + } + }} + /> + +
+ + ); +} diff --git a/app/javascript/components/MapEditor/hooks.ts b/app/javascript/components/MapEditor/hooks.ts new file mode 100644 index 000000000..bc74f63c3 --- /dev/null +++ b/app/javascript/components/MapEditor/hooks.ts @@ -0,0 +1,208 @@ +import { useState, useCallback, useEffect } from 'react'; +import { getJSON, ajax, fire } from '@utils'; +import type { Feature, FeatureCollection, Geometry } from 'geojson'; + +export const SOURCE_SELECTION_UTILISATEUR = 'selection_utilisateur'; +export const SOURCE_CADASTRE = 'cadastre'; + +export type CreateFeatures = (params: { + features: Feature[]; + source?: string; + external?: true; +}) => void; +export type UpdateFatures = (params: { + features: Feature[]; + source?: string; + external?: true; +}) => void; +export type DeleteFeatures = (params: { + features: Feature[]; + source?: string; + external?: true; +}) => void; + +export function useFeatureCollection( + initialFeatureCollection: FeatureCollection, + { url, enabled = true }: { url: string; enabled: boolean } +) { + const [error, onError] = useError(); + const [featureCollection, setFeatureCollection] = useState( + initialFeatureCollection + ); + const updateFeatureCollection = useCallback< + (callback: (features: Feature[]) => Feature[]) => void + >( + (callback) => { + setFeatureCollection(({ features }) => ({ + type: 'FeatureCollection', + features: callback(features) + })); + ajax({ url, type: 'GET' }) + .then(() => fire(document, 'ds:page:update')) + .catch(() => null); + }, + [url, setFeatureCollection] + ); + + const addFeatures = useCallback( + (features: (Feature & { lid?: string })[], external: boolean) => { + for (const feature of features) { + if (feature.lid) { + fire(document, 'map:internal:draw:setId', { + lid: feature.lid, + id: feature.properties?.id + }); + delete feature.lid; + } + if (external) { + if (feature.properties?.source == SOURCE_SELECTION_UTILISATEUR) { + fire(document, 'map:internal:draw:add', { + feature: { + id: feature.properties.id, + ...feature + } + }); + } else { + fire(document, 'map:internal:cadastre:highlight', { + cid: feature.properties?.cid, + highlight: true + }); + } + } + } + }, + [] + ); + + const removeFeatures = useCallback( + (features: Feature[], external: boolean) => { + if (external) { + for (const feature of features) { + if (feature.properties?.source == SOURCE_SELECTION_UTILISATEUR) { + fire(document, 'map:internal:draw:delete', { id: feature.id }); + } else { + fire(document, 'map:internal:cadastre:highlight', { + cid: feature.properties?.cid, + highlight: false + }); + } + } + } + }, + [] + ); + + const createFeatures = useCallback( + async ({ + features, + source = SOURCE_SELECTION_UTILISATEUR, + external = false + }) => { + if (!enabled) { + return; + } + try { + const newFeatures: Feature[] = []; + 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]); + } catch (error) { + console.error(error); + onError('Le polygone dessiné n’est pas valide.'); + } + }, + [enabled, url, updateFeatureCollection, addFeatures, onError] + ); + + const updateFeatures = useCallback( + async ({ + features, + source = SOURCE_SELECTION_UTILISATEUR, + external = false + }) => { + if (!enabled) { + return; + } + try { + const newFeatures: Feature[] = []; + for (const feature of features) { + const id = feature.properties?.id; + 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, onError] + ); + + const deleteFeatures = useCallback( + async ({ features, external = false }) => { + if (!enabled) { + return; + } + try { + const deletedFeatures = []; + for (const feature of features) { + const id = feature.properties?.id; + 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) + ) + ); + } catch (error) { + console.error(error); + onError('Le polygone n’a pas pu être supprimé.'); + } + }, + [enabled, url, updateFeatureCollection, removeFeatures, onError] + ); + + return { + featureCollection, + error, + createFeatures, + updateFeatures, + deleteFeatures + }; +} + +function useError(): [string | undefined, (message: string) => void] { + const [error, onError] = useState(); + useEffect(() => { + const timer = setTimeout(() => onError(undefined), 5000); + return () => clearTimeout(timer); + }, [error]); + + return [error, onError]; +} diff --git a/app/javascript/components/MapEditor/index.jsx b/app/javascript/components/MapEditor/index.jsx deleted file mode 100644 index 20fa26dd4..000000000 --- a/app/javascript/components/MapEditor/index.jsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import ReactMapboxGl, { ZoomControl } from 'react-mapbox-gl'; -import DrawControl from 'react-mapbox-gl-draw'; -import { - CursorClickIcon, - PlusIcon, - LocationMarkerIcon -} from '@heroicons/react/outline'; -import CoordinateInput from 'react-coordinate-input'; -import { fire } from '@utils'; -import VisuallyHidden from '@reach/visually-hidden'; -import { useId } from '@reach/auto-id'; -import 'mapbox-gl/dist/mapbox-gl.css'; -import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; - -import MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl'; -import { FlashMessage } from '../shared/FlashMessage'; - -import ComboAdresseSearch from '../ComboAdresseSearch'; -import { useMapboxEditor } from './useMapboxEditor'; - -const Mapbox = ReactMapboxGl({}); - -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 { - isSupported, - error, - inputs, - onLoad, - onStyleChange, - onFileChange, - drawRef, - createFeatures, - updateFeatures, - deleteFeatures, - addInputFile, - removeInputFile - } = useMapboxEditor(featureCollection, { - url, - enabled: !preview, - cadastreEnabled - }); - const { style, layers, setStyle, setLayerEnabled, setLayerOpacity } = - useMapStyle(options.layers, { - onStyleChange, - cadastreEnabled - }); - - if (!isSupported) { - return ( -

- Nous ne pouvons pas afficher notre éditeur de carte car il est - imcompatible avec votre navigateur. Nous vous conseillons de le mettre à - jour ou utiliser les dernières versions de Chrome, Firefox ou Safari -

- ); - } - - return ( - <> - {error && } -
-

- Besoin d'aide ?  - - consulter les tutoriels video - -

-
-
- -
- {inputs.map((input) => ( -
- onFileChange(e, input.id)} - /> - {input.hasValue && ( - removeInputFile(e, input.id)} - > - )} -
- ))} -
-
-
- { - setCoords(coordinates); - setZoom([17]); - }} - /> -
- onLoad(map)} - center={coords} - zoom={zoom} - style={style} - containerStyle={{ height: '500px' }} - > - {!cadastreEnabled && ( - - )} - - - {options.layers.includes('cadastres') && ( -
- -
- )} -
- - - ); -} - -function PointInput() { - const inputId = useId(); - const [value, setValue] = useState(''); - const [feature, setFeature] = useState(null); - const getCurrentPosition = () => { - navigator.geolocation && - navigator.geolocation.getCurrentPosition(({ coords }) => { - setValue( - `${coords.latitude.toPrecision(6)}, ${coords.longitude.toPrecision( - 6 - )}` - ); - }); - }; - const addPoint = () => { - if (feature) { - fire(document, 'map:feature:create', feature); - setValue(''); - setFeature(null); - } - }; - - return ( - <> - -
- {navigator.geolocation ? ( - - ) : null} - { - setValue(value); - setFeature( - dd.length - ? { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: dd.reverse() - }, - properties: {} - } - : null - ); - }} - /> - -
- - ); -} - -MapEditor.propTypes = { - featureCollection: PropTypes.shape({ - bbox: PropTypes.array, - features: PropTypes.array - }), - url: PropTypes.string, - preview: PropTypes.bool, - options: PropTypes.shape({ layers: PropTypes.array }) -}; - -export default MapEditor; diff --git a/app/javascript/components/MapEditor/index.tsx b/app/javascript/components/MapEditor/index.tsx new file mode 100644 index 000000000..a84094835 --- /dev/null +++ b/app/javascript/components/MapEditor/index.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { CursorClickIcon } from '@heroicons/react/outline'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; +import type { FeatureCollection } from 'geojson'; + +import { MapLibre } from '../shared/maplibre/MapLibre'; +import { useFeatureCollection } from './hooks'; +import { DrawLayer } from './components/DrawLayer'; +import { CadastreLayer } from './components/CadastreLayer'; +import { AddressInput } from './components/AddressInput'; +import { PointInput } from './components/PointInput'; +import { ImportFileInput } from './components/ImportFileInput'; +import { FlashMessage } from '../shared/FlashMessage'; + +export default function MapEditor({ + featureCollection: initialFeatureCollection, + url, + options, + preview +}: { + featureCollection: FeatureCollection; + url: string; + preview: boolean; + options: { layers: string[] }; +}) { + const [cadastreEnabled, setCadastreEnabled] = useState(false); + + const { featureCollection, error, ...actions } = useFeatureCollection( + initialFeatureCollection, + { url, enabled: !preview } + ); + + return ( + <> +
+

+ Besoin d'aide ?  + + consulter les tutoriels video + +

+
+ {error && } + + + + + } + footer={} + > + + {options.layers.includes('cadastres') ? ( + <> + +
+ +
+ + ) : null} +
+ + ); +} diff --git a/app/javascript/components/MapEditor/readGeoFile.js b/app/javascript/components/MapEditor/readGeoFile.ts similarity index 62% rename from app/javascript/components/MapEditor/readGeoFile.js rename to app/javascript/components/MapEditor/readGeoFile.ts index 59f972f02..d9f66f10f 100644 --- a/app/javascript/components/MapEditor/readGeoFile.js +++ b/app/javascript/components/MapEditor/readGeoFile.ts @@ -1,29 +1,37 @@ import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js'; -import { generateId } from '../shared/mapbox/utils'; +import type { FeatureCollection, Feature } from 'geojson'; -export function readGeoFile(file) { +import { generateId } from '../shared/maplibre/utils'; + +export function readGeoFile(file: 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), - file.name - ); + return new Promise>( + (resolve) => { + reader.onload = (event: FileReaderEventMap['load']) => { + const result = event.target?.result; + const xml = new DOMParser().parseFromString( + result as string, + 'text/xml' + ); + const featureCollection = normalizeFeatureCollection( + isGpxFile ? gpx(xml) : kml(xml), + file.name + ); - resolve(featureCollection); - }; - reader.readAsText(file, 'UTF-8'); - }); + resolve(featureCollection); + }; + reader.readAsText(file, 'UTF-8'); + } + ); } -function normalizeFeatureCollection(featureCollection, filename) { - const features = []; +function normalizeFeatureCollection( + featureCollection: FeatureCollection, + filename: string +) { + const features: Feature[] = []; for (const feature of featureCollection.features) { switch (feature.geometry.type) { case 'MultiPoint': @@ -76,13 +84,13 @@ function normalizeFeatureCollection(featureCollection, filename) { } } - featureCollection.filename = `${generateId()}-${filename}`; + const featureCollectionFilename = `${generateId()}-${filename}`; featureCollection.features = features.map((feature) => ({ ...feature, properties: { ...feature.properties, - filename: featureCollection.filename + filename: featureCollectionFilename } })); - return featureCollection; + return { ...featureCollection, filename: featureCollectionFilename }; } diff --git a/app/javascript/components/MapEditor/useMapboxEditor.js b/app/javascript/components/MapEditor/useMapboxEditor.js deleted file mode 100644 index bc875b344..000000000 --- a/app/javascript/components/MapEditor/useMapboxEditor.js +++ /dev/null @@ -1,548 +0,0 @@ -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]); -} diff --git a/app/javascript/components/MapReader/components/CadastreLayer.tsx b/app/javascript/components/MapReader/components/CadastreLayer.tsx new file mode 100644 index 000000000..243610eb1 --- /dev/null +++ b/app/javascript/components/MapReader/components/CadastreLayer.tsx @@ -0,0 +1,32 @@ +import { useRef } from 'react'; +import type { FeatureCollection } from 'geojson'; + +import { useMapLibre } from '../../shared/maplibre/MapLibre'; +import { useMapEvent } from '../../shared/maplibre/hooks'; +import { filterFeatureCollection } from '../../shared/maplibre/utils'; + +export function CadastreLayer({ + featureCollection +}: { + featureCollection: FeatureCollection; +}) { + const map = useMapLibre(); + const selectedCadastresRef = useRef>(); + + useMapEvent('styledata', () => { + selectedCadastresRef.current = new Set( + filterFeatureCollection(featureCollection, 'cadastre').features.map( + ({ properties }) => properties?.cid + ) + ); + if (selectedCadastresRef.current.size > 0) { + map.setFilter('parcelle-highlighted', [ + 'in', + 'id', + ...selectedCadastresRef.current + ]); + } + }); + + return null; +} diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx new file mode 100644 index 000000000..956a71fc4 --- /dev/null +++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useEffect, useMemo } from 'react'; +import { Popup, LngLatBoundsLike } from 'maplibre-gl'; +import type { Feature, FeatureCollection } from 'geojson'; + +import { useMapLibre } from '../../shared/maplibre/MapLibre'; +import { + useFitBounds, + useEvent, + EventHandler, + useMapEvent +} from '../../shared/maplibre/hooks'; +import { + filterFeatureCollection, + findFeature, + getBounds, + getCenter, + filterFeatureCollectionByGeometryType +} from '../../shared/maplibre/utils'; + +export function GeoJSONLayer({ + featureCollection +}: { + featureCollection: FeatureCollection; +}) { + const map = useMapLibre(); + const popup = useMemo( + () => + new Popup({ + closeButton: false, + closeOnClick: false + }), + [] + ); + + const onMouseEnter = useCallback( + (event) => { + const feature = event.features && event.features[0]; + if (feature?.properties && feature.properties.description) { + const coordinates = getCenter(feature.geometry, event.lngLat); + const description = feature.properties.description; + map.getCanvas().style.cursor = 'pointer'; + popup.setLngLat(coordinates).setHTML(description).addTo(map); + } else { + popup.remove(); + } + }, + [popup] + ); + + const onMouseLeave = useCallback(() => { + map.getCanvas().style.cursor = ''; + popup.remove(); + }, [popup]); + + useExternalEvents(featureCollection); + + const polygons = filterFeatureCollectionByGeometryType( + filterFeatureCollection(featureCollection, 'selection_utilisateur'), + 'Polygon' + ); + const lines = filterFeatureCollectionByGeometryType( + filterFeatureCollection(featureCollection, 'selection_utilisateur'), + 'LineString' + ); + const points = filterFeatureCollectionByGeometryType( + filterFeatureCollection(featureCollection, 'selection_utilisateur'), + 'Point' + ); + + return ( + <> + {polygons.features.map((feature) => ( + + ))} + {lines.features.map((feature) => ( + + ))} + {points.features.map((feature) => ( + + ))} + + ); +} + +function useExternalEvents(featureCollection: FeatureCollection) { + const fitBounds = useFitBounds(); + const onFeatureFocus = useCallback(({ detail }) => { + const { id } = detail; + const feature = findFeature(featureCollection, id); + if (feature) { + fitBounds(getBounds(feature.geometry)); + } + }, []); + + useEffect(() => { + fitBounds(featureCollection.bbox as LngLatBoundsLike); + }, []); + + useEvent('map:feature:focus', onFeatureFocus); +} + +function LineStringLayer({ + feature, + onMouseEnter, + onMouseLeave +}: { + feature: Feature; + onMouseEnter: EventHandler; + onMouseLeave: EventHandler; +}) { + const map = useMapLibre(); + const sourceId = String(feature.properties?.id); + const layerId = `${sourceId}-layer`; + + useEffect(() => { + map + .addSource(sourceId, { + type: 'geojson', + data: feature + }) + .addLayer({ + id: layerId, + source: sourceId, + type: 'line', + paint: lineStringSelectionLine + }); + }, []); + + useMapEvent('mouseenter', onMouseEnter, layerId); + useMapEvent('mouseleave', onMouseLeave, layerId); + + return null; +} + +function PointLayer({ + feature, + onMouseEnter, + onMouseLeave +}: { + feature: Feature; + onMouseEnter: EventHandler; + onMouseLeave: EventHandler; +}) { + const map = useMapLibre(); + const sourceId = String(feature.properties?.id); + const layerId = `${sourceId}-layer`; + + useEffect(() => { + map + .addSource(sourceId, { + type: 'geojson', + data: feature + }) + .addLayer({ + id: layerId, + source: sourceId, + type: 'circle', + paint: pointSelectionCircle + }); + }, []); + + useMapEvent('mouseenter', onMouseEnter, layerId); + useMapEvent('mouseleave', onMouseLeave, layerId); + + return null; +} + +function PolygonLayer({ + feature, + onMouseEnter, + onMouseLeave +}: { + feature: Feature; + onMouseEnter: EventHandler; + onMouseLeave: EventHandler; +}) { + const map = useMapLibre(); + const sourceId = String(feature.properties?.id); + const layerId = `${sourceId}-layer`; + const lineLayerId = `${sourceId}-line-layer`; + + useEffect(() => { + map + .addSource(sourceId, { + type: 'geojson', + data: feature + }) + .addLayer({ + id: lineLayerId, + source: sourceId, + type: 'line', + paint: polygonSelectionLine + }) + .addLayer({ + id: layerId, + source: sourceId, + type: 'fill', + paint: polygonSelectionFill + }); + }, []); + + useMapEvent('mouseenter', onMouseEnter, layerId); + useMapEvent('mouseleave', onMouseLeave, layerId); + + return null; +} + +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 pointSelectionCircle = { + 'circle-color': '#EC3323' +}; diff --git a/app/javascript/components/MapReader/index.jsx b/app/javascript/components/MapReader/index.jsx deleted file mode 100644 index b20dde570..000000000 --- a/app/javascript/components/MapReader/index.jsx +++ /dev/null @@ -1,190 +0,0 @@ -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 MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl'; -import { - filterFeatureCollection, - filterFeatureCollectionByGeometryType -} from '../shared/mapbox/utils'; -import { useMapbox } from './useMapbox'; - -const Mapbox = ReactMapboxGl({}); - -const MapReader = ({ featureCollection, options }) => { - const { isSupported, onLoad, onStyleChange, onMouseEnter, onMouseLeave } = - useMapbox(featureCollection); - const { style, layers, setStyle, setLayerEnabled, setLayerOpacity } = - useMapStyle(options.layers, { onStyleChange }); - - if (!isSupported) { - return ( -

- Nous ne pouvons pas afficher la carte car elle est imcompatible avec - votre navigateur. Nous vous conseillons de le mettre à jour ou utiliser - les dernières versions de Chrome, Firefox ou Safari -

- ); - } - - return ( - onLoad(map)} - style={style} - containerStyle={{ height: '500px' }} - > - - - - - - - - ); -}; - -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 ( - - ); -} - -function SelectionUtilisateurLineLayer({ - featureCollection, - onMouseEnter, - onMouseLeave -}) { - const data = useMemo( - () => - filterFeatureCollectionByGeometryType( - filterFeatureCollection(featureCollection, 'selection_utilisateur'), - 'LineString' - ), - [featureCollection] - ); - return ( - - ); -} - -function SelectionUtilisateurPointLayer({ - featureCollection, - onMouseEnter, - onMouseLeave -}) { - const data = useMemo( - () => - filterFeatureCollectionByGeometryType( - filterFeatureCollection(featureCollection, 'selection_utilisateur'), - 'Point' - ), - [featureCollection] - ); - return ( - - ); -} - -SelectionUtilisateurPolygonLayer.propTypes = { - featureCollection: PropTypes.shape({ - type: PropTypes.string, - bbox: PropTypes.array, - features: PropTypes.array - }), - 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; diff --git a/app/javascript/components/MapReader/index.tsx b/app/javascript/components/MapReader/index.tsx new file mode 100644 index 000000000..1f9fb07d4 --- /dev/null +++ b/app/javascript/components/MapReader/index.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { FeatureCollection } from 'geojson'; + +import { MapLibre } from '../shared/maplibre/MapLibre'; +import { CadastreLayer } from './components/CadastreLayer'; +import { GeoJSONLayer } from './components/GeoJSONLayer'; + +const MapReader = ({ + featureCollection, + options +}: { + featureCollection: FeatureCollection; + options: { layers: string[] }; +}) => { + return ( + + + + + ); +}; + +export default MapReader; diff --git a/app/javascript/components/MapReader/useMapbox.js b/app/javascript/components/MapReader/useMapbox.js deleted file mode 100644 index d58b4e463..000000000 --- a/app/javascript/components/MapReader/useMapbox.js +++ /dev/null @@ -1,104 +0,0 @@ -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]); -} diff --git a/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.jsx b/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.jsx index bce4e7103..68a83fc9c 100644 --- a/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.jsx +++ b/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.jsx @@ -82,7 +82,7 @@ const TypeDeChamp = sortableElement( }} > - Supprimer + Supprimer diff --git a/app/javascript/components/shared/FlashMessage.jsx b/app/javascript/components/shared/FlashMessage.tsx similarity index 57% rename from app/javascript/components/shared/FlashMessage.jsx rename to app/javascript/components/shared/FlashMessage.tsx index 027b1c709..6a092ff60 100644 --- a/app/javascript/components/shared/FlashMessage.jsx +++ b/app/javascript/components/shared/FlashMessage.tsx @@ -1,17 +1,26 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import PropTypes from 'prop-types'; -export function FlashMessage({ message, level, sticky, fixed }) { +export function FlashMessage({ + message, + level, + sticky, + fixed +}: { + message: string; + level: string; + sticky?: boolean; + fixed?: boolean; +}) { return createPortal(
{message}
, - document.getElementById('flash_messages') + document.getElementById('flash_messages')! ); } -function flashClassName(level, sticky = false, fixed = false) { +function flashClassName(level: string, sticky = false, fixed = false) { const className = level == 'notice' ? ['alert', 'alert-success'] : ['alert', 'alert-danger']; @@ -23,10 +32,3 @@ function flashClassName(level, sticky = false, fixed = false) { } return className.join(' '); } - -FlashMessage.propTypes = { - message: PropTypes.string, - level: PropTypes.string, - sticky: PropTypes.bool, - fixed: PropTypes.bool -}; diff --git a/app/javascript/components/shared/hooks.js b/app/javascript/components/shared/hooks.ts similarity index 65% rename from app/javascript/components/shared/hooks.js rename to app/javascript/components/shared/hooks.ts index a07191d75..483d5477f 100644 --- a/app/javascript/components/shared/hooks.js +++ b/app/javascript/components/shared/hooks.ts @@ -1,15 +1,18 @@ import { useRef, useCallback, useMemo, useState } from 'react'; import { fire } from '@utils'; -export function useDeferredSubmit(input) { +export function useDeferredSubmit(input?: HTMLInputElement): { + (callback: () => void): void; + done: () => void; +} { const calledRef = useRef(false); const awaitFormSubmit = useCallback( - (callback) => { + (callback: () => void) => { const form = input?.form; if (!form) { return; } - const interceptFormSubmit = (event) => { + const interceptFormSubmit = (event: Event) => { event.preventDefault(); runCallback(); form.submit(); @@ -27,17 +30,24 @@ export function useDeferredSubmit(input) { }, [input] ); - awaitFormSubmit.done = () => { + const done = () => { calledRef.current = true; }; - return awaitFormSubmit; + return Object.assign(awaitFormSubmit, { done }); } -export function groupId(id) { +export function groupId(id: string) { return `#champ-${id.replace(/-input$/, '')}`; } -export function useHiddenField(group, name = 'value') { +export function useHiddenField( + group?: string, + name = 'value' +): [ + value: string | undefined, + setValue: (value: string) => void, + input: HTMLInputElement | undefined +] { const hiddenField = useMemo( () => selectInputInGroup(group, name), [group, name] @@ -53,13 +63,16 @@ export function useHiddenField(group, name = 'value') { fire(hiddenField, 'autosave:trigger'); } }, - hiddenField + hiddenField ?? undefined ]; } -function selectInputInGroup(group, name) { +function selectInputInGroup( + group: string | undefined, + name: string +): HTMLInputElement | undefined | null { if (group) { - return document.querySelector( + return document.querySelector( `${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]` ); } diff --git a/app/javascript/components/shared/mapbox/utils.js b/app/javascript/components/shared/mapbox/utils.js deleted file mode 100644 index 721a0bfb5..000000000 --- a/app/javascript/components/shared/mapbox/utils.js +++ /dev/null @@ -1,72 +0,0 @@ -import { LngLatBounds } from 'mapbox-gl'; - -export function getBounds(geometry) { - const bbox = new LngLatBounds(); - - if (geometry.type === 'Point') { - return [geometry.coordinates, geometry.coordinates]; - } else if (geometry.type === 'LineString') { - for (const coordinate of geometry.coordinates) { - bbox.extend(coordinate); - } - } else { - for (const coordinate of geometry.coordinates[0]) { - bbox.extend(coordinate); - } - } - return bbox; -} - -export function findFeature(featureCollection, value, property = 'id') { - return featureCollection.features.find( - (feature) => feature.properties[property] === value - ); -} - -export function filterFeatureCollection(featureCollection, source) { - return { - type: 'FeatureCollection', - features: featureCollection.features.filter( - (feature) => feature.properties.source === source - ) - }; -} - -export function filterFeatureCollectionByGeometryType(featureCollection, type) { - return { - type: 'FeatureCollection', - features: featureCollection.features.filter( - (feature) => feature.geometry.type === type - ) - }; -} - -export function generateId() { - return Math.random().toString(20).substr(2, 6); -} - -export function getCenter(geometry, lngLat) { - const bbox = new LngLatBounds(); - - switch (geometry.type) { - case 'Point': - return [...geometry.coordinates]; - case 'LineString': - return [lngLat.lng, lngLat.lat]; - default: - for (const coordinate of geometry.coordinates[0]) { - bbox.extend(coordinate); - } - 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; -} diff --git a/app/javascript/components/shared/maplibre/MapLibre.tsx b/app/javascript/components/shared/maplibre/MapLibre.tsx new file mode 100644 index 000000000..b123733b2 --- /dev/null +++ b/app/javascript/components/shared/maplibre/MapLibre.tsx @@ -0,0 +1,104 @@ +import React, { + useState, + useContext, + useRef, + useEffect, + useMemo, + ReactNode, + createContext +} from 'react'; +import maplibre, { Map, Style, NavigationControl } from 'maplibre-gl'; + +import invariant from 'tiny-invariant'; + +import { useStyle } from './hooks'; +import { StyleControl } from './StyleControl'; + +const Context = createContext<{ map?: Map | null }>({}); + +type MapLibreProps = { + layers: string[]; + header?: ReactNode; + footer?: ReactNode; + children: ReactNode; +}; + +export function useMapLibre() { + const context = useContext(Context); + invariant(context.map, 'Maplibre not initialized'); + return context.map; +} + +export function MapLibre({ children, header, footer, layers }: MapLibreProps) { + const isSupported = useMemo( + () => maplibre.supported({ failIfMajorPerformanceCaveat: true }) && !isIE(), + [] + ); + const containerRef = useRef(null); + const [map, setMap] = useState(); + + const onStyleChange = (style: Style) => { + if (map) { + map.setStyle(style); + } + }; + const { style, ...mapStyleProps } = useStyle(layers, onStyleChange); + + useEffect(() => { + if (isSupported && !map) { + invariant(containerRef.current, 'Map container not found'); + const map = new Map({ + container: containerRef.current, + style + }); + map.addControl(new NavigationControl({}), 'top-right'); + map.on('load', () => { + setMap(map); + }); + } + }, []); + + if (!isSupported) { + return ( +
+
+
⚠️
+
+ Nous ne pouvons pas afficher la carte car elle est imcompatible avec + votre navigateur. Nous vous conseillons de le mettre à jour ou + d’utiliser{' '} + + un navigateur plus récent + + . +
+
+
+ ); + } + + return ( + + {map ? header : null} +
+ + {map ? children : null} +
+ {map ? footer : null} +
+ ); +} + +function isIE() { + const ua = window.navigator.userAgent; + const msie = ua.indexOf('MSIE '); + const trident = ua.indexOf('Trident/'); + return msie > 0 || trident > 0; +} diff --git a/app/javascript/components/shared/mapbox/MapStyleControl.jsx b/app/javascript/components/shared/maplibre/StyleControl.tsx similarity index 64% rename from app/javascript/components/shared/mapbox/MapStyleControl.jsx rename to app/javascript/components/shared/maplibre/StyleControl.tsx index f6bc98a04..d959e41d4 100644 --- a/app/javascript/components/shared/mapbox/MapStyleControl.jsx +++ b/app/javascript/components/shared/maplibre/StyleControl.tsx @@ -1,5 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; +import React, { useState } from 'react'; import { Popover, RadioGroup } from '@headlessui/react'; import { usePopper } from 'react-popper'; import { MapIcon } from '@heroicons/react/outline'; @@ -7,7 +6,7 @@ import { Slider } from '@reach/slider'; import { useId } from '@reach/auto-id'; import '@reach/slider/styles.css'; -import { getMapStyle, getLayerName, NBS } from './styles'; +import { LayersMap, NBS } from './styles'; const STYLES = { ortho: 'Satellite', @@ -15,68 +14,22 @@ const STYLES = { ign: 'Carte IGN' }; -function optionalLayersMap(optionalLayers) { - return Object.fromEntries( - optionalLayers.map((layer) => [ - layer, - { - configurable: layer != 'cadastres', - enabled: true, - opacity: 70, - name: getLayerName(layer) - } - ]) - ); -} - -export function useMapStyle( - optionalLayers, - { onStyleChange, cadastreEnabled } -) { - const [styleId, setStyle] = useState('ortho'); - const [layers, setLayers] = useState(() => optionalLayersMap(optionalLayers)); - const setLayerEnabled = (layer, enabled) => - setLayers((optionalLayers) => { - optionalLayers[layer].enabled = enabled; - return { ...optionalLayers }; - }); - const setLayerOpacity = (layer, opacity) => - setLayers((optionalLayers) => { - optionalLayers[layer].opacity = opacity; - return { ...optionalLayers }; - }); - const enabledLayers = Object.entries(layers).filter( - ([, { enabled }]) => enabled - ); - const layerIds = enabledLayers.map( - ([layer, { opacity }]) => `${layer}-${opacity}` - ); - const style = useMemo( - () => - getMapStyle( - styleId, - enabledLayers.map(([layer]) => layer), - Object.fromEntries( - enabledLayers.map(([layer, { opacity }]) => [layer, opacity]) - ) - ), - [styleId, layerIds] - ); - - useEffect(() => onStyleChange(), [styleId, layerIds, cadastreEnabled]); - - return { style, layers, setStyle, setLayerEnabled, setLayerOpacity }; -} - -function MapStyleControl({ - style, +export function StyleControl({ + styleId, layers, setStyle, setLayerEnabled, setLayerOpacity +}: { + styleId: string; + setStyle: (style: string) => void; + layers: LayersMap; + setLayerEnabled: (layer: string, enabled: boolean) => void; + setLayerOpacity: (layer: string, opacity: number) => void; }) { - const [buttonElement, setButtonElement] = useState(); - const [panelElement, setPanelElement] = useState(); + const [buttonElement, setButtonElement] = + useState(); + const [panelElement, setPanelElement] = useState(); const { styles, attributes } = usePopper(buttonElement, panelElement, { placement: 'bottom-end' }); @@ -86,7 +39,10 @@ function MapStyleControl({ const mapId = useId(); return ( -
+
); } - -MapStyleControl.propTypes = { - style: PropTypes.string, - layers: PropTypes.object, - setStyle: PropTypes.func, - setLayerEnabled: PropTypes.func, - setLayerOpacity: PropTypes.func -}; - -export default MapStyleControl; diff --git a/app/javascript/components/shared/maplibre/hooks.ts b/app/javascript/components/shared/maplibre/hooks.ts new file mode 100644 index 000000000..dc6aaef97 --- /dev/null +++ b/app/javascript/components/shared/maplibre/hooks.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState, useMemo } from 'react'; +import type { + LngLatBoundsLike, + LngLat, + MapLayerEventType, + Style +} from 'maplibre-gl'; +import type { Feature, Geometry } from 'geojson'; + +import { getMapStyle, getLayerName, LayersMap } from './styles'; +import { useMapLibre } from './MapLibre'; + +export function useFitBounds() { + const map = useMapLibre(); + return useCallback((bbox: LngLatBoundsLike) => { + map.fitBounds(bbox, { padding: 100 }); + }, []); +} + +export function useFlyTo() { + const map = useMapLibre(); + return useCallback((zoom: number, center: [number, number]) => { + map.flyTo({ zoom, center }); + }, []); +} + +export function useEvent(eventName: string, callback: EventListener) { + return useEffect(() => { + addEventListener(eventName, callback); + return () => removeEventListener(eventName, callback); + }, [eventName, callback]); +} + +export type EventHandler = (event: { + features: Feature[]; + lngLat: LngLat; +}) => void; + +export function useMapEvent( + eventName: string, + callback: EventHandler, + target?: string +) { + const map = useMapLibre(); + return useEffect(() => { + if (target) { + map.on(eventName as keyof MapLayerEventType, target, callback as any); + } else { + map.on(eventName, callback); + } + return () => { + if (target) { + map.off(eventName as keyof MapLayerEventType, target, callback as any); + } else { + map.off(eventName, callback); + } + }; + }, [map, eventName, target, callback]); +} + +function optionalLayersMap(optionalLayers: string[]): LayersMap { + return Object.fromEntries( + optionalLayers.map((layer) => [ + layer, + { + configurable: layer != 'cadastres', + enabled: true, + opacity: 70, + name: getLayerName(layer) + } + ]) + ); +} + +export function useStyle( + optionalLayers: string[], + onStyleChange: (style: Style) => void +) { + const [styleId, setStyle] = useState('ortho'); + const [layers, setLayers] = useState(() => optionalLayersMap(optionalLayers)); + const setLayerEnabled = (layer: string, enabled: boolean) => + setLayers((optionalLayers) => { + optionalLayers[layer].enabled = enabled; + return { ...optionalLayers }; + }); + const setLayerOpacity = (layer: string, opacity: number) => + setLayers((optionalLayers) => { + optionalLayers[layer].opacity = opacity; + return { ...optionalLayers }; + }); + const enabledLayers = useMemo( + () => Object.entries(layers).filter(([, { enabled }]) => enabled), + [layers] + ); + const style = useMemo( + () => + getMapStyle( + styleId, + enabledLayers.map(([layer]) => layer), + Object.fromEntries( + enabledLayers.map(([layer, { opacity }]) => [layer, opacity]) + ) + ), + [styleId, enabledLayers] + ); + + useEffect(() => onStyleChange(style), [style]); + + return { style, layers, setStyle, setLayerEnabled, setLayerOpacity }; +} diff --git a/app/javascript/components/shared/mapbox/styles/base.js b/app/javascript/components/shared/maplibre/styles/base.ts similarity index 85% rename from app/javascript/components/shared/mapbox/styles/base.js rename to app/javascript/components/shared/maplibre/styles/base.ts index 8e7ecd0e1..cc7c880ee 100644 --- a/app/javascript/components/shared/mapbox/styles/base.js +++ b/app/javascript/components/shared/maplibre/styles/base.ts @@ -1,8 +1,11 @@ +import type { AnyLayer, Style, RasterLayer, RasterSource } from 'maplibre-gl'; +import invariant from 'tiny-invariant'; + import cadastreLayers from './layers/cadastre'; -const IGN_TOKEN = 'rc1egnbeoss72hxvd143tbyk'; +const IGN_TOKEN = 'rc1egnbeoss72hxvd143tbyk'; // ggignore -function ignServiceURL(layer, format = 'image/png') { +function ignServiceURL(layer: string, format = 'image/png') { const url = `https://wxs.ign.fr/${IGN_TOKEN}/geoportail/wmts`; const query = 'service=WMTS&request=GetTile&version=1.0.0&tilematrixset=PM&tilematrix={z}&tilecol={x}&tilerow={y}&style=normal'; @@ -10,7 +13,7 @@ function ignServiceURL(layer, format = 'image/png') { return `${url}?${query}&layer=${layer}&format=${format}`; } -const OPTIONAL_LAYERS = [ +const OPTIONAL_LAYERS: { label: string; id: string; layers: string[][] }[] = [ { label: 'UNESCO', id: 'unesco', @@ -127,7 +130,7 @@ function buildSources() { ); } -function rasterSource(tiles, attribution) { +function rasterSource(tiles: string[], attribution: string): RasterSource { return { type: 'raster', tiles, @@ -138,7 +141,7 @@ function rasterSource(tiles, attribution) { }; } -function rasterLayer(source, opacity) { +function rasterLayer(source: string, opacity: number): RasterLayer { return { id: source, source, @@ -147,10 +150,13 @@ function rasterLayer(source, opacity) { }; } -export function buildOptionalLayers(ids, opacity) { +export function buildOptionalLayers( + ids: string[], + opacity: Record +): AnyLayer[] { return OPTIONAL_LAYERS.filter(({ id }) => ids.includes(id)) .flatMap(({ layers, id }) => - layers.map(([, code]) => [code, opacity[id] / 100]) + layers.map(([, code]) => [code, opacity[id] / 100] as const) ) .flatMap(([code, opacity]) => code === 'CADASTRE' @@ -159,16 +165,15 @@ export function buildOptionalLayers(ids, opacity) { ); } -export const NBS = ' '; +export const NBS = ' ' as const; -export function getLayerName(layer) { - return OPTIONAL_LAYERS.find(({ id }) => id == layer).label.replace( - /\s/g, - NBS - ); +export function getLayerName(layer: string): string { + const name = OPTIONAL_LAYERS.find(({ id }) => id == layer); + invariant(name, `Layer "${layer}" not found`); + return name.label.replace(/\s/g, NBS); } -function getLayerCode(code) { +function getLayerCode(code: string) { return code.toLowerCase().replace(/\./g, '-'); } @@ -220,4 +225,4 @@ export default { }, sprite: 'https://openmaptiles.github.io/osm-bright-gl-style/sprite', glyphs: 'https://openmaptiles.geo.data.gouv.fr/fonts/{fontstack}/{range}.pbf' -}; +} as Style; diff --git a/app/javascript/components/shared/mapbox/styles/index.js b/app/javascript/components/shared/maplibre/styles/index.ts similarity index 61% rename from app/javascript/components/shared/mapbox/styles/index.js rename to app/javascript/components/shared/maplibre/styles/index.ts index ed97b2aee..b7d865e29 100644 --- a/app/javascript/components/shared/mapbox/styles/index.js +++ b/app/javascript/components/shared/maplibre/styles/index.ts @@ -1,3 +1,5 @@ +import type { Style } from 'maplibre-gl'; + import baseStyle, { buildOptionalLayers, getLayerName, NBS } from './base'; import orthoStyle from './layers/ortho'; import vectorStyle from './layers/vector'; @@ -5,7 +7,21 @@ import ignLayers from './layers/ign'; export { getLayerName, NBS }; -export function getMapStyle(id, layers, opacity) { +export type LayersMap = Record< + string, + { + configurable: boolean; + enabled: boolean; + opacity: number; + name: string; + } +>; + +export function getMapStyle( + id: string, + layers: string[], + opacity: Record +): Style & { id: string } { const style = { ...baseStyle, id }; switch (id) { @@ -23,7 +39,7 @@ export function getMapStyle(id, layers, opacity) { break; } - style.layers = style.layers.concat(buildOptionalLayers(layers, opacity)); + style.layers = style.layers?.concat(buildOptionalLayers(layers, opacity)); return style; } diff --git a/app/javascript/components/shared/mapbox/styles/layers/cadastre.js b/app/javascript/components/shared/maplibre/styles/layers/cadastre.ts similarity index 95% rename from app/javascript/components/shared/mapbox/styles/layers/cadastre.js rename to app/javascript/components/shared/maplibre/styles/layers/cadastre.ts index 0aed8996c..759504f97 100644 --- a/app/javascript/components/shared/mapbox/styles/layers/cadastre.js +++ b/app/javascript/components/shared/maplibre/styles/layers/cadastre.ts @@ -1,4 +1,6 @@ -export default [ +import { AnyLayer } from 'maplibre-gl'; + +const layers: AnyLayer[] = [ { id: 'batiments-line', type: 'line', @@ -104,3 +106,5 @@ export default [ } } ]; + +export default layers; diff --git a/app/javascript/components/shared/mapbox/styles/layers/ign.js b/app/javascript/components/shared/maplibre/styles/layers/ign.ts similarity index 52% rename from app/javascript/components/shared/mapbox/styles/layers/ign.js rename to app/javascript/components/shared/maplibre/styles/layers/ign.ts index e7e7614a7..545d41911 100644 --- a/app/javascript/components/shared/mapbox/styles/layers/ign.js +++ b/app/javascript/components/shared/maplibre/styles/layers/ign.ts @@ -1,4 +1,6 @@ -export default [ +import type { RasterLayer } from 'maplibre-gl'; + +const layers: RasterLayer[] = [ { id: 'ign', source: 'plan-ign', @@ -6,3 +8,5 @@ export default [ paint: { 'raster-resampling': 'linear' } } ]; + +export default layers; diff --git a/app/javascript/components/shared/mapbox/styles/layers/ortho.js b/app/javascript/components/shared/maplibre/styles/layers/ortho.ts similarity index 99% rename from app/javascript/components/shared/mapbox/styles/layers/ortho.js rename to app/javascript/components/shared/maplibre/styles/layers/ortho.ts index 0e255a426..5423d46ce 100644 --- a/app/javascript/components/shared/mapbox/styles/layers/ortho.js +++ b/app/javascript/components/shared/maplibre/styles/layers/ortho.ts @@ -1,4 +1,6 @@ -export default [ +import type { AnyLayer } from 'maplibre-gl'; + +const layers: AnyLayer[] = [ { id: 'photographies-aeriennes', type: 'raster', @@ -2129,7 +2131,7 @@ export default [ [10, 'point'], [11, 'line'] ] - }, + } as any, 'symbol-spacing': 200, 'text-field': '{ref}', 'text-font': ['Noto Sans Regular'], @@ -2160,7 +2162,7 @@ export default [ [10, 'point'], [11, 'line'] ] - }, + } as any, 'symbol-spacing': 200, 'text-field': '{ref}', 'text-font': ['Noto Sans Regular'], @@ -2262,7 +2264,7 @@ export default [ 'text-letter-spacing': 0, 'icon-padding': 2, 'symbol-placement': 'point', - 'symbol-z-order': 'auto', + 'symbol-z-order': 'auto' as any, 'text-line-height': 1.2, 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -2637,3 +2639,5 @@ export default [ } } ]; + +export default layers; diff --git a/app/javascript/components/shared/mapbox/styles/layers/vector.js b/app/javascript/components/shared/maplibre/styles/layers/vector.ts similarity index 99% rename from app/javascript/components/shared/mapbox/styles/layers/vector.js rename to app/javascript/components/shared/maplibre/styles/layers/vector.ts index d69b812a2..b91bf9cb3 100644 --- a/app/javascript/components/shared/mapbox/styles/layers/vector.js +++ b/app/javascript/components/shared/maplibre/styles/layers/vector.ts @@ -1,4 +1,6 @@ -export default [ +import type { AnyLayer } from 'maplibre-gl'; + +const layers: AnyLayer[] = [ { id: 'background', type: 'background', @@ -113,7 +115,7 @@ export default [ [0, false], [9, true] ] - }, + } as any, 'fill-color': '#6a4', 'fill-opacity': 0.1, 'fill-outline-color': 'hsla(0, 0%, 0%, 0.03)' @@ -324,7 +326,7 @@ export default [ [6, [2, 0]], [8, [0, 0]] ] - } + } as any } }, { @@ -427,7 +429,7 @@ export default [ [14, [0, 0]], [16, [-2, -2]] ] - } + } as any } }, { @@ -2322,7 +2324,7 @@ export default [ [10, 'point'], [11, 'line'] ] - }, + } as any, 'symbol-spacing': 200, 'text-field': '{ref}', 'text-font': ['Noto Sans Regular'], @@ -2354,7 +2356,7 @@ export default [ [7, 'line'], [8, 'line'] ] - }, + } as any, 'symbol-spacing': 200, 'text-field': '{ref}', 'text-font': ['Noto Sans Regular'], @@ -2385,7 +2387,7 @@ export default [ [10, 'point'], [11, 'line'] ] - }, + } as any, 'symbol-spacing': 200, 'text-field': '{ref}', 'text-font': ['Noto Sans Regular'], @@ -2837,3 +2839,5 @@ export default [ } } ]; + +export default layers; diff --git a/app/javascript/components/shared/maplibre/utils.ts b/app/javascript/components/shared/maplibre/utils.ts new file mode 100644 index 000000000..8ab08c181 --- /dev/null +++ b/app/javascript/components/shared/maplibre/utils.ts @@ -0,0 +1,93 @@ +import { + LngLatBounds, + LngLat, + LngLatLike, + LngLatBoundsLike +} from 'maplibre-gl'; +import type { Geometry, FeatureCollection, Feature } from 'geojson'; +import invariant from 'tiny-invariant'; + +export function getBounds(geometry: Geometry): LngLatBoundsLike { + const bbox = new LngLatBounds(); + + if (geometry.type === 'Point') { + return [geometry.coordinates, geometry.coordinates] as [ + [number, number], + [number, number] + ]; + } else if (geometry.type === 'LineString') { + for (const coordinate of geometry.coordinates) { + bbox.extend(coordinate as [number, number]); + } + } else { + invariant( + geometry.type != 'GeometryCollection', + 'GeometryCollection not supported' + ); + for (const coordinate of geometry.coordinates[0]) { + bbox.extend(coordinate as [number, number]); + } + } + return bbox; +} + +export function findFeature( + featureCollection: FeatureCollection, + value: unknown, + property = 'id' +): Feature | null { + return ( + featureCollection.features.find( + (feature) => feature.properties && feature.properties[property] === value + ) ?? null + ); +} + +export function filterFeatureCollection( + featureCollection: FeatureCollection, + source: string +): FeatureCollection { + return { + type: 'FeatureCollection', + features: featureCollection.features.filter( + (feature) => feature.properties?.source === source + ) + }; +} + +export function filterFeatureCollectionByGeometryType( + featureCollection: FeatureCollection, + type: Geometry['type'] +): FeatureCollection { + return { + type: 'FeatureCollection', + features: featureCollection.features.filter( + (feature) => feature.geometry.type === type + ) + }; +} + +export function generateId(): string { + return Math.random().toString(20).substring(2, 6); +} + +export function getCenter(geometry: Geometry, lngLat: LngLat): LngLatLike { + const bbox = new LngLatBounds(); + + invariant( + geometry.type != 'GeometryCollection', + 'GeometryCollection not supported' + ); + + switch (geometry.type) { + case 'Point': + return [...geometry.coordinates] as [number, number]; + case 'LineString': + return [lngLat.lng, lngLat.lat]; + default: + for (const coordinate of geometry.coordinates[0]) { + bbox.extend(coordinate as [number, number]); + } + return bbox.getCenter(); + } +} diff --git a/app/javascript/components/shared/queryClient.js b/app/javascript/components/shared/queryClient.ts similarity index 78% rename from app/javascript/components/shared/queryClient.js rename to app/javascript/components/shared/queryClient.ts index e37e4fb7f..14164f831 100644 --- a/app/javascript/components/shared/queryClient.js +++ b/app/javascript/components/shared/queryClient.ts @@ -1,4 +1,4 @@ -import { QueryClient } from 'react-query'; +import { QueryClient, QueryFunction } from 'react-query'; import { getJSON, isNumeric } from '@utils'; import { matchSorter } from 'match-sorter'; @@ -16,17 +16,15 @@ const API_ADRESSE_QUERY_LIMIT = 5; const API_GEO_COMMUNES_QUERY_LIMIT = 60; const { api_geo_url, api_adresse_url, api_education_url } = - gon.autocomplete || {}; + (window as any).gon.autocomplete || {}; -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - queryFn: defaultQueryFn - } - } -}); +type QueryKey = readonly [ + scope: string, + term: string, + extra: string | undefined +]; -function buildURL(scope, term, extra) { +function buildURL(scope: string, term: string, extra?: string) { term = encodeURIComponent(term.replace(/\(|\)/g, '')); if (scope === 'adresse') { return `${api_adresse_url}/search?q=${term}&limit=${API_ADRESSE_QUERY_LIMIT}`; @@ -48,7 +46,7 @@ function buildURL(scope, term, extra) { return `${api_geo_url}/${scope}?nom=${term}&limit=${API_GEO_QUERY_LIMIT}`; } -function buildOptions() { +function buildOptions(): [RequestInit, AbortController | null] { if (window.AbortController) { const controller = new AbortController(); const signal = controller.signal; @@ -57,7 +55,9 @@ function buildOptions() { return [{}, null]; } -async function defaultQueryFn({ queryKey: [scope, term, extra] }) { +const defaultQueryFn: QueryFunction = async ({ + queryKey: [scope, term, extra] +}) => { if (scope == 'pays') { return matchSorter(await getPays(), term, { keys: ['label'] }); } @@ -70,14 +70,22 @@ async function defaultQueryFn({ queryKey: [scope, term, extra] }) { } throw new Error(`Error fetching from "${scope}" API`); }); - promise.cancel = () => controller && controller.abort(); + (promise as any).cancel = () => controller && controller.abort(); return promise; -} +}; -let paysCache; -async function getPays() { +let paysCache: { label: string }[]; +async function getPays(): Promise<{ label: string }[]> { if (!paysCache) { paysCache = await getJSON('/api/pays', null); } return paysCache; } + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + queryFn: defaultQueryFn as any + } + } +}); diff --git a/app/javascript/shared/polyfills.js b/app/javascript/shared/polyfills.js index d08eb91bf..bcf7ce2b4 100644 --- a/app/javascript/shared/polyfills.js +++ b/app/javascript/shared/polyfills.js @@ -5,6 +5,7 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import 'dom4'; import 'intersection-observer'; +import 'whatwg-fetch'; import './polyfills/insertAdjacentElement'; import './polyfills/dataset'; diff --git a/app/javascript/shared/register-react-components.jsx b/app/javascript/shared/register-react-components.tsx similarity index 75% rename from app/javascript/shared/register-react-components.jsx rename to app/javascript/shared/register-react-components.tsx index 7e628e2d5..48bd43526 100644 --- a/app/javascript/shared/register-react-components.jsx +++ b/app/javascript/shared/register-react-components.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, lazy, createElement } from 'react'; +import React, { Suspense, lazy, createElement, ComponentClass } from 'react'; import { render } from 'react-dom'; // This attribute holds the name of component which should be mounted @@ -13,12 +13,12 @@ const CLASS_NAME_SELECTOR = `[${CLASS_NAME_ATTR}]`; // helper method for the mount and unmount methods to find the // `data-react-class` DOM elements -function findDOMNodes(searchSelector) { +function findDOMNodes(searchSelector?: string): NodeListOf { const [selector, parent] = getSelector(searchSelector); - return parent.querySelectorAll(selector); + return parent.querySelectorAll(selector); } -function getSelector(searchSelector) { +function getSelector(searchSelector?: string): [string, Document] { switch (typeof searchSelector) { case 'undefined': return [CLASS_NAME_SELECTOR, document]; @@ -39,15 +39,15 @@ function getSelector(searchSelector) { class ReactComponentRegistry { #components; - constructor(components) { + constructor(components: Record) { this.#components = components; } - getConstructor(className) { - return this.#components[className]; + getConstructor(className: string | null) { + return className ? this.#components[className] : null; } - mountComponents(searchSelector) { + mountComponents(searchSelector?: string) { const nodes = findDOMNodes(searchSelector); for (const node of nodes) { @@ -76,10 +76,10 @@ class ReactComponentRegistry { const Loader = () =>
; -export function Loadable(loader) { +export function Loadable(loader: () => Promise<{ default: ComponentClass }>) { const LazyComponent = lazy(loader); - return function PureComponent(props) { + return function PureComponent(props: Record) { return ( }> @@ -88,7 +88,9 @@ export function Loadable(loader) { }; } -export function registerReactComponents(components) { +export function registerReactComponents( + components: Record +) { const registry = new ReactComponentRegistry(components); addEventListener('ds:page:update', () => registry.mountComponents()); diff --git a/app/javascript/shared/utils.js b/app/javascript/shared/utils.ts similarity index 60% rename from app/javascript/shared/utils.js rename to app/javascript/shared/utils.ts index 3ac0b7839..4cc346ada 100644 --- a/app/javascript/shared/utils.js +++ b/app/javascript/shared/utils.ts @@ -4,17 +4,17 @@ import debounce from 'debounce'; export { debounce }; export const { fire, csrfToken } = Rails; -export function show(el) { +export function show(el: HTMLElement) { el && el.classList.remove('hidden'); } -export function hide(el) { +export function hide(el: HTMLElement) { el && el.classList.add('hidden'); } -export function toggle(el, force) { +export function toggle(el: HTMLElement, force?: boolean) { if (force == undefined) { - el & el.classList.toggle('hidden'); + el && el.classList.toggle('hidden'); } else if (force) { el && el.classList.remove('hidden'); } else { @@ -22,27 +22,31 @@ export function toggle(el, force) { } } -export function enable(el) { +export function enable(el: HTMLInputElement) { el && (el.disabled = false); } -export function disable(el) { +export function disable(el: HTMLInputElement) { el && (el.disabled = true); } -export function hasClass(el, cssClass) { +export function hasClass(el: HTMLElement, cssClass: string) { return el && el.classList.contains(cssClass); } -export function addClass(el, cssClass) { +export function addClass(el: HTMLElement, cssClass: string) { el && el.classList.add(cssClass); } -export function removeClass(el, cssClass) { +export function removeClass(el: HTMLElement, cssClass: string) { el && el.classList.remove(cssClass); } -export function delegate(eventNames, selector, callback) { +export function delegate( + eventNames: string, + selector: string, + callback: () => void +) { eventNames .split(' ') .forEach((eventName) => @@ -57,15 +61,23 @@ export function delegate(eventNames, selector, callback) { // - rejected with an Error object otherwise. // // See Rails.ajax() code for more details. -export function ajax(options) { +export function ajax(options: Rails.AjaxOptions) { return new Promise((resolve, reject) => { Object.assign(options, { - success: (response, statusText, xhr) => { + success: ( + response: unknown, + statusText: string, + xhr: { status: number } + ) => { resolve({ response, statusText, xhr }); }, - error: (response, statusText, xhr) => { + error: ( + response: unknown, + statusText: string, + xhr: { status: number } + ) => { // NB: on HTTP/2 connections, statusText is always empty. - let error = new Error( + const error = new Error( `Erreur ${xhr.status}` + (statusText ? ` : ${statusText}` : '') ); Object.assign(error, { response, statusText, xhr }); @@ -76,7 +88,7 @@ export function ajax(options) { }); } -export function getJSON(url, data, method = 'GET') { +export function getJSON(url: string, data: unknown, method = 'GET') { const { query, ...options } = fetchOptions(data, method); return fetch(`${url}${query}`, options).then((response) => { @@ -86,32 +98,38 @@ export function getJSON(url, data, method = 'GET') { } return response.json(); } - const error = new Error(response.statusText || response.status); - error.response = response; + const error = new Error(String(response.statusText || response.status)); + (error as any).response = response; throw error; }); } -export function scrollTo(container, scrollTo) { +export function scrollTo(container: HTMLElement, scrollTo: HTMLElement) { container.scrollTop = offset(scrollTo).top - offset(container).top + container.scrollTop; } -export function scrollToBottom(container) { +export function scrollToBottom(container: HTMLElement) { container.scrollTop = container.scrollHeight; } -export function on(selector, eventName, fn) { +export function on( + selector: string, + eventName: string, + fn: (event: Event, detail: unknown) => void +) { [...document.querySelectorAll(selector)].forEach((element) => - element.addEventListener(eventName, (event) => fn(event, event.detail)) + element.addEventListener(eventName, (event) => + fn(event, (event as CustomEvent).detail) + ) ); } -export function isNumeric(n) { - return !isNaN(parseFloat(n)) && isFinite(n); +export function isNumeric(n: string) { + return !isNaN(parseFloat(n)) && isFinite(n as any as number); } -function offset(element) { +function offset(element: HTMLElement) { const rect = element.getBoundingClientRect(); return { top: rect.top + document.body.scrollTop, @@ -120,8 +138,11 @@ function offset(element) { } // Takes a promise, and return a promise that times out after the given delay. -export function timeoutable(promise, timeoutDelay) { - let timeoutPromise = new Promise((resolve, reject) => { +export function timeoutable( + promise: Promise, + timeoutDelay: number +): Promise { + const timeoutPromise = new Promise((resolve, reject) => { setTimeout(() => { reject(new Error(`Promise timed out after ${timeoutDelay}ms`)); }, timeoutDelay); @@ -131,13 +152,16 @@ export function timeoutable(promise, timeoutDelay) { const FETCH_TIMEOUT = 30 * 1000; // 30 sec -function fetchOptions(data, method = 'GET') { - const options = { +function fetchOptions(data: unknown, method = 'GET') { + const options: RequestInit & { + query: string; + headers: Record; + } = { query: '', method: method.toUpperCase(), headers: { accept: 'application/json', - 'x-csrf-token': csrfToken(), + 'x-csrf-token': csrfToken() ?? '', 'x-requested-with': 'XMLHttpRequest' }, credentials: 'same-origin' @@ -145,7 +169,7 @@ function fetchOptions(data, method = 'GET') { if (data) { if (options.method === 'GET') { - options.query = objectToQuerystring(data); + options.query = objectToQuerystring(data as Record); } else { options.headers['content-type'] = 'application/json'; options.body = JSON.stringify(data); @@ -164,7 +188,7 @@ function fetchOptions(data, method = 'GET') { return options; } -function objectToQuerystring(obj) { +function objectToQuerystring(obj: Record): string { return Object.keys(obj).reduce(function (query, key, i) { return [ query, diff --git a/app/javascript/types.d.ts b/app/javascript/types.d.ts new file mode 100644 index 000000000..200e8886e --- /dev/null +++ b/app/javascript/types.d.ts @@ -0,0 +1,23 @@ +// This file contains type definitions for untyped packages. We are lucky to have only two ;) + +declare module '@tmcw/togeojson/dist/togeojson.es.js' { + import { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson'; + + export function kml(doc: Document): FeatureCollection; + + export function kml( + doc: Document + ): FeatureCollection; + + export function gpx(doc: Document): FeatureCollection; + export function gpx( + doc: Document + ): FeatureCollection; + + export function tcx(doc: Document): FeatureCollection; + export function tcx( + doc: Document + ): FeatureCollection; +} + +declare module 'react-coordinate-input'; diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 83a91032a..f80e09aa2 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -17,6 +17,7 @@ # groupe_instructeur_updated_at :datetime # hidden_at :datetime # hidden_by_administration_at :datetime +# hidden_by_reason :string # hidden_by_user_at :datetime # identity_updated_at :datetime # last_avis_updated_at :datetime @@ -761,26 +762,26 @@ class Dossier < ApplicationRecord end def restore_dossier_and_destroy_deleted_dossier(author) - deleted_dossier&.destroy! + if deleted_dossier.present? + deleted_dossier&.destroy! + end + log_dossier_operation(author, :restaurer, self) end def discard_and_keep_track!(author, reason) if termine? && author_is_administration(author) - update(hidden_by_administration_at: Time.zone.now) + update(hidden_by_administration_at: Time.zone.now, hidden_by_reason: reason) end if can_be_hidden_by_user? && author_is_user(author) - update(hidden_by_user_at: Time.zone.now, dossier_transfer_id: nil) + update(hidden_by_user_at: Time.zone.now, dossier_transfer_id: nil, hidden_by_reason: reason) end - deleted_dossier = nil - transaction do if deleted_by_instructeur_and_user? || en_construction? || brouillon? if keep_track_on_deletion? log_dossier_operation(author, :supprimer, self) - deleted_dossier = DeletedDossier.create_from_dossier(self, reason) end if !(en_construction? && author_is_user(author)) @@ -789,12 +790,11 @@ class Dossier < ApplicationRecord end end - if deleted_dossier.present? - if en_construction? - administration_emails = followers_instructeurs.present? ? followers_instructeurs.map(&:email) : procedure.administrateurs.map(&:email) - administration_emails.each do |email| - DossierMailer.notify_deletion_to_administration(deleted_dossier, email).deliver_later - end + if en_construction? + update(hidden_by_reason: reason) + administration_emails = followers_instructeurs.present? ? followers_instructeurs.map(&:email) : procedure.administrateurs.map(&:email) + administration_emails.each do |email| + DossierMailer.notify_en_construction_deletion_to_administration(self, email).deliver_later end end end @@ -813,6 +813,8 @@ class Dossier < ApplicationRecord elsif author_is_user(author) && hidden_by_user? transaction do update(hidden_by_user_at: nil) + !hidden_by_administration? && update(hidden_by_reason: nil) + if en_construction? restore_dossier_and_destroy_deleted_dossier(author) end @@ -820,6 +822,7 @@ class Dossier < ApplicationRecord elsif author_is_administration(author) && hidden_by_administration? transaction do update(hidden_by_administration_at: nil) + !hidden_by_user? && update(hidden_by_reason: nil) end end end @@ -1150,20 +1153,21 @@ class Dossier < ApplicationRecord user&.locale || I18n.default_locale end + def purge_discarded + transaction do + if keep_track_on_deletion? + DeletedDossier.create_from_dossier(self, hidden_by_reason) + end + + dossier_operation_logs.not_deletion.destroy_all + destroy + end + end + def self.purge_discarded - discarded_brouillon_expired.destroy_all - - transaction do - DossierOperationLog.discarded_en_construction_expired.destroy_all - Avis.discarded_en_construction_expired.destroy_all - discarded_en_construction_expired.destroy_all - end - - transaction do - DossierOperationLog.discarded_termine_expired.destroy_all - Avis.discarded_termine_expired.destroy_all - discarded_termine_expired.destroy_all - end + discarded_brouillon_expired.find_each(&:purge_discarded) + discarded_en_construction_expired.find_each(&:purge_discarded) + discarded_termine_expired.find_each(&:purge_discarded) end private diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index b569ee895..8f72114cd 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -235,7 +235,7 @@ class Instructeur < ApplicationRecord COUNT(DISTINCT dossiers.id) FILTER (where not archived AND NOT (dossiers.hidden_by_user_at IS NOT NULL AND state = 'en_construction') AND dossiers.state in ('en_construction', 'en_instruction') AND follows.id IS NULL) AS a_suivre, COUNT(DISTINCT dossiers.id) FILTER (where not archived AND dossiers.state in ('en_construction', 'en_instruction') AND follows.instructeur_id = :instructeur_id) AS suivis, COUNT(DISTINCT dossiers.id) FILTER (where not archived AND dossiers.state in ('accepte', 'refuse', 'sans_suite')) AS traites, - COUNT(DISTINCT dossiers.id) FILTER (where not archived AND NOT (dossiers.hidden_by_user_at IS NOT NULL AND state = 'en_construction')) AS tous, + COUNT(DISTINCT dossiers.id) FILTER (where not archived AND NOT (dossiers.hidden_by_user_at IS NOT NULL AND state = 'en_construction') AND NOT (dossiers.hidden_by_administration_at IS NOT NULL)) AS tous, COUNT(DISTINCT dossiers.id) FILTER (where not archived AND (dossiers.hidden_by_administration_at IS NOT NULL AND dossiers.state in ('accepte', 'refuse', 'sans_suite') )) AS supprimes_recemment, COUNT(DISTINCT dossiers.id) FILTER (where archived) AS archives, COUNT(DISTINCT dossiers.id) FILTER (where diff --git a/babel.config.js b/babel.config.js index 36b1d7ede..f236e2bf6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,9 +1,9 @@ -module.exports = function(api) { - var validEnv = ['development', 'test', 'production'] - var currentEnv = api.env() - var isDevelopmentEnv = api.env('development') - var isProductionEnv = api.env('production') - var isTestEnv = api.env('test') +module.exports = function (api) { + var validEnv = ['development', 'test', 'production']; + var currentEnv = api.env(); + var isDevelopmentEnv = api.env('development'); + var isProductionEnv = api.env('production'); + var isTestEnv = api.env('test'); if (!validEnv.includes(currentEnv)) { throw new Error( @@ -12,7 +12,7 @@ module.exports = function(api) { '"test", and "production". Instead, received: ' + JSON.stringify(currentEnv) + '.' - ) + ); } return { @@ -41,7 +41,8 @@ module.exports = function(api) { development: isDevelopmentEnv || isTestEnv, useBuiltIns: true } - ] + ], + ['@babel/preset-typescript', { allExtensions: true, isTSX: true }] ].filter(Boolean), plugins: [ 'babel-plugin-macros', @@ -92,5 +93,5 @@ module.exports = function(api) { } ] ].filter(Boolean) - } -} + }; +}; diff --git a/config/env.example b/config/env.example index 8f3e332f8..f557df768 100644 --- a/config/env.example +++ b/config/env.example @@ -2,8 +2,8 @@ # # Examples: # * For local development: localhost:3000 -# * For preproduction: staging.ds.organisme.fr -# * For production: ds.organisme.fr +# * For preproduction: staging.ds.example.org +# * For production: ds.example.org APP_HOST="localhost:3000" # Rails key for signing sensitive data @@ -68,7 +68,7 @@ SENTRY_DSN_JS="" # External service: Matomo web analytics MATOMO_ENABLED="disabled" MATOMO_ID="" -MATOMO_HOST="matomo.organisme.fr" +MATOMO_HOST="matomo.example.org" # Default SMTP Provider: Mailjet MAILJET_API_KEY="" diff --git a/config/env.example.optional b/config/env.example.optional index 1c66ecdfd..c91da1971 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -88,7 +88,10 @@ DS_ENV="staging" # API_PARTICULIER_URL="https://particulier.api.gouv.fr/api" # Admins and instructeurs can freely change their email to these domains -# LEGIT_ADMIN_DOMAINS = "domaine_1.com;domaine_2.com" +# LEGIT_ADMIN_DOMAINS = "example.org;example.net" + +# External service: Matomo web analytics +MATOMO_IFRAME_URL="https://matomo.example.org/index.php?module=CoreAdminHome&action=optOut&language=fr&&fontColor=333333&fontSize=16px&fontFamily=Muli" # Instance provider # PROVIDED_BY="la DINUM" diff --git a/config/initializers/urls.rb b/config/initializers/urls.rb index 732c583f2..7c3fcd282 100644 --- a/config/initializers/urls.rb +++ b/config/initializers/urls.rb @@ -43,6 +43,6 @@ FAQ_ERREUR_SIRET_URL = [FAQ_URL, "article", "4-erreur-siret"].join("/") STATUS_PAGE_URL = ENV.fetch("STATUS_PAGE_URL", "https://status.demarches-simplifiees.fr") DEMANDE_INSCRIPTION_ADMIN_PAGE_URL = ENV.fetch("DEMANDE_INSCRIPTION_ADMIN_PAGE_URL", "https://www.demarches-simplifiees.fr/commencer/demande-d-inscription-a-demarches-simplifiees") -MATOMO_IFRAME_URL = "https://stats.data.gouv.fr/index.php?module=CoreAdminHome&action=optOut&language=fr&&fontColor=333333&fontSize=16px&fontFamily=Muli" +MATOMO_IFRAME_URL = ENV.fetch("MATOMO_IFRAME_URL", "https://#{ENV.fetch('MATOMO_HOST', 'stats.data.gouv.fr')}/index.php?module=CoreAdminHome&action=optOut&language=fr&&fontColor=333333&fontSize=16px&fontFamily=Muli") # rubocop:enable DS/ApplicationName diff --git a/config/storage.yml b/config/storage.yml index 99173ad6f..b24cd1613 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -8,7 +8,7 @@ openstack: service: OpenStack container: "<%= ENV['FOG_ACTIVESTORAGE_DIRECTORY'] %>" credentials: - openstack_auth_url: "https://auth.cloud.ovh.net" + openstack_auth_url: "<%= ENV['FOG_OPENSTACK_URL'] %>" openstack_api_key: "<%= ENV['FOG_OPENSTACK_API_KEY'] %>" openstack_username: "<%= ENV['FOG_OPENSTACK_USERNAME'] %>" openstack_region: "<%= ENV['FOG_OPENSTACK_REGION'] %>" diff --git a/config/webpack/environment.js b/config/webpack/environment.js index deb5c4c44..c5e252282 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -24,7 +24,7 @@ if (!Array.isArray(nodeModulesLoader.exclude)) { nodeModulesLoader.exclude == null ? [] : [nodeModulesLoader.exclude]; } nodeModulesLoader.exclude.push( - path.resolve(__dirname, '..', '..', 'node_modules/mapbox-gl') + path.resolve(__dirname, '..', '..', 'node_modules/maplibre-gl') ); // Uncoment next lines to run webpack-bundle-analyzer diff --git a/config/webpacker.yml b/config/webpacker.yml index 56183996a..3991058f3 100644 --- a/config/webpacker.yml +++ b/config/webpacker.yml @@ -33,6 +33,8 @@ default: &default - .woff2 extensions: + - .tsx + - .ts - .mjs - .js - .jsx diff --git a/db/migrate/20220204093401_add_hidden_by_reason_to_dossiers.rb b/db/migrate/20220204093401_add_hidden_by_reason_to_dossiers.rb new file mode 100644 index 000000000..0cfd78aa0 --- /dev/null +++ b/db/migrate/20220204093401_add_hidden_by_reason_to_dossiers.rb @@ -0,0 +1,5 @@ +class AddHiddenByReasonToDossiers < ActiveRecord::Migration[6.1] + def change + add_column :dossiers, :hidden_by_reason, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 31cc440d2..d90acf3a6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_01_28_135056) do +ActiveRecord::Schema.define(version: 2022_02_04_093401) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -324,6 +324,7 @@ ActiveRecord::Schema.define(version: 2022_01_28_135056) do t.datetime "identity_updated_at" t.datetime "depose_at" t.datetime "hidden_by_user_at" + t.string "hidden_by_reason" t.index "to_tsvector('french'::regconfig, (search_terms || private_search_terms))", name: "index_dossiers_on_search_terms_private_search_terms", using: :gin t.index "to_tsvector('french'::regconfig, search_terms)", name: "index_dossiers_on_search_terms", using: :gin t.datetime "hidden_by_administration_at" diff --git a/lib/tasks/lint.rake b/lib/tasks/lint.rake index 05742b96a..cfdb3f906 100644 --- a/lib/tasks/lint.rake +++ b/lib/tasks/lint.rake @@ -7,4 +7,5 @@ task :lint do sh "bundle exec i18n-tasks check-consistent-interpolations" sh "bundle exec brakeman --no-pager" sh "yarn lint:js" + sh "yarn lint:types" end diff --git a/package.json b/package.json index 7ff9df2bf..a65a2247b 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "dependencies": { "@babel/preset-react": "^7.14.5", + "@babel/preset-typescript": "^7.16.7", "@headlessui/react": "^1.3.0", "@heroicons/react": "^1.0.1", - "@mapbox/mapbox-gl-draw": "^1.2.2", + "@mapbox/mapbox-gl-draw": "^1.3.0", "@popperjs/core": "^2.9.2", "@rails/actiontext": "^6.1.4-1", "@rails/activestorage": "^6.1.4-1", @@ -12,7 +13,6 @@ "@reach/auto-id": "^0.16.0", "@reach/combobox": "^0.13.0", "@reach/slider": "^0.15.0", - "@reach/visually-hidden": "^0.15.2", "@sentry/browser": "6.12.0", "@tmcw/togeojson": "^4.3.0", "babel-plugin-macros": "^2.8.0", @@ -22,18 +22,17 @@ "debounce": "^1.2.1", "dom4": "^2.1.6", "email-butler": "^1.0.13", + "geojson": "^0.5.0", "highcharts": "^9.0.0", "intersection-observer": "^0.12.0", "is-hotkey": "^0.2.0", - "mapbox-gl": "^1.3.0", + "maplibre-gl": "^1.15.2", "match-sorter": "^6.2.0", "prop-types": "^15.7.2", "react": "^17.0.1", - "react-coordinate-input": "^1.0.0-rc.2", + "react-coordinate-input": "^1.0.0", "react-dom": "^17.0.1", "react-intersection-observer": "^8.31.0", - "react-mapbox-gl": "^5.1.1", - "react-mapbox-gl-draw": "^2.0.4", "react-popper": "^2.2.5", "react-query": "^3.9.7", "react-sortable-hoc": "^1.11.0", @@ -46,6 +45,14 @@ }, "devDependencies": { "@2fd/graphdoc": "^2.4.0", + "@types/debounce": "^1.2.1", + "@types/geojson": "^7946.0.8", + "@types/mapbox__mapbox-gl-draw": "^1.2.3", + "@types/rails__ujs": "^6.0.1", + "@types/react": "^17.0.38", + "@types/react-dom": "^17.0.11", + "@typescript-eslint/eslint-plugin": "^5.8.1", + "@typescript-eslint/parser": "^5.8.1", "babel-eslint": "^10.1.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", @@ -54,12 +61,14 @@ "eslint-plugin-react-hooks": "^4.2.0", "netlify-cli": "^8.3.0", "prettier": "^2.3.2", + "typescript": "^4.5.5", "webpack-bundle-analyzer": "^3.7.0", "webpack-dev-server": "^4.6.0" }, "scripts": { "lint:js": "eslint --ext .js,.jsx,.ts,.tsx ./app/javascript ./config/webpack", "webpack:build": "NODE_ENV=production bin/webpack", + "lint:types": "tsc", "graphql:docs:build": "graphdoc --force", "graphql:docs:deploy": "netlify deploy -d ./docs/graphql --prod", "graphql:docs:publish": "yarn graphql:docs:build && yarn graphql:docs:deploy" diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 8cde2714c..d5529f59a 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -774,11 +774,8 @@ describe Instructeurs::DossiersController, type: :controller do expect(DossierOperationLog.where(dossier_id: dossier.id).last.operation).to eq('supprimer') end - it 'add a record into deleted_dossiers table' do - expect(DeletedDossier.where(dossier_id: dossier.id).count).to eq(1) - expect(DeletedDossier.where(dossier_id: dossier.id).first.revision_id).to eq(dossier.revision_id) - expect(DeletedDossier.where(dossier_id: dossier.id).first.user_id).to eq(dossier.user_id) - expect(DeletedDossier.where(dossier_id: dossier.id).first.groupe_instructeur_id).to eq(dossier.groupe_instructeur_id) + it 'does not add a record into deleted_dossiers table' do + expect(DeletedDossier.where(dossier_id: dossier.id).count).to eq(0) end it 'discard the dossier' do @@ -804,6 +801,11 @@ describe Instructeurs::DossiersController, type: :controller do it 'does not discard the dossier' do expect(dossier.reload.hidden_at).to eq(nil) end + + it 'fill hidden by reason' do + expect(dossier.reload.hidden_by_reason).not_to eq(nil) + expect(dossier.reload.hidden_by_reason).to eq("instructeur_request") + end end context 'when the instructeur want to delete a dossier without a decision' do diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 855e64790..9020996e6 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -1007,7 +1007,7 @@ describe Users::DossiersController, type: :controller do shared_examples_for "the dossier can not be deleted" do it "doesn’t notify the deletion" do - expect(DossierMailer).not_to receive(:notify_deletion_to_administration) + expect(DossierMailer).not_to receive(:notify_en_construction_deletion_to_administration) subject end @@ -1022,17 +1022,23 @@ describe Users::DossiersController, type: :controller do let(:dossier) { create(:dossier, :en_construction, user: user, autorisation_donnees: true) } it "notifies the user and the admin of the deletion" do - expect(DossierMailer).to receive(:notify_deletion_to_administration).with(kind_of(DeletedDossier), dossier.procedure.administrateurs.first.email).and_return(double(deliver_later: nil)) + expect(DossierMailer).to receive(:notify_en_construction_deletion_to_administration).with(kind_of(Dossier), dossier.procedure.administrateurs.first.email).and_return(double(deliver_later: nil)) subject end - it "hide the dossier and create a deleted dossier" do + it "hide the dossier and does not create a deleted dossier" do procedure = dossier.procedure dossier_id = dossier.id subject expect(Dossier.find_by(id: dossier_id)).to be_present expect(Dossier.find_by(id: dossier_id).hidden_by_user_at).to be_present - expect(procedure.deleted_dossiers.count).to eq(1) + expect(procedure.deleted_dossiers.count).to eq(0) + end + + it "fill hidden by reason" do + subject + expect(dossier.reload.hidden_by_reason).not_to eq(nil) + expect(dossier.reload.hidden_by_reason).to eq("user_request") end it { is_expected.to redirect_to(dossiers_path) } diff --git a/spec/jobs/cron/discarded_dossiers_deletion_job_spec.rb b/spec/jobs/cron/discarded_dossiers_deletion_job_spec.rb index c938414ff..603da2fd4 100644 --- a/spec/jobs/cron/discarded_dossiers_deletion_job_spec.rb +++ b/spec/jobs/cron/discarded_dossiers_deletion_job_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Cron::DiscardedDossiersDeletionJob, type: :job do dossier.send(:log_dossier_operation, instructeur, :passer_en_instruction, dossier) dossier.send(:log_dossier_operation, instructeur, :supprimer, dossier) dossier.update_column(:hidden_at, hidden_at) + dossier.update_column(:hidden_by_reason, "user_request") Cron::DiscardedDossiersDeletionJob.perform_now end @@ -42,7 +43,6 @@ RSpec.describe Cron::DiscardedDossiersDeletionJob, type: :job do context 'not hidden' do let(:hidden_at) { nil } - include_examples "does not delete" end @@ -60,7 +60,6 @@ RSpec.describe Cron::DiscardedDossiersDeletionJob, type: :job do context 'hidden long ago' do let(:hidden_at) { 1.week.ago - 1.hour } - include_examples "does delete" end end diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 62bee0e85..6026e6b9e 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -794,7 +794,6 @@ describe Dossier do describe "#discard_and_keep_track!" do let(:dossier) { create(:dossier, :en_construction) } let(:user) { dossier.user } - let(:deleted_dossier) { DeletedDossier.find_by(dossier_id: dossier.id) } let(:last_operation) { dossier.dossier_operation_logs.last } let(:reason) { :user_request } @@ -811,10 +810,6 @@ describe Dossier do expect(dossier.discarded?).to be_truthy end - it 'do not creates a DeletedDossier record' do - expect(deleted_dossier).to be_nil - end - it 'do not records the operation in the log' do expect(last_operation).to be_nil end @@ -826,14 +821,6 @@ describe Dossier do expect(dossier.hidden_by_user_at).to be_present end - it 'creates a DeletedDossier record' do - expect(deleted_dossier.reason).to eq DeletedDossier.reasons.fetch(reason) - expect(deleted_dossier.dossier_id).to eq dossier.id - expect(deleted_dossier.procedure).to eq dossier.procedure - expect(deleted_dossier.state).to eq dossier.state - expect(deleted_dossier.deleted_at).to be_present - end - it 'records the operation in the log' do expect(last_operation.operation).to eq("supprimer") expect(last_operation.automatic_operation?).to be_falsey @@ -846,19 +833,6 @@ describe Dossier do non_following_instructeur.groupe_instructeurs << dossier.procedure.defaut_groupe_instructeur non_following_instructeur end - - it 'notifies the following instructeurs' do - expect(DossierMailer).to have_received(:notify_deletion_to_administration).once - expect(DossierMailer).to have_received(:notify_deletion_to_administration).with(deleted_dossier, dossier.followers_instructeurs.first.email) - end - end - - context 'when there are no following instructeurs' do - let(:dossier) { create(:dossier, :en_construction) } - it 'notifies the procedure administrateur' do - expect(DossierMailer).to have_received(:notify_deletion_to_administration).once - expect(DossierMailer).to have_received(:notify_deletion_to_administration).with(deleted_dossier, dossier.procedure.administrateurs.first.email) - end end context 'when dossier is brouillon' do @@ -894,6 +868,19 @@ describe Dossier do end end end + + context 'termine' do + let(:dossier) { create(:dossier, state: "accepte", hidden_by_administration_at: 1.hour.ago) } + before { subject } + + it 'affect the right deletion reason to the dossier' do + expect(dossier.hidden_by_reason).to eq("user_request") + end + + it 'discard the dossier' do + expect(dossier.discarded?).to be_truthy + end + end end describe 'webhook' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e7fb8669d..c008fb065 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -310,7 +310,7 @@ describe User, type: :model do it "keep track of dossiers and delete user" do user.delete_and_keep_track_dossiers(super_admin) - expect(DeletedDossier.find_by(dossier_id: dossier_en_construction)).to be_present + expect(DeletedDossier.find_by(dossier_id: dossier_en_construction)).to be_nil expect(DeletedDossier.find_by(dossier_id: dossier_brouillon)).to be_nil expect(User.find_by(id: user.id)).to be_nil end @@ -324,7 +324,7 @@ describe User, type: :model do dossier_to_discard.discard_and_keep_track!(super_admin, :user_request) user.delete_and_keep_track_dossiers(super_admin) - expect(DeletedDossier.find_by(dossier_id: dossier_en_construction)).to be_present + expect(DeletedDossier.find_by(dossier_id: dossier_en_construction)).to be_nil expect(DeletedDossier.find_by(dossier_id: dossier_brouillon)).to be_nil expect(Dossier.find_by(id: dossier_from_another_user.id)).to be_present expect(User.find_by(id: user.id)).to be_nil diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..6995789e9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "declaration": false, + "experimentalDecorators": true, + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "module": "es6", + "moduleResolution": "node", + "sourceMap": true, + "target": "ES2019", + "jsx": "react", + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true, + "paths": { + "~/*": ["./app/javascript/*"], + "@utils": ["./app/javascript/shared/utils.ts"] + } + }, + "exclude": [ + "**/*.spec.ts", + "node_modules", + "vendor", + "public" + ], + "compileOnSave": false +} diff --git a/yarn.lock b/yarn.lock index 610630463..c2b08ad84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,6 +38,13 @@ dependencies: "@babel/highlight" "^7.16.0" +"@babel/code-frame@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789" + integrity sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg== + dependencies: + "@babel/highlight" "^7.16.7" + "@babel/compat-data@^7.13.11", "@babel/compat-data@^7.16.0", "@babel/compat-data@^7.16.4": version "7.16.4" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.16.4.tgz#081d6bbc336ec5c2435c6346b2ae1fb98b5ac68e" @@ -73,6 +80,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.16.8": + version "7.16.8" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.16.8.tgz#359d44d966b8cd059d543250ce79596f792f2ebe" + integrity sha512-1ojZwE9+lOXzcWdWmO6TbUzDfqLD39CmEhN8+2cX9XkDo5yW1OpgfejfliysR2AWLpMamTiOiAp/mtroaymhpw== + dependencies: + "@babel/types" "^7.16.8" + jsesc "^2.5.1" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.0.tgz#9a1f0ebcda53d9a2d00108c4ceace6a5d5f1f08d" @@ -80,6 +96,13 @@ dependencies: "@babel/types" "^7.16.0" +"@babel/helper-annotate-as-pure@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz#bb2339a7534a9c128e3102024c60760a3a7f3862" + integrity sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw== + dependencies: + "@babel/types" "^7.16.7" + "@babel/helper-builder-binary-assignment-operator-visitor@^7.16.5": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.16.5.tgz#a8429d064dce8207194b8bf05a70a9ea828746af" @@ -111,6 +134,19 @@ "@babel/helper-replace-supers" "^7.16.5" "@babel/helper-split-export-declaration" "^7.16.0" +"@babel/helper-create-class-features-plugin@^7.16.7": + version "7.16.10" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.16.10.tgz#8a6959b9cc818a88815ba3c5474619e9c0f2c21c" + integrity sha512-wDeej0pu3WN/ffTxMNCPW5UCiOav8IcLRxSIyp/9+IF2xJUM9h/OYjg0IJLHaL6F8oU8kqMz9nc1vryXhMsgXg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.16.7" + "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-function-name" "^7.16.7" + "@babel/helper-member-expression-to-functions" "^7.16.7" + "@babel/helper-optimise-call-expression" "^7.16.7" + "@babel/helper-replace-supers" "^7.16.7" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/helper-create-regexp-features-plugin@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.16.0.tgz#06b2348ce37fccc4f5e18dcd8d75053f2a7c44ff" @@ -140,6 +176,13 @@ dependencies: "@babel/types" "^7.16.0" +"@babel/helper-environment-visitor@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz#ff484094a839bde9d89cd63cba017d7aae80ecd7" + integrity sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag== + dependencies: + "@babel/types" "^7.16.7" + "@babel/helper-explode-assignable-expression@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.16.0.tgz#753017337a15f46f9c09f674cff10cee9b9d7778" @@ -156,6 +199,15 @@ "@babel/template" "^7.16.0" "@babel/types" "^7.16.0" +"@babel/helper-function-name@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz#f1ec51551fb1c8956bc8dd95f38523b6cf375f8f" + integrity sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA== + dependencies: + "@babel/helper-get-function-arity" "^7.16.7" + "@babel/template" "^7.16.7" + "@babel/types" "^7.16.7" + "@babel/helper-get-function-arity@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.0.tgz#0088c7486b29a9cb5d948b1a1de46db66e089cfa" @@ -163,6 +215,13 @@ dependencies: "@babel/types" "^7.16.0" +"@babel/helper-get-function-arity@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz#ea08ac753117a669f1508ba06ebcc49156387419" + integrity sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw== + dependencies: + "@babel/types" "^7.16.7" + "@babel/helper-hoist-variables@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.0.tgz#4c9023c2f1def7e28ff46fc1dbcd36a39beaa81a" @@ -170,6 +229,13 @@ dependencies: "@babel/types" "^7.16.0" +"@babel/helper-hoist-variables@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz#86bcb19a77a509c7b77d0e22323ef588fa58c246" + integrity sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg== + dependencies: + "@babel/types" "^7.16.7" + "@babel/helper-member-expression-to-functions@^7.16.5": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.16.5.tgz#1bc9f7e87354e86f8879c67b316cb03d3dc2caab" @@ -177,6 +243,13 @@ dependencies: "@babel/types" "^7.16.0" +"@babel/helper-member-expression-to-functions@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.16.7.tgz#42b9ca4b2b200123c3b7e726b0ae5153924905b0" + integrity sha512-VtJ/65tYiU/6AbMTDwyoXGPKHgTsfRarivm+YbB5uAzKUyuPjgZSgAFeG87FCigc7KNHu2Pegh1XIT3lXjvz3Q== + dependencies: + "@babel/types" "^7.16.7" + "@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz#90538e60b672ecf1b448f5f4f5433d37e79a3ec3" @@ -205,11 +278,23 @@ dependencies: "@babel/types" "^7.16.0" +"@babel/helper-optimise-call-expression@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz#a34e3560605abbd31a18546bd2aad3e6d9a174f2" + integrity sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w== + dependencies: + "@babel/types" "^7.16.7" + "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.5.tgz#afe37a45f39fce44a3d50a7958129ea5b1a5c074" integrity sha512-59KHWHXxVA9K4HNF4sbHCf+eJeFe0Te/ZFGqBT4OjXhrwvA04sGfaEGsVTdsjoszq0YTP49RC9UKe5g8uN2RwQ== +"@babel/helper-plugin-utils@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" + integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== + "@babel/helper-remap-async-to-generator@^7.16.5": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.16.5.tgz#e706646dc4018942acb4b29f7e185bc246d65ac3" @@ -230,6 +315,17 @@ "@babel/traverse" "^7.16.5" "@babel/types" "^7.16.0" +"@babel/helper-replace-supers@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz#e9f5f5f32ac90429c1a4bdec0f231ef0c2838ab1" + integrity sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw== + dependencies: + "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-member-expression-to-functions" "^7.16.7" + "@babel/helper-optimise-call-expression" "^7.16.7" + "@babel/traverse" "^7.16.7" + "@babel/types" "^7.16.7" + "@babel/helper-simple-access@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.16.0.tgz#21d6a27620e383e37534cf6c10bba019a6f90517" @@ -251,16 +347,33 @@ dependencies: "@babel/types" "^7.16.0" +"@babel/helper-split-export-declaration@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz#0b648c0c42da9d3920d85ad585f2778620b8726b" + integrity sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw== + dependencies: + "@babel/types" "^7.16.7" + "@babel/helper-validator-identifier@^7.15.7": version "7.15.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389" integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w== +"@babel/helper-validator-identifier@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" + integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== + "@babel/helper-validator-option@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3" integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow== +"@babel/helper-validator-option@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz#b203ce62ce5fe153899b617c08957de860de4d23" + integrity sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ== + "@babel/helper-wrap-function@^7.16.5": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.16.5.tgz#0158fca6f6d0889c3fee8a6ed6e5e07b9b54e41f" @@ -289,11 +402,25 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/highlight@^7.16.7": + version "7.16.10" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.10.tgz#744f2eb81579d6eea753c227b0f570ad785aba88" + integrity sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw== + dependencies: + "@babel/helper-validator-identifier" "^7.16.7" + chalk "^2.0.0" + js-tokens "^4.0.0" + "@babel/parser@^7.0.0", "@babel/parser@^7.15.7", "@babel/parser@^7.16.0", "@babel/parser@^7.16.5", "@babel/parser@^7.7.0": version "7.16.6" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.16.6.tgz#8f194828193e8fa79166f34a4b4e52f3e769a314" integrity sha512-Gr86ujcNuPDnNOY8mi383Hvi8IYrJVJYuf3XcuBM/Dgd+bINn/7tHqsj+tKkoreMbmGsFLsltI/JJd8fOFWGDQ== +"@babel/parser@^7.16.10", "@babel/parser@^7.16.7": + version "7.16.12" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.16.12.tgz#9474794f9a650cf5e2f892444227f98e28cdf8b6" + integrity sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.16.2": version "7.16.2" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.16.2.tgz#2977fca9b212db153c195674e57cfab807733183" @@ -543,6 +670,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" +"@babel/plugin-syntax-typescript@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.16.7.tgz#39c9b55ee153151990fb038651d58d3fd03f98f8" + integrity sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A== + dependencies: + "@babel/helper-plugin-utils" "^7.16.7" + "@babel/plugin-transform-arrow-functions@^7.16.5": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.16.5.tgz#04c18944dd55397b521d9d7511e791acea7acf2d" @@ -822,6 +956,15 @@ dependencies: "@babel/helper-plugin-utils" "^7.16.5" +"@babel/plugin-transform-typescript@^7.16.7": + version "7.16.8" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz#591ce9b6b83504903fa9dd3652c357c2ba7a1ee0" + integrity sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.16.7" + "@babel/helper-plugin-utils" "^7.16.7" + "@babel/plugin-syntax-typescript" "^7.16.7" + "@babel/plugin-transform-unicode-escapes@^7.16.5": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.5.tgz#80507c225af49b4f4ee647e2a0ce53d2eeff9e85" @@ -940,6 +1083,15 @@ "@babel/plugin-transform-react-jsx-development" "^7.16.5" "@babel/plugin-transform-react-pure-annotations" "^7.16.5" +"@babel/preset-typescript@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz#ab114d68bb2020afc069cd51b37ff98a046a70b9" + integrity sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ== + dependencies: + "@babel/helper-plugin-utils" "^7.16.7" + "@babel/helper-validator-option" "^7.16.7" + "@babel/plugin-transform-typescript" "^7.16.7" + "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.5.tgz#7f3e34bf8bdbbadf03fbb7b1ea0d929569c9487a" @@ -956,6 +1108,15 @@ "@babel/parser" "^7.16.0" "@babel/types" "^7.16.0" +"@babel/template@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155" + integrity sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w== + dependencies: + "@babel/code-frame" "^7.16.7" + "@babel/parser" "^7.16.7" + "@babel/types" "^7.16.7" + "@babel/traverse@^7.13.0", "@babel/traverse@^7.16.5", "@babel/traverse@^7.7.0": version "7.16.5" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.16.5.tgz#d7d400a8229c714a59b87624fc67b0f1fbd4b2b3" @@ -972,6 +1133,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.16.7": + version "7.16.10" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.16.10.tgz#448f940defbe95b5a8029975b051f75993e8239f" + integrity sha512-yzuaYXoRJBGMlBhsMJoUW7G1UmSb/eXr/JHYM/MsOJgavJibLwASijW7oXBdw3NQ6T0bW7Ty5P/VarOs9cHmqw== + dependencies: + "@babel/code-frame" "^7.16.7" + "@babel/generator" "^7.16.8" + "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-function-name" "^7.16.7" + "@babel/helper-hoist-variables" "^7.16.7" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/parser" "^7.16.10" + "@babel/types" "^7.16.8" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.16.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.16.0.tgz#db3b313804f96aadd0b776c4823e127ad67289ba" @@ -980,6 +1157,14 @@ "@babel/helper-validator-identifier" "^7.15.7" to-fast-properties "^2.0.0" +"@babel/types@^7.16.7", "@babel/types@^7.16.8": + version "7.16.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.16.8.tgz#0ba5da91dd71e0a4e7781a30f22770831062e3c1" + integrity sha512-smN2DQc5s4M7fntyjGtyIPbRJv6wW4rU/94fmYJ7PKQuZkC0qGMHXJbg6sNGt12JmVr4k5YaptI/XtiLJBnmIg== + dependencies: + "@babel/helper-validator-identifier" "^7.16.7" + to-fast-properties "^2.0.0" + "@bugsnag/browser@^7.14.1": version "7.14.1" resolved "https://registry.yarnpkg.com/@bugsnag/browser/-/browser-7.14.1.tgz#ba92aae1fb40aeba0983d2af950f70cc82729882" @@ -1172,7 +1357,7 @@ resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234" integrity sha1-zlblOfg1UrWNENZy6k1vya3HsjQ= -"@mapbox/mapbox-gl-draw@^1.2.2": +"@mapbox/mapbox-gl-draw@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.3.0.tgz#7a30fb99488cb47a32c25e99c3c62413b04bbaed" integrity sha512-B+KWK+dAgzLHMNyKVuuMRfjeSlQ77MhNLdfpQQpbp3pkhnrdmydDe3ixto1Ua78hktNut0WTrAaD8gYu4PVcjA== @@ -1938,14 +2123,6 @@ tiny-warning "^1.0.3" tslib "^2.3.0" -"@reach/visually-hidden@^0.15.2": - version "0.15.2" - resolved "https://registry.yarnpkg.com/@reach/visually-hidden/-/visually-hidden-0.15.2.tgz#07794cb53f4bd23a9452d53a0ad7778711ee323f" - integrity sha512-suDSCuKKuqiEB4UDgwWHbrPRxNwrusZ3ImXr85kfsQXGmKptMogaq22xoaHn32NC++lzZXxdWtAJriieszzFXw== - dependencies: - prop-types "^15.7.2" - tslib "^2.3.0" - "@rollup/plugin-babel@^5.2.0": version "5.3.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879" @@ -2133,23 +2310,6 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== -"@turf/bbox@4.7.3": - version "4.7.3" - resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-4.7.3.tgz#e3ad4f10a7e9b41b522880d33083198199059067" - integrity sha1-461PEKfptBtSKIDTMIMZgZkFkGc= - dependencies: - "@turf/meta" "^4.7.3" - -"@turf/helpers@4.7.3": - version "4.7.3" - resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-4.7.3.tgz#bc312ac43cab3c532a483151c4c382c5649429e9" - integrity sha1-vDEqxDyrPFMqSDFRxMOCxWSUKek= - -"@turf/meta@^4.7.3": - version "4.7.4" - resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-4.7.4.tgz#6de2f1e9890b8f64b669e4b47c09b20893063977" - integrity sha1-beLx6YkLj2S2aeS0fAmyCJMGOXc= - "@types/cacheable-request@^6.0.1": version "6.0.2" resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.2.tgz#c324da0197de0a98a2312156536ae262429ff6b9" @@ -2160,6 +2320,11 @@ "@types/node" "*" "@types/responselike" "*" +"@types/debounce@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852" + integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA== + "@types/decompress@*": version "4.2.4" resolved "https://registry.yarnpkg.com/@types/decompress/-/decompress-4.2.4.tgz#dd2715d3ac1f566d03e6e302d1a26ffab59f8c5c" @@ -2186,7 +2351,7 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/geojson@*": +"@types/geojson@*", "@types/geojson@^7946.0.8": version "7946.0.8" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca" integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA== @@ -2257,6 +2422,21 @@ dependencies: "@types/node" "*" +"@types/mapbox-gl@*": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-2.6.0.tgz#f9e8e963d5a11eba9d914d95ef8a00d015ca8732" + integrity sha512-lHdITzC0IVn9+Pq6WFkkK0N6rUKIqxsdrNeixiQdvROFn2Aeu3TDvhpuag1IdengL5WGGRuEhK6m6HB916ReLw== + dependencies: + "@types/geojson" "*" + +"@types/mapbox__mapbox-gl-draw@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/mapbox__mapbox-gl-draw/-/mapbox__mapbox-gl-draw-1.2.3.tgz#1c280afaa813aa0095193db82df117a6c450de0e" + integrity sha512-S4Pm3w19S8mduiPgoeSt1UQ4BoqrObJtQRQpkD21hGHb6VsRy3VrD7ZCoC7/r5zwnjsGXhbUqy3lg1mGcI6QzQ== + dependencies: + "@types/geojson" "*" + "@types/mapbox-gl" "*" + "@types/minimatch@*": version "3.0.5" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" @@ -2290,11 +2470,37 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/prop-types@*": + version "15.7.4" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" + integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + "@types/q@^1.5.1": version "1.5.5" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df" integrity sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ== +"@types/rails__ujs@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@types/rails__ujs/-/rails__ujs-6.0.1.tgz#83c5aa1dad88ca869de05a9523eff58041ab307a" + integrity sha512-CVwNOdzTQ9qn6X6HPwx6ikH1T9ueJTdfjwFlXFhGvzXsQuESUksibfSosgxs1D/Q1kVEpjxeXD2RzqJv0Ma5Gw== + +"@types/react-dom@^17.0.11": + version "17.0.11" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" + integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^17.0.38": + version "17.0.38" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.38.tgz#f24249fefd89357d5fa71f739a686b8d7c7202bd" + integrity sha512-SI92X1IA+FMnP3qM5m4QReluXzhcmovhZnLNm3pyeQlooi02qI7sLiepEYqT678uNiyc25XfCqxREFpy3W7YhQ== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -2314,18 +2520,16 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.1.tgz#d8f1c0d0dc23afad6dc16a9e993a0865774b4065" integrity sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g== +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + "@types/semver@^7.0.0": version "7.3.9" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.9.tgz#152c6c20a7688c30b967ec1841d31ace569863fc" integrity sha512-L/TMpyURfBkf+o/526Zb6kd/tchUP3iBDEPjqjb+U2MAJhVRxxrmr2fwpe08E7QsV7YLcpq0tUaQ9O9x97ZIxQ== -"@types/supercluster@^5.0.1": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@types/supercluster/-/supercluster-5.0.3.tgz#aa03a77c6545265e63b50fa267ab12afe0c27658" - integrity sha512-XMSqQEr7YDuNtFwSgaHHOjsbi0ZGL62V9Js4CW45RBuRYlNWSW/KDqN+RFFE7HdHcGhJPtN0klKvw06r9Kg7rg== - dependencies: - "@types/geojson" "*" - "@types/warning@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52" @@ -2343,11 +2547,71 @@ dependencies: "@types/yargs-parser" "*" +"@typescript-eslint/eslint-plugin@^5.8.1": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.10.2.tgz#f8c1d59fc37bd6d9d11c97267fdfe722c4777152" + integrity sha512-4W/9lLuE+v27O/oe7hXJKjNtBLnZE8tQAFpapdxwSVHqtmIoPB1gph3+ahNwVuNL37BX7YQHyGF9Xv6XCnIX2Q== + dependencies: + "@typescript-eslint/scope-manager" "5.10.2" + "@typescript-eslint/type-utils" "5.10.2" + "@typescript-eslint/utils" "5.10.2" + debug "^4.3.2" + functional-red-black-tree "^1.0.1" + ignore "^5.1.8" + regexpp "^3.2.0" + semver "^7.3.5" + tsutils "^3.21.0" + +"@typescript-eslint/parser@^5.8.1": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.10.2.tgz#b6076d27cc5499ce3f2c625f5ccde946ecb7db9a" + integrity sha512-JaNYGkaQVhP6HNF+lkdOr2cAs2wdSZBoalE22uYWq8IEv/OVH0RksSGydk+sW8cLoSeYmC+OHvRyv2i4AQ7Czg== + dependencies: + "@typescript-eslint/scope-manager" "5.10.2" + "@typescript-eslint/types" "5.10.2" + "@typescript-eslint/typescript-estree" "5.10.2" + debug "^4.3.2" + +"@typescript-eslint/scope-manager@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.10.2.tgz#92c0bc935ec00f3d8638cdffb3d0e70c9b879639" + integrity sha512-39Tm6f4RoZoVUWBYr3ekS75TYgpr5Y+X0xLZxXqcZNDWZdJdYbKd3q2IR4V9y5NxxiPu/jxJ8XP7EgHiEQtFnw== + dependencies: + "@typescript-eslint/types" "5.10.2" + "@typescript-eslint/visitor-keys" "5.10.2" + +"@typescript-eslint/type-utils@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.10.2.tgz#ad5acdf98a7d2ab030bea81f17da457519101ceb" + integrity sha512-uRKSvw/Ccs5FYEoXW04Z5VfzF2iiZcx8Fu7DGIB7RHozuP0VbKNzP1KfZkHBTM75pCpsWxIthEH1B33dmGBKHw== + dependencies: + "@typescript-eslint/utils" "5.10.2" + debug "^4.3.2" + tsutils "^3.21.0" + "@typescript-eslint/types@4.33.0": version "4.33.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.33.0.tgz#a1e59036a3b53ae8430ceebf2a919dc7f9af6d72" integrity sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ== +"@typescript-eslint/types@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.10.2.tgz#604d15d795c4601fffba6ecb4587ff9fdec68ce8" + integrity sha512-Qfp0qk/5j2Rz3p3/WhWgu4S1JtMcPgFLnmAKAW061uXxKSa7VWKZsDXVaMXh2N60CX9h6YLaBoy9PJAfCOjk3w== + +"@typescript-eslint/typescript-estree@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.10.2.tgz#810906056cd3ddcb35aa333fdbbef3713b0fe4a7" + integrity sha512-WHHw6a9vvZls6JkTgGljwCsMkv8wu8XU8WaYKeYhxhWXH/atZeiMW6uDFPLZOvzNOGmuSMvHtZKd6AuC8PrwKQ== + dependencies: + "@typescript-eslint/types" "5.10.2" + "@typescript-eslint/visitor-keys" "5.10.2" + debug "^4.3.2" + globby "^11.0.4" + is-glob "^4.0.3" + semver "^7.3.5" + tsutils "^3.21.0" + "@typescript-eslint/typescript-estree@^4.8.2": version "4.33.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.33.0.tgz#0dfb51c2908f68c5c08d82aefeaf166a17c24609" @@ -2361,6 +2625,18 @@ semver "^7.3.5" tsutils "^3.21.0" +"@typescript-eslint/utils@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.10.2.tgz#1fcd37547c32c648ab11aea7173ec30060ee87a8" + integrity sha512-vuJaBeig1NnBRkf7q9tgMLREiYD7zsMrsN1DA3wcoMDvr3BTFiIpKjGiYZoKPllfEwN7spUjv7ZqD+JhbVjEPg== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.10.2" + "@typescript-eslint/types" "5.10.2" + "@typescript-eslint/typescript-estree" "5.10.2" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + "@typescript-eslint/visitor-keys@4.33.0": version "4.33.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz#2a22f77a41604289b7a186586e9ec48ca92ef1dd" @@ -2369,6 +2645,14 @@ "@typescript-eslint/types" "4.33.0" eslint-visitor-keys "^2.0.0" +"@typescript-eslint/visitor-keys@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.10.2.tgz#fdbf272d8e61c045d865bd6c8b41bea73d222f3d" + integrity sha512-zHIhYGGGrFJvvyfwHk5M08C5B5K4bewkm+rrvNTKk1/S15YHR+SA/QUF8ZWscXSfEaB8Nn2puZj+iHcoxVOD/Q== + dependencies: + "@typescript-eslint/types" "5.10.2" + eslint-visitor-keys "^3.0.0" + "@vercel/nft@^0.17.0": version "0.17.0" resolved "https://registry.yarnpkg.com/@vercel/nft/-/nft-0.17.0.tgz#28851fefe42fae7a116dc5e23a0a9da29929a18b" @@ -4597,6 +4881,11 @@ csso@^4.0.2: dependencies: css-tree "^1.1.2" +csstype@^3.0.2: + version "3.0.10" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.10.tgz#2ad3a7bed70f35b965707c092e5f30b327c290e5" + integrity sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -4633,7 +4922,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: +debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: version "4.3.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== @@ -4731,11 +5020,6 @@ decompress@^4.2.1: pify "^2.3.0" strip-dirs "^2.0.0" -deep-equal@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" - integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= - deep-equal@^1.0.1: version "1.1.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" @@ -5415,6 +5699,13 @@ eslint-utils@^2.1.0: dependencies: eslint-visitor-keys "^1.1.0" +eslint-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" + integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== + dependencies: + eslint-visitor-keys "^2.0.0" + eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" @@ -5425,6 +5716,11 @@ eslint-visitor-keys@^2.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== +eslint-visitor-keys@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.2.0.tgz#6fbb166a6798ee5991358bc2daa1ba76cc1254a1" + integrity sha512-IOzT0X126zn7ALX0dwFiUQEdsfzrm4+ISsQS8nukaJXwEyYKRSnEIIDULYg1mCtGp7UUXgfGl7BIolXREQK+XQ== + eslint@^7.32.0: version "7.32.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" @@ -5756,6 +6052,17 @@ fast-glob@^3.0.3, fast-glob@^3.1.1: merge2 "^1.3.0" micromatch "^4.0.4" +fast-glob@^3.2.9: + version "3.2.11" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" + integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -6227,6 +6534,11 @@ geojson-vt@^3.2.1: resolved "https://registry.yarnpkg.com/geojson-vt/-/geojson-vt-3.2.1.tgz#f8adb614d2c1d3f6ee7c4265cad4bbf3ad60c8b7" integrity sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg== +geojson@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/geojson/-/geojson-0.5.0.tgz#3cd6c96399be65b56ee55596116fe9191ce701c0" + integrity sha1-PNbJY5m+ZbVu5VWWEW/pGRznAcA= + get-amd-module-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-3.0.0.tgz#bb334662fa04427018c937774570de495845c288" @@ -6467,6 +6779,18 @@ globby@^11.0.0, globby@^11.0.1, globby@^11.0.3: merge2 "^1.3.0" slash "^3.0.0" +globby@^11.0.4: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + globby@^9.2.0: version "9.2.0" resolved "https://registry.yarnpkg.com/globby/-/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d" @@ -6943,7 +7267,7 @@ ignore@^4.0.3, ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.1.1, ignore@^5.1.4: +ignore@^5.1.1, ignore@^5.1.4, ignore@^5.1.8, ignore@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== @@ -7337,7 +7661,7 @@ is-glob@^3.0.0, is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -8241,10 +8565,10 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" -mapbox-gl@^1.3.0: - version "1.13.2" - resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.13.2.tgz#76639c44f141f8dff71b7d8f1504f2aed11f7517" - integrity sha512-CPjtWygL+f7naL+sGHoC2JQR0DG7u+9ik6WdkjjVmz2uy0kBC2l+aKfdi3ZzUR7VKSQJ6Mc/CeCN+6iVNah+ww== +maplibre-gl@^1.15.2: + version "1.15.2" + resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-1.15.2.tgz#7fb47868b62455af916c090903f2154394450f9c" + integrity sha512-uPeV530apb4JfX3cRFfE+awFnbcJTOnCv2QvY4mw4huiInbybElWYkNzTs324YLSADq0f4bidRoYcR81ho3aLA== dependencies: "@mapbox/geojson-rewind" "^0.5.0" "@mapbox/geojson-types" "^1.0.2" @@ -8374,7 +8698,7 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -merge2@^1.2.3, merge2@^1.3.0: +merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== @@ -10821,7 +11145,7 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-coordinate-input@^1.0.0-rc.2: +react-coordinate-input@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/react-coordinate-input/-/react-coordinate-input-1.0.0.tgz#884be529fe311820e19651bd74d2a0b1cfc7f823" integrity sha512-9iJti+WU38mk+Pab/5+Rn24IKfgxKEwcq4yJeyttAy50NTg33K5xwc/+NcPQoEB82xG0iTW9lRlx1WZCOPjWoQ== @@ -10859,22 +11183,6 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-mapbox-gl-draw@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/react-mapbox-gl-draw/-/react-mapbox-gl-draw-2.0.4.tgz#476d70a6efc07c329fa61c11022bcdab60ac4b91" - integrity sha512-oaBdIlyu+g7PhLUvwnCsl/wvu+5tGB9I3RLjcrYLt6U1sUMzQJqplKtVxXRv9TZqRdNaAU5qNOP+dRs55QKjsA== - -react-mapbox-gl@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/react-mapbox-gl/-/react-mapbox-gl-5.1.1.tgz#49e1ddf441c3ff9406d10ccd577ac5448d51584c" - integrity sha512-8vGldFQf7pW8T5ZV2OOhwXoaBvfigB2F7dnhzaZ/bD5/KJzP9zprMbn0xMX95W3eqbKzGGHnwyD5DyTTwR6wGw== - dependencies: - "@turf/bbox" "4.7.3" - "@turf/helpers" "4.7.3" - "@types/supercluster" "^5.0.1" - deep-equal "1.0.1" - supercluster "^7.0.0" - react-popper@^2.2.5: version "2.2.5" resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96" @@ -11028,7 +11336,7 @@ regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.1: call-bind "^1.0.2" define-properties "^1.1.3" -regexpp@^3.1.0: +regexpp@^3.1.0, regexpp@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== @@ -12164,7 +12472,7 @@ stylehacks@^4.0.0: postcss "^7.0.0" postcss-selector-parser "^3.0.0" -supercluster@^7.0.0, supercluster@^7.1.0: +supercluster@^7.1.0: version "7.1.4" resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-7.1.4.tgz#6762aabfd985d3390b49f13b815567d5116a828a" integrity sha512-GhKkRM1jMR6WUwGPw05fs66pOFWhf59lXq+Q3J3SxPvhNcmgOtLRV6aVQPMRsmXdpaeFJGivt+t7QXUPL3ff4g== @@ -12740,6 +13048,11 @@ typescript@^4.1.5, typescript@^4.4.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8" integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg== +typescript@^4.5.5: + version "4.5.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" + integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== + uid-safe@2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a"