Skip to content

Commit e017c2c

Browse files
frozenheliumshreeyash07
authored andcommitted
feat(local-unit): enhance local unit form, permissions, and imports
- Add location search and new fields to the local unit form - Reorder and adjust field layout and orientation - Update map zoom level in the form - Fix add, edit, and delete permissions, including organization-based edit access - Update import modal file naming and descriptions
1 parent 2892386 commit e017c2c

File tree

36 files changed

+2928
-1753
lines changed

36 files changed

+2928
-1753
lines changed

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

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
11
import {
22
useCallback,
33
useMemo,
4+
useState,
45
} from 'react';
5-
import { NumberInput } from '@ifrc-go/ui';
6+
import {
7+
ListView,
8+
NumberInput,
9+
} from '@ifrc-go/ui';
610
import { useTranslation } from '@ifrc-go/ui/hooks';
711
import {
812
_cs,
913
isDefined,
1014
isNotDefined,
1115
} from '@togglecorp/fujs';
1216
import {
17+
MapCenter,
1318
MapContainer,
1419
MapLayer,
1520
MapSource,
1621
} from '@togglecorp/re-map';
1722
import { type ObjectError } from '@togglecorp/toggle-form';
1823
import getBbox from '@turf/bbox';
1924
import {
25+
type AnySourceData,
2026
type CircleLayer,
2127
type FillLayer,
28+
type FitBoundsOptions,
29+
type FlyToOptions,
2230
type LngLat,
2331
type Map,
2432
type MapboxGeoJSONFeature,
@@ -36,15 +44,34 @@ import {
3644
import { localUnitMapStyle } from '#utils/map';
3745

3846
import ActiveCountryBaseMapLayer from '../ActiveCountryBaseMapLayer';
47+
import LocationSearchInput, { type LocationSearchResult } from '../LocationSearchInput';
3948

4049
import i18n from './i18n.json';
4150
import styles from './styles.module.css';
4251

52+
const centerOptions = {
53+
zoom: 16,
54+
duration: 1000,
55+
} satisfies FlyToOptions;
56+
57+
const geoJsonSourceOptions = {
58+
type: 'geojson',
59+
} satisfies AnySourceData;
60+
4361
interface GeoPoint {
4462
lng: number;
4563
lat: number
4664
}
4765

66+
const fitBoundsOptions = {
67+
padding: {
68+
left: 20,
69+
top: 20,
70+
bottom: 50,
71+
right: 20,
72+
},
73+
} satisfies FitBoundsOptions;
74+
4875
type Value = Partial<GeoPoint>;
4976

5077
interface Props<NAME> extends BaseMapProps {
@@ -90,17 +117,6 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
90117
const countryDetails = useCountry({ id: country ?? -1 });
91118
const strings = useTranslation(i18n);
92119

93-
const bounds = useMemo(
94-
() => {
95-
if (isNotDefined(countryDetails)) {
96-
return undefined;
97-
}
98-
99-
return getBbox(countryDetails.bbox);
100-
},
101-
[countryDetails],
102-
);
103-
104120
const pointGeoJson = useMemo<GeoJSON.Feature | undefined>(
105121
() => {
106122
if (isNotDefined(value) || isNotDefined(value.lng) || isNotDefined(value.lat)) {
@@ -189,9 +205,33 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
189205
[value, onChange, name],
190206
);
191207

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 (isDefined(value?.lng) && isDefined(value?.lat)) {
223+
return [value.lng, value.lat] satisfies [number, number];
224+
}
225+
if (isDefined(searchResult)) {
226+
return [+searchResult.lon, +searchResult.lat] satisfies [number, number];
227+
}
228+
229+
return undefined;
230+
}, [searchResult, value?.lng, value?.lat]);
231+
192232
return (
193233
<div className={_cs(styles.baseMapPointInput, className)}>
194-
<div className={styles.locationInputs}>
234+
<ListView spacing="xl">
195235
<DiffWrapper
196236
diffViewEnabled={showChanges}
197237
showPreviousValue={showPreviousValue}
@@ -200,7 +240,7 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
200240
className={diffWrapperClassName}
201241
>
202242
<NumberInput
203-
changed={hasChanged(value?.lat, previousValue?.lat)}
243+
changed={showChanges && hasChanged(value?.lat, previousValue?.lat)}
204244
name="lat"
205245
label={strings.latitude}
206246
value={value?.lat}
@@ -218,7 +258,7 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
218258
className={diffWrapperClassName}
219259
>
220260
<NumberInput
221-
changed={hasChanged(value?.lng, previousValue?.lng)}
261+
changed={showChanges && hasChanged(value?.lng, previousValue?.lng)}
222262
name="lng"
223263
label={strings.longitude}
224264
value={value?.lng}
@@ -228,13 +268,23 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
228268
required={required}
229269
/>
230270
</DiffWrapper>
231-
</div>
271+
</ListView>
272+
{isDefined(countryDetails) && (
273+
<div className={styles.locationSearch}>
274+
<LocationSearchInput
275+
readOnly={readOnly}
276+
countryIso={countryDetails.iso}
277+
onResultSelect={setSearchResult}
278+
/>
279+
</div>
280+
)}
232281
<BaseMap
233282
// eslint-disable-next-line react/jsx-props-no-spreading
234283
{...otherProps}
235284
mapOptions={{
236285
zoom: 18,
237286
bounds,
287+
fitBoundsOptions,
238288
...mapOptions,
239289
}}
240290
mapStyle={mapStyle}
@@ -261,14 +311,20 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
261311
<MapSource
262312
sourceKey="selected-point"
263313
geoJson={pointGeoJson}
264-
sourceOptions={{ type: 'geojson' }}
314+
sourceOptions={geoJsonSourceOptions}
265315
>
266316
<MapLayer
267317
layerKey="point-circle"
268318
layerOptions={circleLayerOptions}
269319
/>
270320
</MapSource>
271321
)}
322+
{center && (
323+
<MapCenter
324+
center={center}
325+
centerOptions={centerOptions}
326+
/>
327+
)}
272328
{children}
273329
</BaseMap>
274330
</div>
Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
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

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

app/src/hooks/domain/usePermissions.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import { useMemo } from 'react';
2-
import { isDefined } from '@togglecorp/fujs';
2+
import {
3+
isDefined,
4+
isNotDefined,
5+
} from '@togglecorp/fujs';
36

7+
import { type GlobalEnums } from '#contexts/domain';
48
import useUserMe from '#hooks/domain/useUserMe';
59

10+
type OrganizationType = NonNullable<GlobalEnums['api_profile_org_types']>[number]['key'];
11+
12+
const canEditLocalUnitOrganization: OrganizationType[] = ['NTLS', 'DLGN', 'SCRT'];
13+
614
function usePermissions() {
715
const userMe = useUserMe();
816

@@ -20,7 +28,7 @@ function usePermissions() {
2028
&& isDefined(countryId)
2129
&& !!userMe?.is_admin_for_countries?.includes(countryId)
2230
);
23-
const isRegionAdmin = (regionId: number | undefined) => (
31+
const isRegionAdmin = (regionId: number | null | undefined) => (
2432
!isGuestUser
2533
&& isDefined(regionId)
2634
&& !!userMe?.is_admin_for_regions?.includes(regionId)
@@ -96,6 +104,19 @@ function usePermissions() {
96104
)
97105
);
98106

107+
const canEditLocalUnit = (
108+
countryId: number | undefined,
109+
) => {
110+
if (isGuestUser
111+
|| isNotDefined(countryId)
112+
|| isNotDefined(userMe?.profile.org_type)) return false;
113+
114+
return (
115+
userMe?.profile.country?.id === countryId
116+
&& canEditLocalUnitOrganization.includes(userMe?.profile.org_type)
117+
);
118+
};
119+
99120
const isPerAdmin = !isGuestUser
100121
&& ((userMe?.is_per_admin_for_countries.length ?? 0) > 0
101122
|| (userMe?.is_per_admin_for_regions.length ?? 0) > 0);
@@ -126,6 +147,7 @@ function usePermissions() {
126147
isSuperUser,
127148
isGuestUser,
128149
isRegionalOrCountryAdmin,
150+
canEditLocalUnit,
129151
};
130152
},
131153
[userMe],

0 commit comments

Comments
 (0)