commit
e0a0a4e81f
20 changed files with 296 additions and 135 deletions
|
@ -57,7 +57,8 @@ module.exports = {
|
||||||
'prettier/prettier': 'error',
|
'prettier/prettier': 'error',
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'error',
|
'react-hooks/exhaustive-deps': 'error',
|
||||||
'@typescript-eslint/no-explicit-any': 'error'
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'error'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
14
Gemfile.lock
14
Gemfile.lock
|
@ -251,7 +251,7 @@ GEM
|
||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
ffi (1.15.4)
|
ffi (1.15.5)
|
||||||
flipper (0.20.3)
|
flipper (0.20.3)
|
||||||
flipper-active_record (0.20.3)
|
flipper-active_record (0.20.3)
|
||||||
activerecord (>= 5.0, < 7)
|
activerecord (>= 5.0, < 7)
|
||||||
|
@ -350,7 +350,7 @@ GEM
|
||||||
i18n_data (0.13.0)
|
i18n_data (0.13.0)
|
||||||
iban-tools (1.1.0)
|
iban-tools (1.1.0)
|
||||||
ice_nine (0.11.2)
|
ice_nine (0.11.2)
|
||||||
image_processing (1.12.1)
|
image_processing (1.12.2)
|
||||||
mini_magick (>= 4.9.5, < 5)
|
mini_magick (>= 4.9.5, < 5)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
invisible_captcha (2.0.0)
|
invisible_captcha (2.0.0)
|
||||||
|
@ -420,7 +420,7 @@ GEM
|
||||||
rake
|
rake
|
||||||
mini_magick (4.11.0)
|
mini_magick (4.11.0)
|
||||||
mini_mime (1.1.2)
|
mini_mime (1.1.2)
|
||||||
mini_portile2 (2.7.1)
|
mini_portile2 (2.8.0)
|
||||||
minitest (5.15.0)
|
minitest (5.15.0)
|
||||||
momentjs-rails (2.20.1)
|
momentjs-rails (2.20.1)
|
||||||
railties (>= 3.1)
|
railties (>= 3.1)
|
||||||
|
@ -431,8 +431,8 @@ GEM
|
||||||
ruby2_keywords (~> 0.0.1)
|
ruby2_keywords (~> 0.0.1)
|
||||||
netrc (0.11.0)
|
netrc (0.11.0)
|
||||||
nio4r (2.5.8)
|
nio4r (2.5.8)
|
||||||
nokogiri (1.13.1)
|
nokogiri (1.13.3)
|
||||||
mini_portile2 (~> 2.7.0)
|
mini_portile2 (~> 2.8.0)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
open4 (1.3.4)
|
open4 (1.3.4)
|
||||||
openid_connect (1.3.0)
|
openid_connect (1.3.0)
|
||||||
|
@ -621,8 +621,8 @@ GEM
|
||||||
rexml
|
rexml
|
||||||
ruby-progressbar (1.11.0)
|
ruby-progressbar (1.11.0)
|
||||||
ruby-saml-idp (0.3.5)
|
ruby-saml-idp (0.3.5)
|
||||||
ruby-vips (2.0.17)
|
ruby-vips (2.1.4)
|
||||||
ffi (~> 1.9)
|
ffi (~> 1.12)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
ruby_parser (3.15.1)
|
ruby_parser (3.15.1)
|
||||||
sexp_processor (~> 4.9)
|
sexp_processor (~> 4.9)
|
||||||
|
|
|
@ -132,14 +132,7 @@ Le projet utilise plusieurs linters pour vérifier la lisibilité et la qualité
|
||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
Dans le cas d’un déploiement sur plusieurs serveurs, l’application peut être déployée avec la tâche :
|
Voir les notes de déploiement dans [DEPLOYMENT.md](doc/DEPLOYMENT.md)
|
||||||
|
|
||||||
```
|
|
||||||
DOMAINS="web1 web2" BRANCH="main" bin/rake deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
En interne, cette tâche utilise [mina](https://github.com/mina-deploy/mina) pour lancer les commandes
|
|
||||||
de déploiement sur tous les serveurs spécifiés.
|
|
||||||
|
|
||||||
## Tâches courantes
|
## Tâches courantes
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,12 @@ import React, {
|
||||||
useContext,
|
useContext,
|
||||||
createContext,
|
createContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect
|
useLayoutEffect,
|
||||||
|
MutableRefObject,
|
||||||
|
ReactNode,
|
||||||
|
ChangeEventHandler,
|
||||||
|
KeyboardEventHandler
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxInput,
|
ComboboxInput,
|
||||||
|
@ -24,22 +27,51 @@ import invariant from 'tiny-invariant';
|
||||||
|
|
||||||
import { useDeferredSubmit, useHiddenField } from './shared/hooks';
|
import { useDeferredSubmit, useHiddenField } from './shared/hooks';
|
||||||
|
|
||||||
const Context = createContext();
|
const Context = createContext<{
|
||||||
|
selectionsRef: MutableRefObject<string[]>;
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const optionValueByLabel = (values, options, label) => {
|
type Option = [label: string, value: string];
|
||||||
const maybeOption = values.includes(label)
|
|
||||||
|
function isOptions(options: string[] | Option[]): options is Option[] {
|
||||||
|
return Array.isArray(options[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionValueByLabel = (
|
||||||
|
values: string[],
|
||||||
|
options: Option[],
|
||||||
|
label: string
|
||||||
|
): string => {
|
||||||
|
const maybeOption: Option | undefined = values.includes(label)
|
||||||
? [label, label]
|
? [label, label]
|
||||||
: options.find(([optionLabel]) => optionLabel == label);
|
: options.find(([optionLabel]) => optionLabel == label);
|
||||||
return maybeOption ? maybeOption[1] : undefined;
|
return maybeOption ? maybeOption[1] : '';
|
||||||
};
|
};
|
||||||
const optionLabelByValue = (values, options, value) => {
|
const optionLabelByValue = (
|
||||||
const maybeOption = values.includes(value)
|
values: string[],
|
||||||
|
options: Option[],
|
||||||
|
value: string
|
||||||
|
): string => {
|
||||||
|
const maybeOption: Option | undefined = values.includes(value)
|
||||||
? [value, value]
|
? [value, value]
|
||||||
: options.find(([, optionValue]) => optionValue == value);
|
: options.find(([, optionValue]) => optionValue == value);
|
||||||
return maybeOption ? maybeOption[0] : undefined;
|
return maybeOption ? maybeOption[0] : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
function ComboMultiple({
|
export type ComboMultipleProps = {
|
||||||
|
options: string[] | Option[];
|
||||||
|
id: string;
|
||||||
|
labelledby: string;
|
||||||
|
describedby: string;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
name?: string;
|
||||||
|
selected: string[];
|
||||||
|
acceptNewValues?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ComboMultiple({
|
||||||
options,
|
options,
|
||||||
id,
|
id,
|
||||||
labelledby,
|
labelledby,
|
||||||
|
@ -49,21 +81,21 @@ function ComboMultiple({
|
||||||
name = 'value',
|
name = 'value',
|
||||||
selected,
|
selected,
|
||||||
acceptNewValues = false
|
acceptNewValues = false
|
||||||
}) {
|
}: ComboMultipleProps) {
|
||||||
invariant(id || label, 'ComboMultiple: `id` or a `label` are required');
|
invariant(id || label, 'ComboMultiple: `id` or a `label` are required');
|
||||||
invariant(group, 'ComboMultiple: `group` is required');
|
invariant(group, 'ComboMultiple: `group` is required');
|
||||||
|
|
||||||
const inputRef = useRef();
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [term, setTerm] = useState('');
|
const [term, setTerm] = useState('');
|
||||||
const [selections, setSelections] = useState(selected);
|
const [selections, setSelections] = useState(selected);
|
||||||
const [newValues, setNewValues] = useState([]);
|
const [newValues, setNewValues] = useState<string[]>([]);
|
||||||
const inputId = useId(id);
|
const inputId = useId(id);
|
||||||
const removedLabelledby = `${inputId}-remove`;
|
const removedLabelledby = `${inputId}-remove`;
|
||||||
const selectedLabelledby = `${inputId}-selected`;
|
const selectedLabelledby = `${inputId}-selected`;
|
||||||
|
|
||||||
const optionsWithLabels = useMemo(
|
const optionsWithLabels = useMemo<Option[]>(
|
||||||
() =>
|
() =>
|
||||||
Array.isArray(options[0])
|
isOptions(options)
|
||||||
? options
|
? options
|
||||||
: options.filter((o) => o).map((o) => [o, o]),
|
: options.filter((o) => o).map((o) => [o, o]),
|
||||||
[options]
|
[options]
|
||||||
|
@ -94,11 +126,11 @@ function ComboMultiple({
|
||||||
const [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name);
|
const [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name);
|
||||||
const awaitFormSubmit = useDeferredSubmit(hiddenField);
|
const awaitFormSubmit = useDeferredSubmit(hiddenField);
|
||||||
|
|
||||||
const handleChange = (event) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
setTerm(event.target.value);
|
setTerm(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSelection = (fn) => {
|
const saveSelection = (fn: (selections: string[]) => string[]) => {
|
||||||
setSelections((selections) => {
|
setSelections((selections) => {
|
||||||
selections = fn(selections);
|
selections = fn(selections);
|
||||||
setHiddenFieldValue(JSON.stringify(selections));
|
setHiddenFieldValue(JSON.stringify(selections));
|
||||||
|
@ -106,7 +138,7 @@ function ComboMultiple({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelect = (value) => {
|
const onSelect = (value: string) => {
|
||||||
const maybeValue = [...extraOptions, ...optionsWithLabels].find(
|
const maybeValue = [...extraOptions, ...optionsWithLabels].find(
|
||||||
([val]) => val == value
|
([val]) => val == value
|
||||||
);
|
);
|
||||||
|
@ -134,8 +166,8 @@ function ComboMultiple({
|
||||||
hidePopover();
|
hidePopover();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemove = (label) => {
|
const onRemove = (label: string) => {
|
||||||
const optionValue = optionValueByLabel(newValues, options, label);
|
const optionValue = optionValueByLabel(newValues, optionsWithLabels, label);
|
||||||
if (optionValue) {
|
if (optionValue) {
|
||||||
saveSelection((selections) =>
|
saveSelection((selections) =>
|
||||||
selections.filter((value) => value != optionValue)
|
selections.filter((value) => value != optionValue)
|
||||||
|
@ -144,10 +176,10 @@ function ComboMultiple({
|
||||||
newValues.filter((value) => value != optionValue)
|
newValues.filter((value) => value != optionValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
inputRef.current.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (event) => {
|
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||||
if (
|
if (
|
||||||
isHotkey('enter', event) ||
|
isHotkey('enter', event) ||
|
||||||
isHotkey(' ', event) ||
|
isHotkey(' ', event) ||
|
||||||
|
@ -210,7 +242,11 @@ function ComboMultiple({
|
||||||
<ComboboxToken
|
<ComboboxToken
|
||||||
key={selection}
|
key={selection}
|
||||||
describedby={removedLabelledby}
|
describedby={removedLabelledby}
|
||||||
value={optionLabelByValue(newValues, options, selection)}
|
value={optionLabelByValue(
|
||||||
|
newValues,
|
||||||
|
optionsWithLabels,
|
||||||
|
selection
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -263,31 +299,35 @@ function ComboMultiple({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComboboxTokenLabel({ onRemove, ...props }) {
|
function ComboboxTokenLabel({
|
||||||
const selectionsRef = useRef([]);
|
onRemove,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const selectionsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
selectionsRef.current = [];
|
selectionsRef.current = [];
|
||||||
return () => (selectionsRef.current = []);
|
return () => {
|
||||||
});
|
selectionsRef.current = [];
|
||||||
|
|
||||||
const context = {
|
|
||||||
onRemove,
|
|
||||||
selectionsRef
|
|
||||||
};
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={context}>
|
<Context.Provider
|
||||||
<div data-reach-combobox-token-label {...props} />
|
value={{
|
||||||
|
onRemove,
|
||||||
|
selectionsRef
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-reach-combobox-token-label>{children}</div>
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ComboboxTokenLabel.propTypes = {
|
function ComboboxSeparator({ value }: { value: string }) {
|
||||||
onRemove: PropTypes.func
|
|
||||||
};
|
|
||||||
|
|
||||||
function ComboboxSeparator({ value }) {
|
|
||||||
return (
|
return (
|
||||||
<li aria-disabled="true" role="option" data-reach-combobox-separator>
|
<li aria-disabled="true" role="option" data-reach-combobox-separator>
|
||||||
{value.slice(2, -2)}
|
{value.slice(2, -2)}
|
||||||
|
@ -295,12 +335,17 @@ function ComboboxSeparator({ value }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ComboboxSeparator.propTypes = {
|
function ComboboxToken({
|
||||||
value: PropTypes.string
|
value,
|
||||||
};
|
describedby,
|
||||||
|
...props
|
||||||
function ComboboxToken({ value, describedby, ...props }) {
|
}: {
|
||||||
const { selectionsRef, onRemove } = useContext(Context);
|
value: string;
|
||||||
|
describedby: string;
|
||||||
|
}) {
|
||||||
|
const context = useContext(Context);
|
||||||
|
invariant(context, 'invalid context');
|
||||||
|
const { selectionsRef, onRemove } = context;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectionsRef.current.push(value);
|
selectionsRef.current.push(value);
|
||||||
});
|
});
|
||||||
|
@ -325,31 +370,3 @@ function ComboboxToken({ value, describedby, ...props }) {
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ComboboxToken.propTypes = {
|
|
||||||
value: PropTypes.string,
|
|
||||||
describedby: PropTypes.string
|
|
||||||
};
|
|
||||||
|
|
||||||
ComboMultiple.propTypes = {
|
|
||||||
options: PropTypes.oneOfType([
|
|
||||||
PropTypes.arrayOf(PropTypes.string),
|
|
||||||
PropTypes.arrayOf(
|
|
||||||
PropTypes.arrayOf(
|
|
||||||
PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
selected: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
arraySelected: PropTypes.arrayOf(PropTypes.array),
|
|
||||||
acceptNewValues: PropTypes.bool,
|
|
||||||
mandatory: PropTypes.bool,
|
|
||||||
id: PropTypes.string,
|
|
||||||
group: PropTypes.string,
|
|
||||||
name: PropTypes.string,
|
|
||||||
labelledby: PropTypes.string,
|
|
||||||
describedby: PropTypes.string,
|
|
||||||
label: PropTypes.string
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ComboMultiple;
|
|
|
@ -1,12 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Point } from 'geojson';
|
import { fire } from '@utils';
|
||||||
|
|
||||||
import ComboAdresseSearch from '../../ComboAdresseSearch';
|
import ComboAdresseSearch from '../../ComboAdresseSearch';
|
||||||
import { useFlyTo } from '../../shared/maplibre/hooks';
|
|
||||||
|
|
||||||
export function AddressInput() {
|
export function AddressInput() {
|
||||||
const flyTo = useFlyTo();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
@ -17,9 +14,8 @@ export function AddressInput() {
|
||||||
className="no-margin"
|
className="no-margin"
|
||||||
placeholder="Rechercher une adresse : saisissez au moins 2 caractères"
|
placeholder="Rechercher une adresse : saisissez au moins 2 caractères"
|
||||||
allowInputValues={false}
|
allowInputValues={false}
|
||||||
onChange={(_, result) => {
|
onChange={(_, feature) => {
|
||||||
const geometry = result?.geometry as Point;
|
fire(document, 'map:zoom', { feature });
|
||||||
flyTo(17, geometry.coordinates as [number, number]);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,7 +7,8 @@ import { useMapLibre } from '../../shared/maplibre/MapLibre';
|
||||||
import {
|
import {
|
||||||
useFitBounds,
|
useFitBounds,
|
||||||
useEvent,
|
useEvent,
|
||||||
useMapEvent
|
useMapEvent,
|
||||||
|
useFlyTo
|
||||||
} from '../../shared/maplibre/hooks';
|
} from '../../shared/maplibre/hooks';
|
||||||
import {
|
import {
|
||||||
filterFeatureCollection,
|
filterFeatureCollection,
|
||||||
|
@ -116,6 +117,7 @@ function useExternalEvents(
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const fitBounds = useFitBounds();
|
const fitBounds = useFitBounds();
|
||||||
|
const flyTo = useFlyTo();
|
||||||
|
|
||||||
const onFeatureFocus = useCallback(
|
const onFeatureFocus = useCallback(
|
||||||
({ detail }) => {
|
({ detail }) => {
|
||||||
|
@ -132,6 +134,16 @@ function useExternalEvents(
|
||||||
[featureCollection, fitBounds]
|
[featureCollection, fitBounds]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onZoomFocus = useCallback(
|
||||||
|
({ detail }) => {
|
||||||
|
const { feature } = detail;
|
||||||
|
if (feature) {
|
||||||
|
flyTo(17, feature.geometry.coordinates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flyTo]
|
||||||
|
);
|
||||||
|
|
||||||
const onFeatureCreate = useCallback(
|
const onFeatureCreate = useCallback(
|
||||||
({ detail }) => {
|
({ detail }) => {
|
||||||
const { geometry, properties } = detail;
|
const { geometry, properties } = detail;
|
||||||
|
@ -181,6 +193,7 @@ function useExternalEvents(
|
||||||
useEvent('map:feature:create', onFeatureCreate);
|
useEvent('map:feature:create', onFeatureCreate);
|
||||||
useEvent('map:feature:update', onFeatureUpdate);
|
useEvent('map:feature:update', onFeatureUpdate);
|
||||||
useEvent('map:feature:delete', onFeatureDelete);
|
useEvent('map:feature:delete', onFeatureDelete);
|
||||||
|
useEvent('map:zoom', onZoomFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
const translations = [
|
const translations = [
|
||||||
|
|
|
@ -5,11 +5,7 @@ import { PlusIcon, LocationMarkerIcon } from '@heroicons/react/outline';
|
||||||
import { useId } from '@reach/auto-id';
|
import { useId } from '@reach/auto-id';
|
||||||
import CoordinateInput from 'react-coordinate-input';
|
import CoordinateInput from 'react-coordinate-input';
|
||||||
|
|
||||||
import { useFlyTo } from '../../shared/maplibre/hooks';
|
|
||||||
|
|
||||||
export function PointInput() {
|
export function PointInput() {
|
||||||
const flyTo = useFlyTo();
|
|
||||||
|
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [feature, setFeature] = useState<Feature | null>(null);
|
const [feature, setFeature] = useState<Feature | null>(null);
|
||||||
|
@ -60,15 +56,13 @@ export function PointInput() {
|
||||||
setValue(value);
|
setValue(value);
|
||||||
if (dd.length) {
|
if (dd.length) {
|
||||||
const coordinates: [number, number] = [dd[1], dd[0]];
|
const coordinates: [number, number] = [dd[1], dd[0]];
|
||||||
setFeature({
|
const feature = {
|
||||||
type: 'Feature',
|
type: 'Feature' as const,
|
||||||
geometry: {
|
geometry: { type: 'Point' as const, coordinates },
|
||||||
type: 'Point',
|
|
||||||
coordinates
|
|
||||||
},
|
|
||||||
properties: {}
|
properties: {}
|
||||||
});
|
};
|
||||||
flyTo(17, coordinates);
|
setFeature(feature);
|
||||||
|
fire(document, 'map:zoom', { feature });
|
||||||
} else {
|
} else {
|
||||||
setFeature(null);
|
setFeature(null);
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,19 +46,11 @@ export default function MapEditor({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
||||||
<MapLibre
|
|
||||||
layers={options.layers}
|
<ImportFileInput featureCollection={featureCollection} {...actions} />
|
||||||
header={
|
|
||||||
<>
|
|
||||||
<ImportFileInput
|
|
||||||
featureCollection={featureCollection}
|
|
||||||
{...actions}
|
|
||||||
/>
|
|
||||||
<AddressInput />
|
<AddressInput />
|
||||||
</>
|
|
||||||
}
|
<MapLibre layers={options.layers}>
|
||||||
footer={<PointInput />}
|
|
||||||
>
|
|
||||||
<DrawLayer
|
<DrawLayer
|
||||||
featureCollection={featureCollection}
|
featureCollection={featureCollection}
|
||||||
{...actions}
|
{...actions}
|
||||||
|
@ -86,6 +78,7 @@ export default function MapEditor({
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</MapLibre>
|
</MapLibre>
|
||||||
|
<PointInput />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,8 @@ import {
|
||||||
useFitBounds,
|
useFitBounds,
|
||||||
useEvent,
|
useEvent,
|
||||||
EventHandler,
|
EventHandler,
|
||||||
useMapEvent
|
useMapEvent,
|
||||||
|
useFlyTo
|
||||||
} from '../../shared/maplibre/hooks';
|
} from '../../shared/maplibre/hooks';
|
||||||
import {
|
import {
|
||||||
filterFeatureCollection,
|
filterFeatureCollection,
|
||||||
|
@ -99,6 +100,7 @@ export function GeoJSONLayer({
|
||||||
|
|
||||||
function useExternalEvents(featureCollection: FeatureCollection) {
|
function useExternalEvents(featureCollection: FeatureCollection) {
|
||||||
const fitBounds = useFitBounds();
|
const fitBounds = useFitBounds();
|
||||||
|
const flyTo = useFlyTo();
|
||||||
const onFeatureFocus = useCallback(
|
const onFeatureFocus = useCallback(
|
||||||
({ detail }) => {
|
({ detail }) => {
|
||||||
const { id } = detail;
|
const { id } = detail;
|
||||||
|
@ -109,6 +111,15 @@ function useExternalEvents(featureCollection: FeatureCollection) {
|
||||||
},
|
},
|
||||||
[featureCollection, fitBounds]
|
[featureCollection, fitBounds]
|
||||||
);
|
);
|
||||||
|
const onZoomFocus = useCallback(
|
||||||
|
({ detail }) => {
|
||||||
|
const { feature } = detail;
|
||||||
|
if (feature) {
|
||||||
|
flyTo(17, feature.geometry.coordinates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flyTo]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fitBounds(featureCollection.bbox as LngLatBoundsLike);
|
fitBounds(featureCollection.bbox as LngLatBoundsLike);
|
||||||
|
@ -117,6 +128,7 @@ function useExternalEvents(featureCollection: FeatureCollection) {
|
||||||
}, [fitBounds]);
|
}, [fitBounds]);
|
||||||
|
|
||||||
useEvent('map:feature:focus', onFeatureFocus);
|
useEvent('map:feature:focus', onFeatureFocus);
|
||||||
|
useEvent('map:zoom', onZoomFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LineStringLayer({
|
function LineStringLayer({
|
||||||
|
|
|
@ -19,8 +19,6 @@ const Context = createContext<{ map?: Map | null }>({});
|
||||||
|
|
||||||
type MapLibreProps = {
|
type MapLibreProps = {
|
||||||
layers: string[];
|
layers: string[];
|
||||||
header?: ReactNode;
|
|
||||||
footer?: ReactNode;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -30,7 +28,7 @@ export function useMapLibre() {
|
||||||
return context.map;
|
return context.map;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapLibre({ children, header, footer, layers }: MapLibreProps) {
|
export function MapLibre({ children, layers }: MapLibreProps) {
|
||||||
const isSupported = useMemo(
|
const isSupported = useMemo(
|
||||||
() => maplibre.supported({ failIfMajorPerformanceCaveat: true }) && !isIE(),
|
() => maplibre.supported({ failIfMajorPerformanceCaveat: true }) && !isIE(),
|
||||||
[]
|
[]
|
||||||
|
@ -90,12 +88,10 @@ export function MapLibre({ children, header, footer, layers }: MapLibreProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={{ map }}>
|
<Context.Provider value={{ map }}>
|
||||||
{map ? header : null}
|
|
||||||
<div ref={containerRef} style={{ height: '500px' }}>
|
<div ref={containerRef} style={{ height: '500px' }}>
|
||||||
<StyleControl styleId={style.id} {...mapStyleProps} />
|
<StyleControl styleId={style.id} {...mapStyleProps} />
|
||||||
{map ? children : null}
|
{map ? children : null}
|
||||||
</div>
|
</div>
|
||||||
{map ? footer : null}
|
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,8 +12,7 @@ class Administrateur < ApplicationRecord
|
||||||
include ActiveRecord::SecureToken
|
include ActiveRecord::SecureToken
|
||||||
|
|
||||||
has_and_belongs_to_many :instructeurs
|
has_and_belongs_to_many :instructeurs
|
||||||
has_many :administrateurs_procedures
|
has_and_belongs_to_many :procedures
|
||||||
has_many :procedures, through: :administrateurs_procedures
|
|
||||||
has_many :services
|
has_many :services
|
||||||
|
|
||||||
has_one :user, dependent: :nullify
|
has_one :user, dependent: :nullify
|
||||||
|
|
|
@ -20,14 +20,43 @@
|
||||||
# type_de_champ_id :integer
|
# type_de_champ_id :integer
|
||||||
#
|
#
|
||||||
class Champs::PhoneChamp < Champs::TextChamp
|
class Champs::PhoneChamp < Champs::TextChamp
|
||||||
|
# We want to allow:
|
||||||
|
# * international (e164) phone numbers
|
||||||
|
# * “french format” (ten digits with a leading 0)
|
||||||
|
# * DROM numbers
|
||||||
|
#
|
||||||
|
# However, we need to special-case some ten-digit numbers,
|
||||||
|
# because the ARCEP assigns some blocks of "O6 XX XX XX XX" numbers to DROM operators.
|
||||||
|
# Guadeloupe | GP | +590 | 0690XXXXXX, 0691XXXXXX
|
||||||
|
# Guyane | GF | +594 | 0694XXXXXX
|
||||||
|
# Martinique | MQ | +596 | 0696XXXXXX, 0697XXXXXX
|
||||||
|
# Réunion | RE | +262 | 0692XXXXXX, 0693XXXXXX
|
||||||
|
# Mayotte | YT | +262 | 0692XXXXXX, 0693XXXXXX
|
||||||
|
# Nouvelle-Calédonie | NC | +687 |
|
||||||
|
# Polynésie française | PF | +689 | 40XXXXXX, 45XXXXXX, 87XXXXXX, 88XXXXXX, 89XXXXXX
|
||||||
|
#
|
||||||
|
# Cf: Plan national de numérotation téléphonique,
|
||||||
|
# https://www.arcep.fr/uploads/tx_gsavis/05-1085.pdf “Numéros mobiles à 10 chiffres”, page 6
|
||||||
|
#
|
||||||
|
# See issue #6996.
|
||||||
|
DEFAULT_COUNTRY_CODES = [:FR, :GP, :GF, :MQ, :RE, :YT, :NC, :PF].freeze
|
||||||
|
|
||||||
validates :value,
|
validates :value,
|
||||||
phone: {
|
phone: {
|
||||||
possible: true,
|
possible: true,
|
||||||
allow_blank: true,
|
allow_blank: true,
|
||||||
message: I18n.t(:not_a_phone, scope: 'activerecord.errors.messages')
|
message: I18n.t(:not_a_phone, scope: 'activerecord.errors.messages')
|
||||||
}, unless: -> { Phonelib.valid_for_country?(value, :pf) }
|
}, unless: -> { Phonelib.valid_for_countries?(value, DEFAULT_COUNTRY_CODES) }
|
||||||
|
|
||||||
def to_s
|
def to_s
|
||||||
value.present? ? Phonelib.parse(value).full_national : ''
|
return '' if value.blank?
|
||||||
|
|
||||||
|
if Phonelib.valid_for_countries?(value, DEFAULT_COUNTRY_CODES)
|
||||||
|
Phonelib.parse_for_countries(value, DEFAULT_COUNTRY_CODES).full_national
|
||||||
|
else
|
||||||
|
# When he phone number is possible for the default countries, but not strictly valid,
|
||||||
|
# `full_national` could mess up the formatting. In this case just return the original.
|
||||||
|
value
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
class AddAdministrateurForeignKeyToAdministrateursProcedure < ActiveRecord::Migration[6.1]
|
||||||
|
def up
|
||||||
|
# Sanity check
|
||||||
|
say_with_time 'Removing AdministrateursProcedures where the associated Administrateur no longer exists ' do
|
||||||
|
deleted_administrateur_ids = AdministrateursProcedure.where.missing(:administrateur).pluck(:administrateur_id)
|
||||||
|
AdministrateursProcedure.where(administrateur_id: deleted_administrateur_ids).delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key :administrateurs_procedures, :administrateurs
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_foreign_key :administrateurs_procedures, :administrateurs
|
||||||
|
end
|
||||||
|
end
|
61
doc/DEPLOYMENT.md
Normal file
61
doc/DEPLOYMENT.md
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
# Deployment documentation
|
||||||
|
|
||||||
|
demarches-simplifiees.fr is a standard Rails app, and can be deployed using standard methods (PaaS, Docker, bare-metal, etc.) Deployments are engineered not to require any downtime.
|
||||||
|
|
||||||
|
## 1. Deploying demarches-simplifiees.fr
|
||||||
|
|
||||||
|
Usually, a deployment goes like this (in pseudo-code):
|
||||||
|
|
||||||
|
```
|
||||||
|
# Run database schema migrations (e.g. `bin/rails db:migrate`)
|
||||||
|
# For each server:
|
||||||
|
# Stop the server
|
||||||
|
# Get the new code (e.g. `git clone git@github.com:betagouv/demarches-simplifiees.fr.git`)
|
||||||
|
# Install new dependencies (e.g. `bundle install && yarn install`)
|
||||||
|
# Restart the app server
|
||||||
|
# Run data migrations (e.g. `rake after_party:run`)
|
||||||
|
```
|
||||||
|
|
||||||
|
On the main instance, this deployment flow is implemented using [`mina`](https://github.com/mina-deploy/mina), which automatically sshs to the application servers, run the appropriate commands (see `lib/tasks/deploy.rake` and `config/deploy.rb`), and restarts the puma webserver in a way that ensures zero-downtime deployments.
|
||||||
|
A deploy on multiple application servers is typically done using:
|
||||||
|
```shell
|
||||||
|
DOMAINS="web1 web2" BRANCH="main" bin/rake deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
But of course other methods can be used.
|
||||||
|
|
||||||
|
## 2. Upgrading demarches-simplifiees.fr
|
||||||
|
|
||||||
|
### 2.1 Standard upgrade path
|
||||||
|
|
||||||
|
Theoretically, only deploying each version sequentially is fully supported. This means that to deploy the version N+3, the upgrade plan should be to deploy the version N+1, N+2 and then only N+3, in that order.
|
||||||
|
|
||||||
|
Release notes for each version are available on [GitHub's Releases page](https://github.com/betagouv/demarches-simplifiees.fr/releases). Since 2022, when a release includes a database schema or data migration is present, this is mentionned in the release notes.
|
||||||
|
|
||||||
|
### 2.2 Upgrading several releases at once
|
||||||
|
|
||||||
|
Upgrading from several releases at once (like migrating directly from a version N to a version N+3) is theoretically unsupported. This is because database schema migrations and data migrations have to run in the exact order they were created, along the application code as it was when the migration was written.
|
||||||
|
That said, it is possible to batch the upgrade of several releases at once, _provided that the data migrations run in the correct order_.
|
||||||
|
|
||||||
|
The rule of thumb is that _an intermediary upgrade should be done before every database schema migration that follows a data migration_.
|
||||||
|
|
||||||
|
_NB: There are some plans to improve this, and contributions are welcome. See https://github.com/betagouv/demarches-simplifiees.fr/issues/6970_
|
||||||
|
|
||||||
|
# Historical notes
|
||||||
|
|
||||||
|
- During 2021, some older data migration tasks were deleted from the repository. This has to be checked manually when upgrading from an older version.
|
||||||
|
```
|
||||||
|
lib/tasks/deployment/20200326133630_cleanup_deleted_dossiers.rake
|
||||||
|
lib/tasks/deployment/20200401123317_process_expired_dossiers_en_construction.rake
|
||||||
|
lib/tasks/deployment/20200527124112_fix_champ_etablissement.rake
|
||||||
|
lib/tasks/deployment/20200528124044_fix_dossier_etablissement.rake
|
||||||
|
lib/tasks/deployment/20200618121241_drop_down_list_options_to_json.rake
|
||||||
|
lib/tasks/deployment/20200625113026_migrate_revisions.rake
|
||||||
|
lib/tasks/deployment/20200630154829_add_traitements_from_dossiers.rake
|
||||||
|
lib/tasks/deployment/20200708101123_add_default_skip_validation_to_piece_justificative.rake
|
||||||
|
lib/tasks/deployment/20200728150458_fix_cloned_revisions.rake
|
||||||
|
lib/tasks/deployment/20200813111957_fix_geo_areas_geometry.rake
|
||||||
|
lib/tasks/deployment/20201001161931_migrate_filters_to_use_stable_id.rake
|
||||||
|
lib/tasks/deployment/20201006123842_setup_first_stats.rake
|
||||||
|
lib/tasks/deployment/20201218163035_fix_types_de_champ_revisions.rake
|
||||||
|
```
|
14
lib/core_ext/phonelib.rb
Normal file
14
lib/core_ext/phonelib.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# Class extensions to the Phonelib module, which allow parsing using several countries at once.
|
||||||
|
module Phonelib
|
||||||
|
# Variation of `.valid_for_country`, that can check several countries at once.
|
||||||
|
def self.valid_for_countries?(value, countries)
|
||||||
|
countries.any? { |country| valid_for_country?(value, country) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Variation of `Phonelib.parse`, which parses the given string using the first country
|
||||||
|
# for which the phone number is valid.
|
||||||
|
def self.parse_for_countries(value, passed_countries = [])
|
||||||
|
valid_country = passed_countries.find { |country| valid_for_country?(value, country) }
|
||||||
|
parse(value, valid_country)
|
||||||
|
end
|
||||||
|
end
|
|
@ -47,6 +47,7 @@
|
||||||
"@2fd/graphdoc": "^2.4.0",
|
"@2fd/graphdoc": "^2.4.0",
|
||||||
"@types/debounce": "^1.2.1",
|
"@types/debounce": "^1.2.1",
|
||||||
"@types/geojson": "^7946.0.8",
|
"@types/geojson": "^7946.0.8",
|
||||||
|
"@types/is-hotkey": "^0.1.7",
|
||||||
"@types/mapbox__mapbox-gl-draw": "^1.2.3",
|
"@types/mapbox__mapbox-gl-draw": "^1.2.3",
|
||||||
"@types/rails__ujs": "^6.0.1",
|
"@types/rails__ujs": "^6.0.1",
|
||||||
"@types/react": "^17.0.38",
|
"@types/react": "^17.0.38",
|
||||||
|
|
|
@ -3,7 +3,7 @@ describe Administrateur, type: :model do
|
||||||
|
|
||||||
describe 'associations' do
|
describe 'associations' do
|
||||||
it { is_expected.to have_and_belong_to_many(:instructeurs) }
|
it { is_expected.to have_and_belong_to_many(:instructeurs) }
|
||||||
it { is_expected.to have_many(:procedures) }
|
it { is_expected.to have_and_belong_to_many(:procedures) }
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#renew_api_token" do
|
describe "#renew_api_token" do
|
||||||
|
|
|
@ -22,6 +22,10 @@ describe Champs::PhoneChamp do
|
||||||
expect(champ_with_value("+1(0) - 123456789")).to be_valid
|
expect(champ_with_value("+1(0) - 123456789")).to be_valid
|
||||||
expect(champ_with_value("+49 2109 87654321")).to be_valid
|
expect(champ_with_value("+49 2109 87654321")).to be_valid
|
||||||
expect(champ_with_value("012345678")).to be_valid
|
expect(champ_with_value("012345678")).to be_valid
|
||||||
|
# DROM numbers should be valid
|
||||||
|
expect(champ_with_value("06 96 04 78 07")).to be_valid
|
||||||
|
expect(champ_with_value("05 94 22 31 31")).to be_valid
|
||||||
|
expect(champ_with_value("+594 5 94 22 31 31")).to be_valid
|
||||||
# polynesian numbers should not return errors in any way
|
# polynesian numbers should not return errors in any way
|
||||||
## landline numbers start with 40 or 45
|
## landline numbers start with 40 or 45
|
||||||
expect(champ_with_value("45187272")).to be_valid
|
expect(champ_with_value("45187272")).to be_valid
|
||||||
|
@ -36,9 +40,27 @@ describe Champs::PhoneChamp do
|
||||||
expect(champ_with_value("88473500")).to be_valid
|
expect(champ_with_value("88473500")).to be_valid
|
||||||
expect(champ_with_value("89473500")).to be_valid
|
expect(champ_with_value("89473500")).to be_valid
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#to_s' do
|
||||||
|
context 'for valid phone numbers' do
|
||||||
|
it 'returns the national part of the number, formatted nicely' do
|
||||||
|
expect(champ_with_value("0115789055").to_s).to eq("01 15 78 90 55")
|
||||||
|
expect(champ_with_value("+33115789055").to_s).to eq("01 15 78 90 55")
|
||||||
|
# DROM phone numbers are formatted differently – but still formatted
|
||||||
|
expect(champ_with_value("0696047807").to_s).to eq("0696 04 78 07")
|
||||||
|
expect(champ_with_value("45187272").to_s).to eq("45187272")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'for possible (but not valid) phone numbers' do
|
||||||
|
it 'returns the original' do
|
||||||
|
expect(champ_with_value("1234").to_s).to eq("1234")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def champ_with_value(number)
|
def champ_with_value(number)
|
||||||
phone_champ.tap { |c| c.value = number }
|
phone_champ.tap { |c| c.value = number }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
|
@ -2383,6 +2383,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/is-hotkey@^0.1.7":
|
||||||
|
version "0.1.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/is-hotkey/-/is-hotkey-0.1.7.tgz#30ec6d4234895230b576728ef77e70a52962f3b3"
|
||||||
|
integrity sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ==
|
||||||
|
|
||||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
|
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
|
||||||
version "2.0.3"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
|
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
|
||||||
|
|
Loading…
Reference in a new issue