Skip to content

Commit 69e22cb

Browse files
frozenheliumshreeyash07
authored andcommitted
Add location search in the local unit form
1 parent 85c2f97 commit 69e22cb

File tree

7 files changed

+203
-13
lines changed

7 files changed

+203
-13
lines changed

app/src/components/domain/BaseMapPointInput/index.tsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
useCallback,
33
useMemo,
4+
useState,
45
} from 'react';
56
import { NumberInput } from '@ifrc-go/ui';
67
import { useTranslation } from '@ifrc-go/ui/hooks';
@@ -10,15 +11,19 @@ import {
1011
isNotDefined,
1112
} from '@togglecorp/fujs';
1213
import {
14+
MapCenter,
1315
MapContainer,
1416
MapLayer,
1517
MapSource,
1618
} from '@togglecorp/re-map';
1719
import { type ObjectError } from '@togglecorp/toggle-form';
1820
import getBbox from '@turf/bbox';
1921
import {
22+
type AnySourceData,
2023
type CircleLayer,
2124
type FillLayer,
25+
type FitBoundsOptions,
26+
type FlyToOptions,
2227
type LngLat,
2328
type Map,
2429
type MapboxGeoJSONFeature,
@@ -35,15 +40,34 @@ import {
3540
import { localUnitMapStyle } from '#utils/map';
3641

3742
import ActiveCountryBaseMapLayer from '../ActiveCountryBaseMapLayer';
43+
import LocationSearchInput, { type LocationSearchResult } from '../LocationSearchInput';
3844

3945
import i18n from './i18n.json';
4046
import styles from './styles.module.css';
4147

48+
const centerOptions = {
49+
zoom: 16,
50+
duration: 1000,
51+
} satisfies FlyToOptions;
52+
53+
const geoJsonSourceOptions = {
54+
type: 'geojson',
55+
} satisfies AnySourceData;
56+
4257
interface GeoPoint {
4358
lng: number;
4459
lat: number
4560
}
4661

62+
const fitBoundsOptions = {
63+
padding: {
64+
left: 20,
65+
top: 20,
66+
bottom: 50,
67+
right: 20,
68+
},
69+
} satisfies FitBoundsOptions;
70+
4771
type Value = Partial<GeoPoint>;
4872

4973
interface Props<NAME> extends BaseMapProps {
@@ -93,17 +117,6 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
93117
const countryDetails = useCountry({ id: country ?? -1 });
94118
const strings = useTranslation(i18n);
95119

96-
const bounds = useMemo(
97-
() => {
98-
if (isNotDefined(countryDetails)) {
99-
return undefined;
100-
}
101-
102-
return getBbox(countryDetails.bbox);
103-
},
104-
[countryDetails],
105-
);
106-
107120
const pointGeoJson = useMemo<GeoJSON.Feature | undefined>(
108121
() => {
109122
if (isNotDefined(value) || isNotDefined(value.lng) || isNotDefined(value.lat)) {
@@ -192,6 +205,27 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
192205
[value, onChange, name],
193206
);
194207

208+
const bounds = useMemo(
209+
() => {
210+
if (isNotDefined(countryDetails)) {
211+
return undefined;
212+
}
213+
214+
return getBbox(countryDetails.bbox);
215+
},
216+
[countryDetails],
217+
);
218+
219+
const [searchResult, setSearchResult] = useState<LocationSearchResult | undefined>();
220+
221+
const center = useMemo(() => {
222+
if (isNotDefined(searchResult)) {
223+
return undefined;
224+
}
225+
226+
return [+searchResult.lon, +searchResult.lat] satisfies [number, number];
227+
}, [searchResult]);
228+
195229
return (
196230
<div className={_cs(styles.baseMapPointInput, className)}>
197231
<div className={styles.locationInputs}>
@@ -232,12 +266,21 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
232266
/>
233267
</DiffWrapper>
234268
</div>
269+
{isDefined(countryDetails) && (
270+
<div className={styles.locationSearch}>
271+
<LocationSearchInput
272+
countryIso={countryDetails.iso}
273+
onResultSelect={setSearchResult}
274+
/>
275+
</div>
276+
)}
235277
<BaseMap
236278
// eslint-disable-next-line react/jsx-props-no-spreading
237279
{...otherProps}
238280
mapOptions={{
239281
zoom: 18,
240282
bounds,
283+
fitBoundsOptions,
241284
...mapOptions,
242285
}}
243286
mapStyle={mapStyle}
@@ -264,14 +307,20 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
264307
<MapSource
265308
sourceKey="selected-point"
266309
geoJson={pointGeoJson}
267-
sourceOptions={{ type: 'geojson' }}
310+
sourceOptions={geoJsonSourceOptions}
268311
>
269312
<MapLayer
270313
layerKey="point-circle"
271314
layerOptions={circleLayerOptions}
272315
/>
273316
</MapSource>
274317
)}
318+
{center && (
319+
<MapCenter
320+
center={center}
321+
centerOptions={centerOptions}
322+
/>
323+
)}
275324
{children}
276325
</BaseMap>
277326
</div>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
.base-map-point-input {
22
display: flex;
3+
position: relative;
34
flex-direction: column;
45
gap: var(--go-ui-spacing-md);
6+
isolation: isolate;
57

68
.location-inputs {
79
display: grid;
810
gap: var(--go-ui-spacing-sm);
911
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
1012
}
13+
14+
.location-search {
15+
position: absolute;
16+
right: var(--go-ui-spacing-sm);
17+
bottom: var(--go-ui-spacing-sm);
18+
z-index: 1;
19+
border-radius: var(--go-ui-border-radius-lg);
20+
background-color: var(--go-ui-color-foreground);
21+
padding: var(--go-ui-spacing-sm);
22+
}
1123
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {
2+
useCallback,
3+
useState,
4+
} from 'react';
5+
import { SearchLineIcon } from '@ifrc-go/icons';
6+
import { SearchSelectInput } from '@ifrc-go/ui';
7+
import { useDebouncedValue } from '@ifrc-go/ui/hooks';
8+
9+
import { useExternalRequest } from '#utils/restRequest';
10+
11+
export interface LocationSearchResult {
12+
addresstype: string;
13+
boundingbox: string[];
14+
class: string;
15+
display_name: string;
16+
importance: number;
17+
lat: string;
18+
licence: string;
19+
lon: string;
20+
name: string;
21+
osm_id: number;
22+
osm_type: string;
23+
place_id: number;
24+
place_rank: number;
25+
type: string
26+
}
27+
28+
function keySelector(result: LocationSearchResult) {
29+
return String(result.osm_id);
30+
}
31+
32+
function labelSelector(result: LocationSearchResult) {
33+
return result.name;
34+
}
35+
36+
function descriptionSelector(result: LocationSearchResult) {
37+
return result.display_name;
38+
}
39+
40+
interface Props {
41+
className?: string;
42+
onResultSelect: (result: LocationSearchResult | undefined) => void;
43+
countryIso: string;
44+
}
45+
46+
function LocationSearchInput(props: Props) {
47+
const {
48+
className,
49+
onResultSelect,
50+
countryIso,
51+
} = props;
52+
53+
const [opened, setOpened] = useState(false);
54+
const [searchText, setSearchText] = useState<string | undefined>(undefined);
55+
56+
const debouncedSearchText = useDebouncedValue(
57+
searchText?.trim() ?? '',
58+
);
59+
60+
const {
61+
pending,
62+
response: options,
63+
} = useExternalRequest<LocationSearchResult[] | undefined>({
64+
skip: !opened || debouncedSearchText.length === 0,
65+
url: 'https://nominatim.openstreetmap.org/search',
66+
query: {
67+
q: debouncedSearchText,
68+
countrycodes: countryIso,
69+
format: 'json',
70+
},
71+
});
72+
73+
const handleOptionSelect = useCallback((
74+
_: string | undefined,
75+
__: string,
76+
option: LocationSearchResult | undefined,
77+
) => {
78+
onResultSelect(option);
79+
}, [onResultSelect]);
80+
81+
return (
82+
<SearchSelectInput
83+
className={className}
84+
name=""
85+
// FIXME: use translations
86+
placeholder="Search for a place"
87+
options={undefined}
88+
value={undefined}
89+
keySelector={keySelector}
90+
labelSelector={labelSelector}
91+
descriptionSelector={descriptionSelector}
92+
onSearchValueChange={setSearchText}
93+
searchOptions={options}
94+
optionsPending={pending}
95+
onChange={handleOptionSelect}
96+
totalOptionsCount={options?.length ?? 0}
97+
onShowDropdownChange={setOpened}
98+
selectedOnTop={false}
99+
icons={<SearchLineIcon />}
100+
/>
101+
);
102+
}
103+
104+
export default LocationSearchInput;

app/src/utils/restRequest/go.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,17 @@ const isSuccessfulStatus = (status: number): boolean => status >= 200 && status
228228

229229
const isContentTypeExcel = (res: Response): boolean => res.headers.get('content-type') === CONTENT_TYPE_EXCEL;
230230

231-
const isContentTypeJson = (res: Response): boolean => res.headers.get('content-type') === CONTENT_TYPE_JSON;
231+
const isContentTypeJson = (res: Response): boolean => {
232+
const contentTypeHeaders = res.headers.get('content-type');
233+
234+
if (isNotDefined(contentTypeHeaders)) {
235+
return false;
236+
}
237+
238+
const mediaTypes = contentTypeHeaders.split('; ');
239+
240+
return mediaTypes[0]?.toLowerCase() === CONTENT_TYPE_JSON;
241+
};
232242

233243
const isLoginRedirect = (url: string): boolean => new URL(url).pathname.includes('login');
234244

app/src/utils/restRequest/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import type {
1515
CustomLazyRequestReturn,
1616
CustomRequestOptions,
1717
CustomRequestReturn,
18+
ExternalRequestOptions,
19+
ExternalRequestReturn,
1820
VALID_METHOD,
1921
} from './overrideTypes';
2022

@@ -70,8 +72,13 @@ const useRiskLazyRequest = useLazyRequest as <
7072
requestOptions: CustomLazyRequestOptions<riskApiPaths, PATH, METHOD, CONTEXT> & { apiType: 'risk' }
7173
) => CustomLazyRequestReturn<riskApiPaths, PATH, METHOD, CONTEXT>;
7274

75+
const useExternalRequest = useRequest as <RESPONSE>(
76+
requestOptions: ExternalRequestOptions<RESPONSE>,
77+
) => ExternalRequestReturn<RESPONSE>;
78+
7379
export {
7480
RequestContext,
81+
useExternalRequest,
7582
useGoLazyRequest as useLazyRequest,
7683
useGoRequest as useRequest,
7784
useRiskLazyRequest,

app/src/utils/restRequest/overrideTypes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,11 @@ type LazyRequestOptionsBase<PATH, METHOD, CONTEXT> = {
270270
OptionOmissions
271271
>;
272272

273+
export type ExternalRequestOptions<RESPONSE> = Pick<
274+
RequestOptions<RESPONSE, TransformedError, unknown>,
275+
'query' | 'url' | 'skip'
276+
>;
277+
273278
export type CustomRequestOptions<
274279
SCHEMA extends object,
275280
PATH extends keyof SCHEMA,
@@ -327,6 +332,8 @@ type LazyRequestReturn<RESPONSE, CONTEXT> = ReturnType<typeof useLazyRequest<
327332
CONTEXT
328333
>>;
329334

335+
export type ExternalRequestReturn<RESPONSE> = RequestReturn<RESPONSE>;
336+
330337
export type CustomRequestReturn<
331338
SCHEMA,
332339
PATH extends keyof SCHEMA,

app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ function LocalUnitsForm(props: Props) {
217217
const {
218218
response: localUnitPreviousResponse,
219219
} = useRequest({
220+
skip: isNotDefined(localUnitId),
220221
url: '/api/v2/local-units/{id}/latest-change-request/',
221222
pathVariables: isDefined(localUnitId) ? { id: localUnitId } : undefined,
222223
});

0 commit comments

Comments
 (0)