diff --git a/backend/core/src/applications/services/applications.service.ts b/backend/core/src/applications/services/applications.service.ts index 45060cad09..6735fdac4b 100644 --- a/backend/core/src/applications/services/applications.service.ts +++ b/backend/core/src/applications/services/applications.service.ts @@ -235,6 +235,17 @@ export class ApplicationsService { return await applicationsRepository.findOne({ where: { id: newApplication.id } }) } ) + + const listing = await this.listingsService.findOne(application.listingId) + + // Calculate geocoding preferences after save + if (listing.jurisdiction?.enableGeocodingPreferences) { + try { + void this.geocodingService.validateGeocodingPreferences(application, listing) + } catch (e) { + console.warn("error while validating geocoding preferences") + } + } return app } diff --git a/backend/core/src/applications/types/application-multiselect-question-option.ts b/backend/core/src/applications/types/application-multiselect-question-option.ts index f108c4133f..a2109dbf8c 100644 --- a/backend/core/src/applications/types/application-multiselect-question-option.ts +++ b/backend/core/src/applications/types/application-multiselect-question-option.ts @@ -1,5 +1,5 @@ import { Expose, Type } from "class-transformer" -import { ArrayMaxSize, IsBoolean, IsString, ValidateNested } from "class-validator" +import { ArrayMaxSize, IsBoolean, IsOptional, IsString, ValidateNested } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { ApiProperty, getSchemaPath } from "@nestjs/swagger" import { BooleanInput } from "./form-metadata/boolean-input" @@ -19,6 +19,12 @@ export class ApplicationMultiselectQuestionOption { @ApiProperty() checked: boolean + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ required: false }) + mapPinPosition?: string + @Expose() @ApiProperty({ type: "array", diff --git a/backend/core/src/multiselect-question/types/multiselect-option.ts b/backend/core/src/multiselect-question/types/multiselect-option.ts index 16296fba79..5a04a75a7b 100644 --- a/backend/core/src/multiselect-question/types/multiselect-option.ts +++ b/backend/core/src/multiselect-question/types/multiselect-option.ts @@ -74,6 +74,12 @@ export class MultiselectOption { @ApiProperty({ required: false }) collectRelationship?: boolean + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ required: false }) + mapPinPosition?: string + @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index 0703790b18..499a2ec7bb 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -2674,6 +2674,9 @@ export interface ApplicationMultiselectQuestionOption { /** */ checked: boolean + /** */ + mapPinPosition?: string + /** */ extraData?: AllExtraDataTypes[] } @@ -4565,6 +4568,9 @@ export interface MultiselectOption { /** */ collectRelationship?: boolean + /** */ + mapPinPosition?: string + /** */ exclusive?: boolean } diff --git a/shared-helpers/src/views/multiselectQuestions.tsx b/shared-helpers/src/views/multiselectQuestions.tsx index 03e8a1005e..43234ba2aa 100644 --- a/shared-helpers/src/views/multiselectQuestions.tsx +++ b/shared-helpers/src/views/multiselectQuestions.tsx @@ -358,7 +358,7 @@ export const mapCheckboxesToApi = ( const addressHolderRelationshipData = addressFields.filter( (addressField) => addressField === `${key}-${AddressHolder.Relationship}` ) - if (addressData.length) { + if (data[key] === true && addressData.length) { extraData.push({ type: InputType.address, key: "address", value: data[addressData[0]] }) if (addressHolderNameData.length) { @@ -380,6 +380,7 @@ export const mapCheckboxesToApi = ( return { key, + mapPinPosition: data?.[`${key}-mapPinPosition`], checked: data[key] === true, extraData: extraData, } @@ -450,6 +451,9 @@ export const mapApiToMultiselectForm = ( if (addressHolderRelationship) { acc[`${curr.key}-${AddressHolder.Relationship}`] = addressHolderRelationship.value } + if (curr?.mapPinPosition) { + acc[`${curr.key}-mapPinPosition`] = curr.mapPinPosition + } } } diff --git a/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx b/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx new file mode 100644 index 0000000000..c78795d458 --- /dev/null +++ b/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useState } from "react" +import { FieldGroup, LatitudeLongitude, ListingMap, t } from "@bloom-housing/ui-components" +import { FieldValue, Grid } from "@bloom-housing/ui-seeds" +import { useFormContext, useWatch } from "react-hook-form" +import { GeocodeService as GeocodeServiceType } from "@mapbox/mapbox-sdk/services/geocoding" + +interface MapBoxFeature { + center: number[] // Index 0: longitude, Index 1: latitude +} + +interface MapboxApiResponseBody { + features: MapBoxFeature[] +} + +interface MapboxApiResponse { + body: MapboxApiResponseBody +} + +interface BuildingAddress { + city: string + state: string + street: string + zipCode: string + longitude?: number + latitude?: number +} + +type MultiselectQuestionsMapProps = { + geocodingClient: GeocodeServiceType + dataKey: string +} + +const MultiselectQuestionsMap = ({ geocodingClient, dataKey }: MultiselectQuestionsMapProps) => { + const [customMapPositionChosen, setCustomMapPositionChosen] = useState(true) + const formMethods = useFormContext() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, control, getValues, setValue, watch } = formMethods + + const buildingAddress: BuildingAddress = useWatch({ + control, + name: `${dataKey}-address`, + }) + const mapPinPosition = useWatch({ + control, + name: `${dataKey}-mapPinPosition`, + }) + + const [latLong, setLatLong] = useState({ + latitude: buildingAddress?.latitude ?? null, + longitude: buildingAddress?.longitude ?? null, + }) + + const displayMapPreview = () => { + return ( + buildingAddress?.city && + buildingAddress?.state && + buildingAddress?.street && + buildingAddress?.zipCode && + buildingAddress?.zipCode.length >= 5 + ) + } + + const getNewLatLong = () => { + if ( + buildingAddress?.city && + buildingAddress?.state && + buildingAddress?.street && + buildingAddress?.zipCode && + geocodingClient + ) { + geocodingClient + .forwardGeocode({ + query: `${buildingAddress.street}, ${buildingAddress.city}, ${buildingAddress.state}, ${buildingAddress.zipCode}`, + limit: 1, + }) + .send() + .then((response: MapboxApiResponse) => { + setLatLong({ + latitude: response.body.features[0].center[1], + longitude: response.body.features[0].center[0], + }) + }) + .catch((err) => console.error(`Error calling Mapbox API: ${err}`)) + } + } + + if ( + getValues(`${dataKey}-address.latitude`) !== latLong.latitude || + getValues(`${dataKey}-address.longitude`) !== latLong.longitude + ) { + setValue(`${dataKey}-address.latitude`, latLong.latitude) + setValue(`${dataKey}-address.longitude`, latLong.longitude) + } + + useEffect(() => { + if (watch(dataKey)) { + register(`${dataKey}-address.longitude`) + register(`${dataKey}-address.latitude`) + } + }, [dataKey, register, setValue, watch]) + + useEffect(() => { + let timeout + if (!customMapPositionChosen || mapPinPosition === "automatic") { + timeout = setTimeout(() => { + getNewLatLong() + }, 1000) + } + return () => { + clearTimeout(timeout) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + buildingAddress?.city, + buildingAddress?.state, + buildingAddress?.street, + buildingAddress?.zipCode, + ]) + + useEffect(() => { + if (mapPinPosition === "automatic") { + getNewLatLong() + setCustomMapPositionChosen(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapPinPosition]) + + return ( + <> + + + {displayMapPreview() ? ( + + ) : ( +
+ {t("listings.mapPreviewNoAddress")} +
+ )} +
+
+ +

