Skip to content
Draft
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
73 changes: 61 additions & 12 deletions app/src/components/domain/BaseMapPointInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
useCallback,
useMemo,
useState,
} from 'react';
import { NumberInput } from '@ifrc-go/ui';
import { useTranslation } from '@ifrc-go/ui/hooks';
Expand All @@ -10,15 +11,19 @@ import {
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 @@ -35,15 +40,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 @@ -93,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 @@ -192,6 +205,27 @@ 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 (isNotDefined(searchResult)) {
return undefined;
}

return [+searchResult.lon, +searchResult.lat] satisfies [number, number];
}, [searchResult]);

return (
<div className={_cs(styles.baseMapPointInput, className)}>
<div className={styles.locationInputs}>
Expand Down Expand Up @@ -232,12 +266,21 @@ function BaseMapPointInput<NAME extends string>(props: Props<NAME>) {
/>
</DiffWrapper>
</div>
{isDefined(countryDetails) && (
<div className={styles.locationSearch}>
<LocationSearchInput
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 @@ -264,14 +307,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
12 changes: 12 additions & 0 deletions app/src/components/domain/BaseMapPointInput/styles.module.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
.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);
}
}
108 changes: 108 additions & 0 deletions app/src/components/domain/LocationSearchInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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;
2 changes: 1 addition & 1 deletion app/src/hooks/domain/usePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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
21 changes: 21 additions & 0 deletions app/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { listToMap } from '@togglecorp/fujs';

import { type components } from '#generated/types';

import { type GoApiResponse } from './restRequest';

export const defaultChartMargin = {
top: 0,
right: 0,
Expand Down Expand Up @@ -195,3 +197,22 @@ export const multiMonthSelectDefaultValue = listToMap(
export const ERU_READINESS_READY = 1;
export const ERU_READINESS_CAN_CONTRIBUTE = 2;
export const ERU_READINESS_NO_CAPACITY = 3;

// LocalUnits
type LocalUnitHealthFacilityTypeOptions = NonNullable<NonNullable<GoApiResponse<'/api/v2/local-units-options/'>['health_facility_type']>[number]>['id']

export const AMBULANCE_TYPE = 1 satisfies LocalUnitHealthFacilityTypeOptions;
export const HOSPITAL_TYPE = 3 satisfies LocalUnitHealthFacilityTypeOptions;
export const PRIMARY_HEALTH_TYPE = 5 satisfies LocalUnitHealthFacilityTypeOptions;
export const RESIDENTIAL_TYPE = 6 satisfies LocalUnitHealthFacilityTypeOptions;
export const TRAINING_FACILITY_TYPE = 7 satisfies LocalUnitHealthFacilityTypeOptions;
export const SPECIALIZED_SERVICES_TYPE = 8 satisfies LocalUnitHealthFacilityTypeOptions;
export const OTHER_TYPE = 9 satisfies LocalUnitHealthFacilityTypeOptions;

type LocalUnitTrainingFacilityTypeOptions = NonNullable<NonNullable<GoApiResponse<'/api/v2/local-units-options/'>['professional_training_facilities']>[number]>['id']

export const OTHER_TRAINING_FACILITIES = 9 satisfies LocalUnitTrainingFacilityTypeOptions;

type LocalUnitAffiliationOptions = NonNullable<NonNullable<GoApiResponse<'/api/v2/local-units-options/'>['affiliation']>[number]>['id']

export const OTHER_AFFILIATION = 9 satisfies LocalUnitAffiliationOptions;
4 changes: 2 additions & 2 deletions app/src/utils/localUnits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { type GoApiResponse } from './restRequest';

type LocalUnitResponse = NonNullable<GoApiResponse<'/api/v2/local-units/{id}/'>>;

export function getFormFields(value: LocalUnitResponse | PartialLocalUnits) {
export function getFormFields(value: LocalUnitResponse | PartialLocalUnits | undefined) {
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
created_at,
Expand All @@ -30,7 +30,7 @@ export function getFormFields(value: LocalUnitResponse | PartialLocalUnits) {
...formValues
// Note: the cast is safe as we're only trying to
// remove fields if they exist
} = removeNull(value) as LocalUnitResponse;
} = removeNull(value ?? {}) as LocalUnitResponse || undefined;

const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
12 changes: 11 additions & 1 deletion app/src/utils/restRequest/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,17 @@ const isSuccessfulStatus = (status: number): boolean => status >= 200 && status

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

const isContentTypeJson = (res: Response): boolean => res.headers.get('content-type') === CONTENT_TYPE_JSON;
const isContentTypeJson = (res: Response): boolean => {
const contentTypeHeaders = res.headers.get('content-type');

if (isNotDefined(contentTypeHeaders)) {
return false;
}

const mediaTypes = contentTypeHeaders.split('; ');

return mediaTypes[0]?.toLowerCase() === CONTENT_TYPE_JSON;
};

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

Expand Down
7 changes: 7 additions & 0 deletions app/src/utils/restRequest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
CustomLazyRequestReturn,
CustomRequestOptions,
CustomRequestReturn,
ExternalRequestOptions,
ExternalRequestReturn,
VALID_METHOD,
} from './overrideTypes';

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

const useExternalRequest = useRequest as <RESPONSE>(
requestOptions: ExternalRequestOptions<RESPONSE>,
) => ExternalRequestReturn<RESPONSE>;

export {
RequestContext,
useExternalRequest,
useGoLazyRequest as useLazyRequest,
useGoRequest as useRequest,
useRiskLazyRequest,
Expand Down
Loading
Loading