From 6526bc8987a01e2b97b9e84c8a6a41d1339ec9f2 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Tue, 28 Oct 2025 17:29:42 +0545 Subject: [PATCH 1/7] Add location search in the local unit form --- .../domain/BaseMapPointInput/index.tsx | 73 ++++++++++-- .../BaseMapPointInput/styles.module.css | 12 ++ .../domain/LocationSearchInput/index.tsx | 104 ++++++++++++++++++ app/src/utils/restRequest/go.ts | 12 +- app/src/utils/restRequest/index.ts | 7 ++ app/src/utils/restRequest/overrideTypes.ts | 7 ++ .../LocalUnitsForm/index.tsx | 1 + 7 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 app/src/components/domain/LocationSearchInput/index.tsx diff --git a/app/src/components/domain/BaseMapPointInput/index.tsx b/app/src/components/domain/BaseMapPointInput/index.tsx index 22bb28ba91..5c11ba9ace 100644 --- a/app/src/components/domain/BaseMapPointInput/index.tsx +++ b/app/src/components/domain/BaseMapPointInput/index.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, + useState, } from 'react'; import { NumberInput } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; @@ -10,6 +11,7 @@ import { isNotDefined, } from '@togglecorp/fujs'; import { + MapCenter, MapContainer, MapLayer, MapSource, @@ -17,8 +19,11 @@ import { 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, @@ -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; interface Props extends BaseMapProps { @@ -93,17 +117,6 @@ function BaseMapPointInput(props: Props) { 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( () => { if (isNotDefined(value) || isNotDefined(value.lng) || isNotDefined(value.lat)) { @@ -192,6 +205,27 @@ function BaseMapPointInput(props: Props) { [value, onChange, name], ); + const bounds = useMemo( + () => { + if (isNotDefined(countryDetails)) { + return undefined; + } + + return getBbox(countryDetails.bbox); + }, + [countryDetails], + ); + + const [searchResult, setSearchResult] = useState(); + + const center = useMemo(() => { + if (isNotDefined(searchResult)) { + return undefined; + } + + return [+searchResult.lon, +searchResult.lat] satisfies [number, number]; + }, [searchResult]); + return (
@@ -232,12 +266,21 @@ function BaseMapPointInput(props: Props) { />
+ {isDefined(countryDetails) && ( +
+ +
+ )} (props: Props) { (props: Props) { /> )} + {center && ( + + )} {children}
diff --git a/app/src/components/domain/BaseMapPointInput/styles.module.css b/app/src/components/domain/BaseMapPointInput/styles.module.css index c07f3b9f5e..deefdb1604 100644 --- a/app/src/components/domain/BaseMapPointInput/styles.module.css +++ b/app/src/components/domain/BaseMapPointInput/styles.module.css @@ -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); + } } diff --git a/app/src/components/domain/LocationSearchInput/index.tsx b/app/src/components/domain/LocationSearchInput/index.tsx new file mode 100644 index 0000000000..507a2cf0f4 --- /dev/null +++ b/app/src/components/domain/LocationSearchInput/index.tsx @@ -0,0 +1,104 @@ +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[]; + 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; +} + +function LocationSearchInput(props: Props) { + const { + className, + onResultSelect, + countryIso, + } = props; + + const [opened, setOpened] = useState(false); + const [searchText, setSearchText] = useState(undefined); + + const debouncedSearchText = useDebouncedValue( + searchText?.trim() ?? '', + ); + + const { + pending, + response: options, + } = useExternalRequest({ + 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 ( + } + /> + ); +} + +export default LocationSearchInput; diff --git a/app/src/utils/restRequest/go.ts b/app/src/utils/restRequest/go.ts index 1779c29117..bb81ab09d4 100644 --- a/app/src/utils/restRequest/go.ts +++ b/app/src/utils/restRequest/go.ts @@ -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'); diff --git a/app/src/utils/restRequest/index.ts b/app/src/utils/restRequest/index.ts index ad31e30028..f8ebd92b38 100644 --- a/app/src/utils/restRequest/index.ts +++ b/app/src/utils/restRequest/index.ts @@ -15,6 +15,8 @@ import type { CustomLazyRequestReturn, CustomRequestOptions, CustomRequestReturn, + ExternalRequestOptions, + ExternalRequestReturn, VALID_METHOD, } from './overrideTypes'; @@ -70,8 +72,13 @@ const useRiskLazyRequest = useLazyRequest as < requestOptions: CustomLazyRequestOptions & { apiType: 'risk' } ) => CustomLazyRequestReturn; +const useExternalRequest = useRequest as ( + requestOptions: ExternalRequestOptions, +) => ExternalRequestReturn; + export { RequestContext, + useExternalRequest, useGoLazyRequest as useLazyRequest, useGoRequest as useRequest, useRiskLazyRequest, diff --git a/app/src/utils/restRequest/overrideTypes.ts b/app/src/utils/restRequest/overrideTypes.ts index 974ec91371..9911d6d96e 100644 --- a/app/src/utils/restRequest/overrideTypes.ts +++ b/app/src/utils/restRequest/overrideTypes.ts @@ -270,6 +270,11 @@ type LazyRequestOptionsBase = { OptionOmissions >; +export type ExternalRequestOptions = Pick< +RequestOptions, +'query' | 'url' | 'skip' +>; + export type CustomRequestOptions< SCHEMA extends object, PATH extends keyof SCHEMA, @@ -327,6 +332,8 @@ type LazyRequestReturn = ReturnType>; +export type ExternalRequestReturn = RequestReturn; + export type CustomRequestReturn< SCHEMA, PATH extends keyof SCHEMA, diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx index 9efa44c010..ab380bda21 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx @@ -217,6 +217,7 @@ function LocalUnitsForm(props: Props) { const { response: localUnitPreviousResponse, } = useRequest({ + skip: isNotDefined(localUnitId), url: '/api/v2/local-units/{id}/latest-change-request/', pathVariables: isDefined(localUnitId) ? { id: localUnitId } : undefined, }); From 2bff234753465ad9f819d379c5699c870c89d5a4 Mon Sep 17 00:00:00 2001 From: Shreeyash Shrestha Date: Mon, 13 Oct 2025 09:55:04 +0545 Subject: [PATCH 2/7] feat(local-units): Update forms fields ordering --- app/src/utils/constants.ts | 17 + .../LocalUnitsForm/i18n.json | 1 + .../LocalUnitsForm/index.tsx | 1665 +++++++++-------- .../LocalUnitsForm/styles.module.css | 10 + 4 files changed, 894 insertions(+), 799 deletions(-) diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index 79eee57999..850e538bd1 100644 --- a/app/src/utils/constants.ts +++ b/app/src/utils/constants.ts @@ -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, @@ -195,3 +197,18 @@ 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['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 LocalUnitAffiliationOptions = NonNullable['affiliation']>[number]>['id'] + +export const OTHER_AFFILIATION = 9 satisfies LocalUnitAffiliationOptions; diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/i18n.json index cd3baabd48..7e32e47190 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/i18n.json +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/i18n.json @@ -19,6 +19,7 @@ "sourceEn": "Source (En)", "sourceLocal": "Source (Local)", "addressAndContactTitle": "Address and Contact", + "qualifiersTitle": "Qualifiers", "humanResourcesTitle": "Human Resources", "addressEn": "Address (En)", "addressLocal": "Address (Local)", diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx index ab380bda21..42341cadaa 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx @@ -9,6 +9,7 @@ import { useOutletContext } from 'react-router-dom'; import { BooleanInput, Button, + Checklist, Container, DateInput, DateOutput, @@ -54,7 +55,17 @@ import useGlobalEnums from '#hooks/domain/useGlobalEnums'; import usePermissions from '#hooks/domain/usePermissions'; import useAlert from '#hooks/useAlert'; import { getFirstTruthyString } from '#utils/common'; -import { VISIBILITY_PUBLIC } from '#utils/constants'; +import { + AMBULANCE_TYPE, + HOSPITAL_TYPE, + OTHER_AFFILIATION, + OTHER_TYPE, + PRIMARY_HEALTH_TYPE, + RESIDENTIAL_TYPE, + SPECIALIZED_SERVICES_TYPE, + TRAINING_FACILITY_TYPE, + VISIBILITY_PUBLIC, +} from '#utils/constants'; import { getUserName } from '#utils/domain/user'; import { type CountryOutletContext } from '#utils/outletContext'; import { @@ -596,12 +607,15 @@ function LocalUnitsForm(props: Props) { )} + {/* Address and Contact */} + {value.type !== TYPE_HEALTH_CARE && ( + + + + )} {value.type !== TYPE_HEALTH_CARE && ( - - - + <> + {hasUpdatePermission && ( + <> + + + + + + + + )} + + + + + )} + + + + {value.type === TYPE_HEALTH_CARE && ( + <> + + + + {hasUpdatePermission && ( + <> + + + + + + + + )} + )} {value.type !== TYPE_HEALTH_CARE && hasUpdatePermission && ( <> @@ -814,23 +971,25 @@ function LocalUnitsForm(props: Props) { error={healthFormError?.affiliation} /> - - - + previousValue={previousData?.health?.other_affiliation} + diffViewEnabled={showChanges} + className={styles.diffContainer} + > + + + )} - - - - - - - - - - - - )} @@ -959,36 +1035,7 @@ function LocalUnitsForm(props: Props) { readOnly /> - - - - - - - +
+
+
- - - - - - {value.type !== TYPE_HEALTH_CARE && ( - <> - {hasUpdatePermission && ( - <> - - - - - - - - )} - - - - - )} - {value.type === TYPE_HEALTH_CARE && ( - <> - - - - {hasUpdatePermission && ( - <> - - - - - - - - )} - - )} - - - - {value.type === TYPE_HEALTH_CARE && ( - <> - - )} - > - - - + + + + + + {/* Specialities and Capacity */} + {value.type === TYPE_HEALTH_CARE && ( + <> + + )} + > + + + + - - + readOnly={readOnly} + error={healthFormError?.health_facility_type} + /> + + {value.health?.health_facility_type === OTHER_TYPE && ( @@ -1263,10 +1187,14 @@ function LocalUnitsForm(props: Props) { error={healthFormError?.other_facility_type} /> + )} + {value?.health + ?.health_facility_type === PRIMARY_HEALTH_TYPE && ( + )} + {value.health + ?.health_facility_type === SPECIALIZED_SERVICES_TYPE && ( - - - + + )} + {value.health?.health_facility_type === AMBULANCE_TYPE && ( + <> + + + + + + + + + + + )} + {value.health + ?.health_facility_type === TRAINING_FACILITY_TYPE && ( - + )} + + + + + + + + + + + + + + + + + + + - - - - - + - - - - - - - - - - - - - - - - - - - + {value.health?.is_in_patient_capacity && ( + + + )} - /> - - - + + )} - /> - + + )} + + + {value?.health?.health_facility_type === HOSPITAL_TYPE && ( + + + + )} + {(value?.health?.health_facility_type === HOSPITAL_TYPE + || value?.health + ?.health_facility_type === PRIMARY_HEALTH_TYPE + || value?.health + ?.health_facility_type === SPECIALIZED_SERVICES_TYPE + || value?.health + ?.health_facility_type === RESIDENTIAL_TYPE + || value?.health + ?.health_facility_type === OTHER_TYPE + ) && ( + <> + + + + + + + + )} - - - - + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - - + + + + + - - - - - - - - - - - + onChange={onHealthFieldChange} + readOnly={readOnly} + error={getErrorString( + healthFormError?.dentist, + )} + /> + - - - - - + + + -