{t("listings.mapPinPosition")}

+
+ + + + + ) +} + +export default MultiselectQuestionsMap diff --git a/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx b/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx index 6f8c6546f1..cac836d64c 100644 --- a/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx +++ b/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react" +import React, { useEffect, useMemo, useState } from "react" import { Field, t, FieldGroup, resolveObject } from "@bloom-housing/ui-components" import { FieldValue, Grid } from "@bloom-housing/ui-seeds" import { useFormContext } from "react-hook-form" @@ -11,6 +11,10 @@ import { } from "@bloom-housing/backend-core/types" import SectionWithGrid from "../../../shared/SectionWithGrid" import { FormAddressAlternate } from "@bloom-housing/shared-helpers/src/views/address/FormAddressAlternate" +import GeocodeService, { + GeocodeService as GeocodeServiceType, +} from "@mapbox/mapbox-sdk/services/geocoding" +import MultiselectQuestionsMap from "../MultiselectQuestionsMap" type FormMultiselectQuestionsProps = { questions: ListingMultiselectQuestion[] @@ -45,6 +49,18 @@ const FormMultiselectQuestions = ({ return keys }, [questions, applicationSection]) + const [geocodingClient, setGeocodingClient] = useState() + + useEffect(() => { + if (process.env.mapBoxToken || process.env.MAPBOX_TOKEN) { + setGeocodingClient( + GeocodeService({ + accessToken: process.env.mapBoxToken || process.env.MAPBOX_TOKEN, + }) + ) + } + }, []) + if (questions?.length === 0) { return null } @@ -106,6 +122,10 @@ const FormMultiselectQuestions = ({ stateKeys={stateKeys} data-testid={"app-question-extra-field"} /> + )}