diff --git a/backend/core/src/app.module.ts b/backend/core/src/app.module.ts index 42b65fddcb..397560fa2c 100644 --- a/backend/core/src/app.module.ts +++ b/backend/core/src/app.module.ts @@ -37,6 +37,7 @@ import { PaperApplicationsModule } from "./paper-applications/paper-applications import { ActivityLogModule } from "./activity-log/activity-log.module" import { logger } from "./shared/middlewares/logger.middleware" import { CatchAllFilter } from "./shared/filters/catch-all-filter" +import { MapLayersModule } from "./map-layers/map-layers.module" export function applicationSetup(app: INestApplication) { const { httpAdapter } = app.get(HttpAdapterHost) @@ -106,6 +107,7 @@ export class AppModule { UnitTypesModule, UnitRentTypesModule, UnitAccessibilityPriorityTypesModule, + MapLayersModule, ], } } diff --git a/backend/core/src/map-layers/dto/map-layer.dto.ts b/backend/core/src/map-layers/dto/map-layer.dto.ts new file mode 100644 index 0000000000..a397aff273 --- /dev/null +++ b/backend/core/src/map-layers/dto/map-layer.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer" +import { IsString, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class MapLayerDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + name: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionId: string +} diff --git a/backend/core/src/map-layers/dto/map-layers-query-params.ts b/backend/core/src/map-layers/dto/map-layers-query-params.ts new file mode 100644 index 0000000000..a817a2dbc3 --- /dev/null +++ b/backend/core/src/map-layers/dto/map-layers-query-params.ts @@ -0,0 +1,16 @@ +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class MapLayersQueryParams { + @Expose() + @ApiProperty({ + name: "jurisdictionId", + required: false, + type: String, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionId?: string +} diff --git a/backend/core/src/map-layers/entities/map-layer.entity.ts b/backend/core/src/map-layers/entities/map-layer.entity.ts new file mode 100644 index 0000000000..2417c9abf3 --- /dev/null +++ b/backend/core/src/map-layers/entities/map-layer.entity.ts @@ -0,0 +1,23 @@ +import { Entity, PrimaryGeneratedColumn, Column } from "typeorm" +import { Expose } from "class-transformer" +import { IsString, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +@Entity({ name: "map_layers" }) +export class MapLayer { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @Expose() + @Column() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + name: string + + @Expose() + @Column() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionId: string +} diff --git a/backend/core/src/map-layers/map-layers.controller.ts b/backend/core/src/map-layers/map-layers.controller.ts new file mode 100644 index 0000000000..cc25386ebb --- /dev/null +++ b/backend/core/src/map-layers/map-layers.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { MapLayersService } from "./map-layers.service" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { mapTo } from "../shared/mapTo" +import { MapLayerDto } from "./dto/map-layer.dto" +import { MapLayersQueryParams } from "./dto/map-layers-query-params" + +@Controller("/mapLayers") +@ApiTags("mapLayers") +@ApiBearerAuth() +@ResourceType("mapLayer") +@UseGuards(OptionalAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class MapLayersController { + constructor(private readonly mapLayerService: MapLayersService) {} + + @Get() + @ApiOperation({ summary: "List map layers", operationId: "list" }) + async list(@Query() queryParams: MapLayersQueryParams): Promise { + return mapTo(MapLayerDto, await this.mapLayerService.list(queryParams)) + } +} diff --git a/backend/core/src/map-layers/map-layers.module.ts b/backend/core/src/map-layers/map-layers.module.ts new file mode 100644 index 0000000000..3bb84ed481 --- /dev/null +++ b/backend/core/src/map-layers/map-layers.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { MapLayersService } from "./map-layers.service" +import { MapLayersController } from "./map-layers.controller" +import { TypeOrmModule } from "@nestjs/typeorm" +import { MapLayer } from "./entities/map-layer.entity" +import { AuthModule } from "../auth/auth.module" + +@Module({ + imports: [TypeOrmModule.forFeature([MapLayer]), AuthModule], + providers: [MapLayersService], + controllers: [MapLayersController], +}) +export class MapLayersModule {} diff --git a/backend/core/src/map-layers/map-layers.service.ts b/backend/core/src/map-layers/map-layers.service.ts new file mode 100644 index 0000000000..961ff29ec2 --- /dev/null +++ b/backend/core/src/map-layers/map-layers.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { MapLayer } from "./entities/map-layer.entity" +import { MapLayersQueryParams } from "./dto/map-layers-query-params" + +@Injectable() +export class MapLayersService { + constructor( + @InjectRepository(MapLayer) + private readonly mapLayerRepository: Repository + ) {} + + list(queryParams: MapLayersQueryParams): Promise { + if (queryParams.jurisdictionId) { + return this.mapLayerRepository.find({ where: { jurisdictionId: queryParams.jurisdictionId } }) + } + return this.mapLayerRepository.find() + } +} diff --git a/backend/core/src/migration/1704908499461-addMapLayers.ts b/backend/core/src/migration/1704908499461-addMapLayers.ts new file mode 100644 index 0000000000..5945636621 --- /dev/null +++ b/backend/core/src/migration/1704908499461-addMapLayers.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addMapLayers1704908499461 implements MigrationInterface { + name = "addMapLayers1704908499461" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "map_layers" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "jurisdiction_id" character varying NOT NULL, CONSTRAINT "PK_d1bcb10041ba88ffea330dc10d9" PRIMARY KEY ("id"))` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "map_layers"`) + } +} diff --git a/backend/core/src/multiselect-question/types/multiselect-option.ts b/backend/core/src/multiselect-question/types/multiselect-option.ts index c4da532868..16296fba79 100644 --- a/backend/core/src/multiselect-question/types/multiselect-option.ts +++ b/backend/core/src/multiselect-question/types/multiselect-option.ts @@ -56,6 +56,12 @@ export class MultiselectOption { @ApiProperty({ required: false }) radiusSize?: number + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ required: false }) + mapLayerId?: string + @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/src/seeder/seed.ts b/backend/core/src/seeder/seed.ts index 719945dc38..ab86127752 100644 --- a/backend/core/src/seeder/seed.ts +++ b/backend/core/src/seeder/seed.ts @@ -54,6 +54,7 @@ import { UnitTypesService } from "../unit-types/unit-types.service" import dayjs from "dayjs" import { CountyCode } from "../shared/types/county-code" import { ApplicationFlaggedSetsCronjobService } from "../application-flagged-sets/application-flagged-sets-cronjob.service" +import { MapLayerSeeder } from "./seeds/map-layers" const argv = yargs.scriptName("seed").options({ test: { type: "boolean", default: false }, @@ -224,6 +225,9 @@ async function seed() { await seedAmiCharts(app) const listings = await seedListings(app, rolesRepo, jurisdictions) + const mapLayerSeeder = app.get(MapLayerSeeder) + await mapLayerSeeder.seed(jurisdictions) + const user1 = await userService.createPublicUser( plainToClass(UserCreateDto, { email: "test@example.com", diff --git a/backend/core/src/seeder/seeder.module.ts b/backend/core/src/seeder/seeder.module.ts index 83009bdaac..6f5944b17b 100644 --- a/backend/core/src/seeder/seeder.module.ts +++ b/backend/core/src/seeder/seeder.module.ts @@ -52,6 +52,8 @@ import { AmiDefaultTritonDetroit } from "../seeder/seeds/ami-charts/triton-ami-c import { AmiDefaultSanJose } from "../seeder/seeds/ami-charts/default-ami-chart-san-jose" import { AmiDefaultSanMateo } from "../seeder/seeds/ami-charts/default-ami-chart-san-mateo" import { Asset } from "../assets/entities/asset.entity" +import { MapLayer } from "../map-layers/entities/map-layer.entity" +import { MapLayerSeeder } from "./seeds/map-layers" @Module({}) export class SeederModule { @@ -80,6 +82,7 @@ export class SeederModule { ApplicationMethod, PaperApplication, Jurisdiction, + MapLayer, ]), ThrottlerModule.forRoot({ ttl: 60, @@ -119,6 +122,7 @@ export class SeederModule { AmiDefaultTritonDetroit, AmiDefaultSanJose, AmiDefaultSanMateo, + MapLayerSeeder, ], } } diff --git a/backend/core/src/seeder/seeds/map-layers.ts b/backend/core/src/seeder/seeds/map-layers.ts new file mode 100644 index 0000000000..87fd08043c --- /dev/null +++ b/backend/core/src/seeder/seeds/map-layers.ts @@ -0,0 +1,38 @@ +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { MapLayer } from "../../map-layers/entities/map-layer.entity" +import { Jurisdiction } from "../../../types" + +export class MapLayerSeeder { + constructor( + @InjectRepository(MapLayer) + protected readonly mapLayerRepository: Repository + ) {} + + async seed(jurisdictions: Jurisdiction[]) { + const mapLayers = [ + { + name: "Map Layer 1", + jurisdictionId: jurisdictions?.[0]?.id ?? "1", + }, + { + name: "Map Layer 2", + jurisdictionId: jurisdictions?.[0]?.id ?? "1", + }, + { + name: "Map Layer 3", + jurisdictionId: jurisdictions?.[0]?.id ?? "1", + }, + { + name: "Map Layer 4", + jurisdictionId: jurisdictions?.[1]?.id ?? "2", + }, + { + name: "Map Layer 5", + jurisdictionId: jurisdictions?.[2]?.id ?? "3", + }, + ] + + await this.mapLayerRepository.save(mapLayers) + } +} diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index 05d4f7e64a..0703790b18 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -2329,6 +2329,30 @@ export class UnitAccessibilityPriorityTypesService { } } +export class MapLayersService { + /** + * List map layers + */ + list( + params: { + /** */ + jurisdictionId?: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/mapLayers" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { jurisdictionId: params["jurisdictionId"] } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + export interface AmiChartItem { /** */ percentOfAmi: number @@ -4532,6 +4556,9 @@ export interface MultiselectOption { /** */ radiusSize?: number + /** */ + mapLayerId?: string + /** */ collectName?: boolean @@ -6186,6 +6213,17 @@ export interface UnitAccessibilityPriorityTypeUpdate { id: string } +export interface MapLayer { + /** */ + id: string + + /** */ + name: string + + /** */ + jurisdictionId: string +} + export enum IncomePeriod { "perMonth" = "perMonth", "perYear" = "perYear", diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index 9594e411c7..f3bff26f8f 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -19,6 +19,7 @@ import { RequestMfaCodeResponse, EnumRequestMfaCodeMfaType, EnumLoginMfaType, + MapLayersService, } from "@bloom-housing/backend-core/types" import { GenericRouter, NavigationContext } from "@bloom-housing/ui-components" import { @@ -46,9 +47,10 @@ type ContextProps = { userProfileService: UserProfileService authService: AuthService multiselectQuestionsService: MultiselectQuestionsService + unitTypesService: UnitTypesService reservedCommunityTypeService: ReservedCommunityTypesService unitPriorityService: UnitAccessibilityPriorityTypesService - unitTypesService: UnitTypesService + mapLayersService: MapLayersService loadProfile: (redirect?: string) => void login: ( email: string, @@ -213,6 +215,7 @@ export const AuthProvider: FunctionComponent = ({ child userProfileService: new UserProfileService(), authService: new AuthService(), multiselectQuestionsService: new MultiselectQuestionsService(), + mapLayersService: new MapLayersService(), reservedCommunityTypeService: new ReservedCommunityTypesService(), unitPriorityService: new UnitAccessibilityPriorityTypesService(), unitTypesService: new UnitTypesService(), diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json index e2838522b7..df139e8ba3 100644 --- a/sites/partners/page_content/locale_overrides/general.json +++ b/sites/partners/page_content/locale_overrides/general.json @@ -364,8 +364,11 @@ "settings.preferenceShowOnListing": "Show preference on listing?", "settings.preferenceValidatingAddress": "Do you need help validating the address?", "settings.preferenceValidatingAddress.checkWithinRadius": "Yes, check if within geographic radius of property", + "settings.preferenceValidatingAddress.checkWithArcGisMap": "Yes, check with ArcGIS map", "settings.preferenceValidatingAddress.checkManually": "No, will check manually", "settings.preferenceValidatingAddress.howManyMiles": "How many miles is the qualifying geographic radius?", + "settings.preferenceValidatingAddress.selectMapLayer": "Select a map layer", + "settings.preferenceValidatingAddress.selectMapLayerDescription": "Select your map layer based on your district. If you don't see your map contact us", "settings.preferenceDeleteConfirmation": "Deleting a preference cannot be undone.", "settings.preferenceChangesRequired": "Changes required before deleting", "settings.preferenceDeleteError": "This preference is currently added to listings and needs to be removed before being deleted.", diff --git a/sites/partners/src/components/settings/PreferenceDrawer.module.scss b/sites/partners/src/components/settings/PreferenceDrawer.module.scss new file mode 100644 index 0000000000..9b4b2f9dd7 --- /dev/null +++ b/sites/partners/src/components/settings/PreferenceDrawer.module.scss @@ -0,0 +1,4 @@ +.helperText { + font-size: var(--seeds-font-size-2xs); + color: var(--field-value-help-text-color); +} diff --git a/sites/partners/src/components/settings/PreferenceDrawer.tsx b/sites/partners/src/components/settings/PreferenceDrawer.tsx index 1054cfe70a..d54603f04f 100644 --- a/sites/partners/src/components/settings/PreferenceDrawer.tsx +++ b/sites/partners/src/components/settings/PreferenceDrawer.tsx @@ -24,6 +24,8 @@ import { import ManageIconSection from "./ManageIconSection" import { DrawerType } from "../../pages/settings/index" import SectionWithGrid from "../shared/SectionWithGrid" +import s from "./PreferenceDrawer.module.scss" +import { useMapLayersList } from "../../lib/hooks" type PreferenceDrawerProps = { drawerOpen: boolean @@ -49,6 +51,7 @@ type OptionForm = { optionLinkTitle: string optionTitle: string optionUrl: string + mapLayerId?: string } const PreferenceDrawer = ({ @@ -65,7 +68,6 @@ const PreferenceDrawer = ({ const [dragOrder, setDragOrder] = useState([]) const { profile } = useContext(AuthContext) - // eslint-disable-next-line @typescript-eslint/unbound-method const { register, @@ -79,6 +81,8 @@ const PreferenceDrawer = ({ formState, } = useForm() + const { mapLayers } = useMapLayersList(watch("jurisdictionId")) + useEffect(() => { if (!optOutQuestion) { setValue( @@ -104,6 +108,11 @@ const PreferenceDrawer = ({ watch("validationMethod") === undefined) || watch("validationMethod") === ValidationMethod.radius + const mapExpand = + (optionData?.validationMethod === ValidationMethod.map && + watch("validationMethod") === undefined) || + watch("validationMethod") === ValidationMethod.map + // Update local state with dragged state useEffect(() => { if (questionData?.options?.length > 0 && dragOrder?.length > 0) { @@ -629,6 +638,18 @@ const PreferenceDrawer = ({ }, }, }, + { + label: t("settings.preferenceValidatingAddress.checkWithArcGisMap"), + value: ValidationMethod.map, + defaultChecked: optionData?.validationMethod === ValidationMethod.map, + id: "validationMethodMap", + dataTestId: "validation-method-map", + inputProps: { + onChange: () => { + clearErrors("validationMethod") + }, + }, + }, { label: t("settings.preferenceValidatingAddress.checkManually"), value: ValidationMethod.none, @@ -670,6 +691,38 @@ const PreferenceDrawer = ({ /> )} + {collectAddressExpand && mapExpand && ( + +

+ {t("settings.preferenceValidatingAddress.selectMapLayerDescription")} +

+