frontend/containers/PlaceInput/index.tsx (view raw)
1import {useState} from 'react';
2import TextField, {TextFieldProps} from '@mui/material/TextField';
3import InputAdornment from '@mui/material/InputAdornment';
4import ListItem from '@mui/material/ListItem';
5import ListItemText from '@mui/material/ListItemText';
6import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
7import Autocomplete from '@mui/material/Autocomplete';
8import {debounce} from '@mui/material/utils';
9import {SessionToken} from '@mapbox/search-js-core';
10import {useTranslation} from 'react-i18next';
11import useLocale from '../../hooks/useLocale';
12import {MapboxSuggestion} from '../../pages/api/mapbox/searchbox/suggest';
13import {GeocodedOption} from '../../pages/api/mapbox/searchbox/retrieve';
14
15interface Props {
16 place: string;
17 latitude?: number;
18 longitude?: number;
19 onSelect: ({
20 latitude,
21 longitude,
22 place,
23 }: {
24 latitude?: number;
25 longitude?: number;
26 place: string;
27 }) => void;
28 label?: string;
29 textFieldProps?: TextFieldProps;
30 disabled?: boolean;
31}
32
33type Option = MapboxSuggestion | {name: String; previous?: Boolean};
34
35const MAPBOX_CONFIGURED = process.env['MAPBOX_CONFIGURED'] || false;
36
37const PlaceInput = ({
38 latitude,
39 longitude,
40 place = '',
41 onSelect,
42 label,
43 textFieldProps,
44 disabled,
45}: Props) => {
46 const {t} = useTranslation();
47 const {locale} = useLocale();
48 const [mapboxAvailable, setMapboxAvailable] = useState(MAPBOX_CONFIGURED);
49 const [noCoordinates, setNoCoordinates] = useState(!latitude || !longitude);
50 const previousOption = place ? {name: place, previous: true} : null;
51 const sessionToken = new SessionToken();
52
53 const [options, setOptions] = useState([] as Array<Option>);
54
55 const getOptionDecorators = option => {
56 if (option.mapbox_id) {
57 return {secondary: option.address || option.place_formatted};
58 } else {
59 return {
60 secondary: t`placeInput.item.noCoordinates`,
61 color: 'warning.main',
62 };
63 }
64 };
65
66 const onChange = async (e, selectedOption) => {
67 if (selectedOption.mapbox_id) {
68 const geocodedFeature: GeocodedOption = await fetch(
69 '/api/mapbox/searchbox/retrieve?' +
70 new URLSearchParams({
71 id: selectedOption.mapbox_id,
72 sessionToken: String(sessionToken),
73 locale,
74 })
75 ).then(response => response.json());
76 const {longitude, latitude} = geocodedFeature.coordinates;
77 setNoCoordinates(false);
78 onSelect({
79 place: geocodedFeature.name,
80 latitude,
81 longitude,
82 });
83 } else {
84 setNoCoordinates(true);
85 onSelect({
86 place: selectedOption.name,
87 latitude: null,
88 longitude: null,
89 });
90 }
91 };
92
93 const updateOptions = debounce(async (e, search: string) => {
94 if (search !== '') {
95 try {
96 await fetch(
97 '/api/mapbox/searchbox/suggest?' +
98 new URLSearchParams({
99 search,
100 sessionToken,
101 locale,
102 })
103 )
104 .then(response => response.json())
105 .then(suggestions => setOptions([{name: search}, ...suggestions]));
106 } catch (err) {
107 console.warn(err);
108 setMapboxAvailable(false);
109 }
110 }
111 }, 400);
112
113 const getHelperText = () => {
114 if (!mapboxAvailable) return t`placeInput.mapboxUnavailable`;
115 else if (noCoordinates) return t`placeInput.noCoordinates`;
116 };
117
118 const handleBlur = e => {
119 onSelect({
120 place: e.target.value,
121 latitude,
122 longitude,
123 });
124 };
125
126 return (
127 <Autocomplete
128 freeSolo
129 disableClearable
130 getOptionLabel={option => option?.name || place}
131 options={options}
132 defaultValue={previousOption}
133 autoComplete
134 filterOptions={x => x}
135 noOptionsText={t('autocomplete.noMatch')}
136 onChange={onChange}
137 onInputChange={updateOptions}
138 disabled={disabled}
139 renderInput={params => (
140 <TextField
141 label={label}
142 multiline
143 maxRows={4}
144 helperText={MAPBOX_CONFIGURED && getHelperText()}
145 FormHelperTextProps={{sx: {color: 'warning.main'}}}
146 InputProps={{
147 type: 'search',
148 endAdornment: (
149 <InputAdornment position="end" sx={{mr: -0.5}}>
150 <PlaceOutlinedIcon />
151 </InputAdornment>
152 ),
153 }}
154 {...params}
155 {...textFieldProps}
156 onBlur={handleBlur}
157 />
158 )}
159 renderOption={(props, option) => {
160 const {color, secondary} = getOptionDecorators(option);
161 if (option.previous) return null;
162 return (
163 <ListItem key={option.mapbox_id || 'text'} {...props}>
164 <ListItemText
165 primary={option.name}
166 secondary={secondary}
167 secondaryTypographyProps={{color}}
168 />
169 </ListItem>
170 );
171 }}
172 />
173 );
174};
175
176export default PlaceInput;