Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/big-turkeys-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"go-web-app": patch
---

Local Unit Form Improvements

- Improved the Local Unit form with location search, new fields, and better field ordering and layout.
- Updated map zoom behavior for more accurate location selection.
- Enhanced add, edit, and delete permissions, including organization-based edit access.
- Refined the import modal with updated file naming and descriptions.
90 changes: 73 additions & 17 deletions app/src/components/domain/BaseMapPointInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
import {
useCallback,
useMemo,
useState,
} from 'react';
import { NumberInput } from '@ifrc-go/ui';
import {
ListView,
NumberInput,
} from '@ifrc-go/ui';
import { useTranslation } from '@ifrc-go/ui/hooks';
import {
_cs,
isDefined,
isNotDefined,
} from '@togglecorp/fujs';
import {
MapCenter,
MapContainer,
MapLayer,
MapSource,
} from '@togglecorp/re-map';
import { type ObjectError } from '@togglecorp/toggle-form';
import getBbox from '@turf/bbox';
import {
type AnySourceData,
type CircleLayer,
type FillLayer,
type FitBoundsOptions,
type FlyToOptions,
type LngLat,
type Map,
type MapboxGeoJSONFeature,
Expand All @@ -36,15 +44,34 @@ import {
import { localUnitMapStyle } from '#utils/map';

import ActiveCountryBaseMapLayer from '../ActiveCountryBaseMapLayer';
import LocationSearchInput, { type LocationSearchResult } from '../LocationSearchInput';

import i18n from './i18n.json';
import styles from './styles.module.css';

const centerOptions = {
zoom: 16,
duration: 1000,
} satisfies FlyToOptions;

const geoJsonSourceOptions = {
type: 'geojson',
} satisfies AnySourceData;

interface GeoPoint {
lng: number;
lat: number
}

const fitBoundsOptions = {
padding: {
left: 20,
top: 20,
bottom: 50,
right: 20,
},
} satisfies FitBoundsOptions;

type Value = Partial<GeoPoint>;

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

const bounds = useMemo(
() => {
if (isNotDefined(countryDetails)) {
return undefined;
}

return getBbox(countryDetails.bbox);
},
[countryDetails],
);

const pointGeoJson = useMemo<GeoJSON.Feature | undefined>(
() => {
if (isNotDefined(value) || isNotDefined(value.lng) || isNotDefined(value.lat)) {
Expand Down Expand Up @@ -189,9 +205,33 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
[value, onChange, name],
);

const bounds = useMemo(
() => {
if (isNotDefined(countryDetails)) {
return undefined;
}

return getBbox(countryDetails.bbox);
},
[countryDetails],
);

const [searchResult, setSearchResult] = useState<LocationSearchResult | undefined>();

const center = useMemo(() => {
if (isDefined(value?.lng) && isDefined(value?.lat)) {
return [value.lng, value.lat] satisfies [number, number];
}
if (isDefined(searchResult)) {
return [+searchResult.lon, +searchResult.lat] satisfies [number, number];
}

return undefined;
}, [searchResult, value?.lng, value?.lat]);

return (
<div className={_cs(styles.baseMapPointInput, className)}>
<div className={styles.locationInputs}>
<ListView spacing="xl">
<DiffWrapper
diffViewEnabled={showChanges}
showPreviousValue={showPreviousValue}
Expand All @@ -200,7 +240,7 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
className={diffWrapperClassName}
>
<NumberInput
changed={hasChanged(value?.lat, previousValue?.lat)}
changed={showChanges && hasChanged(value?.lat, previousValue?.lat)}
name="lat"
label={strings.latitude}
value={value?.lat}
Expand All @@ -218,7 +258,7 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
className={diffWrapperClassName}
>
<NumberInput
changed={hasChanged(value?.lng, previousValue?.lng)}
changed={showChanges && hasChanged(value?.lng, previousValue?.lng)}
name="lng"
label={strings.longitude}
value={value?.lng}
Expand All @@ -228,13 +268,23 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
required={required}
/>
</DiffWrapper>
</div>
</ListView>
{isDefined(countryDetails) && (
<div className={styles.locationSearch}>
<LocationSearchInput
readOnly={readOnly}
countryIso={countryDetails.iso}
onResultSelect={setSearchResult}
/>
</div>
)}
<BaseMap
// eslint-disable-next-line react/jsx-props-no-spreading
{...otherProps}
mapOptions={{
zoom: 18,
bounds,
fitBoundsOptions,
...mapOptions,
}}
mapStyle={mapStyle}
Expand All @@ -261,14 +311,20 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
<MapSource
sourceKey="selected-point"
geoJson={pointGeoJson}
sourceOptions={{ type: 'geojson' }}
sourceOptions={geoJsonSourceOptions}
>
<MapLayer
layerKey="point-circle"
layerOptions={circleLayerOptions}
/>
</MapSource>
)}
{center && (
<MapCenter
center={center}
centerOptions={centerOptions}
/>
)}
{children}
</BaseMap>
</div>
Expand Down
14 changes: 10 additions & 4 deletions app/src/components/domain/BaseMapPointInput/styles.module.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
.base-map-point-input {
display: flex;
position: relative;
flex-direction: column;
gap: var(--go-ui-spacing-md);
isolation: isolate;

.location-inputs {
display: grid;
gap: var(--go-ui-spacing-sm);
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
.location-search {
position: absolute;
right: var(--go-ui-spacing-sm);
bottom: var(--go-ui-spacing-sm);
z-index: 1;
border-radius: var(--go-ui-border-radius-lg);
background-color: var(--go-ui-color-foreground);
padding: var(--go-ui-spacing-sm);
}
}
105 changes: 105 additions & 0 deletions app/src/components/domain/LocationSearchInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
useCallback,
useState,
} from 'react';
import { SearchLineIcon } from '@ifrc-go/icons';
import { SearchSelectInput } from '@ifrc-go/ui';
import { useDebouncedValue } from '@ifrc-go/ui/hooks';

import { useExternalRequest } from '#utils/restRequest';

export interface LocationSearchResult {
addresstype: string;
boundingbox: string[];
readOnly?: boolean;
class: string;
display_name: string;
importance: number;
lat: string;
licence: string;
lon: string;
name: string;
osm_id: number;
osm_type: string;
place_id: number;
place_rank: number;
type: string;
}

function keySelector(result: LocationSearchResult) {
return String(result.osm_id);
}

function labelSelector(result: LocationSearchResult) {
return result.name;
}

function descriptionSelector(result: LocationSearchResult) {
return result.display_name;
}

interface Props {
className?: string;
onResultSelect: (result: LocationSearchResult | undefined) => void;
countryIso: string;
readOnly?: boolean;
}

function LocationSearchInput(props: Props) {
const {
className, onResultSelect, readOnly, countryIso,
} = props;

const [opened, setOpened] = useState(false);
const [searchText, setSearchText] = useState<string | undefined>(undefined);

const debouncedSearchText = useDebouncedValue(searchText?.trim() ?? '');

const { pending, response: options } = useExternalRequest<
LocationSearchResult[] | undefined
>({
skip: !opened || debouncedSearchText.length === 0,
url: 'https://nominatim.openstreetmap.org/search',
query: {
q: debouncedSearchText,
countrycodes: countryIso,
format: 'json',
},
});

const handleOptionSelect = useCallback(
(
_: string | undefined,
__: string,
option: LocationSearchResult | undefined,
) => {
onResultSelect(option);
},
[onResultSelect],
);

return (
<SearchSelectInput
className={className}
name=""
// FIXME: use translations
placeholder="Search for a place"
readOnly={readOnly}
options={undefined}
value={undefined}
keySelector={keySelector}
labelSelector={labelSelector}
descriptionSelector={descriptionSelector}
onSearchValueChange={setSearchText}
searchOptions={options}
optionsPending={pending}
onChange={handleOptionSelect}
totalOptionsCount={options?.length ?? 0}
onShowDropdownChange={setOpened}
selectedOnTop={false}
icons={<SearchLineIcon />}
/>
);
}

export default LocationSearchInput;
26 changes: 24 additions & 2 deletions app/src/hooks/domain/usePermissions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { useMemo } from 'react';
import { isDefined } from '@togglecorp/fujs';
import {
isDefined,
isNotDefined,
} from '@togglecorp/fujs';

import { type GlobalEnums } from '#contexts/domain';
import useUserMe from '#hooks/domain/useUserMe';

type OrganizationType = NonNullable<GlobalEnums['api_profile_org_types']>[number]['key'];

const canEditLocalUnitOrganization: OrganizationType[] = ['NTLS', 'DLGN', 'SCRT'];

function usePermissions() {
const userMe = useUserMe();

Expand All @@ -20,7 +28,7 @@ function usePermissions() {
&& isDefined(countryId)
&& !!userMe?.is_admin_for_countries?.includes(countryId)
);
const isRegionAdmin = (regionId: number | undefined) => (
const isRegionAdmin = (regionId: number | null | undefined) => (
!isGuestUser
&& isDefined(regionId)
&& !!userMe?.is_admin_for_regions?.includes(regionId)
Expand Down Expand Up @@ -96,6 +104,19 @@ function usePermissions() {
)
);

const canEditLocalUnit = (
countryId: number | undefined,
) => {
if (isGuestUser
|| isNotDefined(countryId)
|| isNotDefined(userMe?.profile.org_type)) return false;

return (
userMe?.profile.country?.id === countryId
&& canEditLocalUnitOrganization.includes(userMe?.profile.org_type)
);
};

const isPerAdmin = !isGuestUser
&& ((userMe?.is_per_admin_for_countries.length ?? 0) > 0
|| (userMe?.is_per_admin_for_regions.length ?? 0) > 0);
Expand Down Expand Up @@ -126,6 +147,7 @@ function usePermissions() {
isSuperUser,
isGuestUser,
isRegionalOrCountryAdmin,
canEditLocalUnit,
};
},
[userMe],
Expand Down
Loading
Loading