From d4b25930c59a4b9b3e58c3951a6b131a185e5b6b Mon Sep 17 00:00:00 2001 From: krzysztof ziecina Date: Tue, 4 Nov 2025 08:03:01 +0100 Subject: [PATCH 1/6] feat: add visible neighborhood amenities to jurisdictions and update related components --- .../migration.sql | 6 + api/prisma/schema.prisma | 88 +++++----- .../dtos/jurisdictions/jurisdiction.dto.ts | 20 ++- shared-helpers/src/types/backend-swagger.ts | 18 +++ .../sections/DetailNeighborhoodAmenities.tsx | 136 +++++++++------- .../sections/NeighborhoodAmenities.tsx | 153 ++++++++++-------- .../components/listing/ListingViewSeeds.tsx | 1 + .../listing/listing_sections/Neighborhood.tsx | 16 +- 8 files changed, 264 insertions(+), 174 deletions(-) create mode 100644 api/prisma/migrations/36_add_visible_neighborhood_amenities/migration.sql diff --git a/api/prisma/migrations/36_add_visible_neighborhood_amenities/migration.sql b/api/prisma/migrations/36_add_visible_neighborhood_amenities/migration.sql new file mode 100644 index 00000000000..fb83a489680 --- /dev/null +++ b/api/prisma/migrations/36_add_visible_neighborhood_amenities/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "neighborhood_amenities_enum" AS ENUM ('groceryStores', 'publicTransportation', 'schools', 'parksAndCommunityCenters', 'pharmacies', 'healthCareResources'); + +-- AlterTable +ALTER TABLE "jurisdictions" ADD COLUMN "visible_neighborhood_amenities" "neighborhood_amenities_enum"[] DEFAULT ARRAY['groceryStores', 'publicTransportation', 'schools', 'parksAndCommunityCenters', 'pharmacies', 'healthCareResources']::"neighborhood_amenities_enum"[]; + diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index e5a3c569802..a33a38e8130 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -178,12 +178,12 @@ model ApplicationSelectionOptions { addressHolderAddressId String? @unique() @map("address_holder_address_id") @db.Uuid addressHolderName String? @map("address_holder_name") addressHolderRelationship String? @map("address_holder_relationship") - applicationSelectionId String @map("application_selection_id") @db.Uuid - isGeocodingVerified Boolean? @map("is_geocoding_verified") - multiselectOptionId String @map("multiselect_option_id") @db.Uuid - addressHolderAddress Address? @relation("application_selection_address", fields: [addressHolderAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) - applicationSelections ApplicationSelections @relation(fields: [applicationSelectionId], references: [id], onDelete: NoAction, onUpdate: NoAction) - multiselectOption MultiselectOptions @relation(fields: [multiselectOptionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applicationSelectionId String @map("application_selection_id") @db.Uuid + isGeocodingVerified Boolean? @map("is_geocoding_verified") + multiselectOptionId String @map("multiselect_option_id") @db.Uuid + addressHolderAddress Address? @relation("application_selection_address", fields: [addressHolderAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applicationSelections ApplicationSelections @relation(fields: [applicationSelectionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + multiselectOption MultiselectOptions @relation(fields: [multiselectOptionId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@map("application_selection_options") } @@ -195,9 +195,9 @@ model ApplicationSelections { applicationId String @map("application_id") @db.Uuid hasOptedOut Boolean? @map("has_opted_out") multiselectQuestionId String @map("multiselect_question_id") @db.Uuid - application Applications @relation(fields: [applicationId], references: [id], onDelete: NoAction, onUpdate: NoAction) - multiselectQuestion MultiselectQuestions @relation(fields: [multiselectQuestionId], references: [id], onDelete: NoAction, onUpdate: NoAction) - selections ApplicationSelectionOptions[] + application Applications @relation(fields: [applicationId], references: [id], onDelete: NoAction, onUpdate: NoAction) + multiselectQuestion MultiselectQuestions @relation(fields: [multiselectQuestionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + selections ApplicationSelectionOptions[] @@map("application_selections") } @@ -368,24 +368,24 @@ model HouseholdMember { // Note: [name] formerly max length 256 model Jurisdictions { - id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) - name String @unique() - notificationsSignUpUrl String? @map("notifications_sign_up_url") - languages LanguagesEnum[] @default([en]) - partnerTerms String? @map("partner_terms") - publicUrl String @default("") @map("public_url") - emailFromAddress String? @map("email_from_address") - rentalAssistanceDefault String @map("rental_assistance_default") - whatToExpect String @default("Applicants will be contacted by the property agent in rank order until vacancies are filled. All of the information that you have provided will be verified and your eligibility confirmed. Your application will be removed from the waitlist if you have made any fraudulent statements. If we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized. Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.") @map("what_to_expect") - whatToExpectAdditionalText String @default("") @map("what_to_expect_additional_text") - whatToExpectUnderConstruction String @default("") @map("what_to_expect_under_construction") - enablePartnerSettings Boolean @default(false) @map("enable_partner_settings") - enablePartnerDemographics Boolean @default(false) @map("enable_partner_demographics") - enableGeocodingPreferences Boolean @default(false) @map("enable_geocoding_preferences") - enableGeocodingRadiusMethod Boolean @default(false) @map("enable_geocoding_radius_method") - allowSingleUseCodeLogin Boolean @default(false) @map("allow_single_use_code_login") + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + name String @unique() + notificationsSignUpUrl String? @map("notifications_sign_up_url") + languages LanguagesEnum[] @default([en]) + partnerTerms String? @map("partner_terms") + publicUrl String @default("") @map("public_url") + emailFromAddress String? @map("email_from_address") + rentalAssistanceDefault String @map("rental_assistance_default") + whatToExpect String @default("Applicants will be contacted by the property agent in rank order until vacancies are filled. All of the information that you have provided will be verified and your eligibility confirmed. Your application will be removed from the waitlist if you have made any fraudulent statements. If we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized. Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.") @map("what_to_expect") + whatToExpectAdditionalText String @default("") @map("what_to_expect_additional_text") + whatToExpectUnderConstruction String @default("") @map("what_to_expect_under_construction") + enablePartnerSettings Boolean @default(false) @map("enable_partner_settings") + enablePartnerDemographics Boolean @default(false) @map("enable_partner_demographics") + enableGeocodingPreferences Boolean @default(false) @map("enable_geocoding_preferences") + enableGeocodingRadiusMethod Boolean @default(false) @map("enable_geocoding_radius_method") + allowSingleUseCodeLogin Boolean @default(false) @map("allow_single_use_code_login") amiChart AmiChart[] featureFlags FeatureFlags[] multiselectQuestions MultiselectQuestions[] @@ -393,9 +393,10 @@ model Jurisdictions { reservedCommunityTypes ReservedCommunityTypes[] translations Translations[] user_accounts UserAccounts[] - listingApprovalPermissions UserRoleEnum[] @map("listing_approval_permission") - duplicateListingPermissions UserRoleEnum[] @map("duplicate_listing_permissions") - requiredListingFields String[] @default([]) @map("required_listing_fields") + listingApprovalPermissions UserRoleEnum[] @map("listing_approval_permission") + duplicateListingPermissions UserRoleEnum[] @map("duplicate_listing_permissions") + requiredListingFields String[] @default([]) @map("required_listing_fields") + visibleNeighborhoodAmenities NeighborhoodAmenitiesEnum[] @default([groceryStores, publicTransportation, schools, parksAndCommunityCenters, pharmacies, healthCareResources]) @map("visible_neighborhood_amenities") @@map("jurisdictions") } @@ -973,15 +974,15 @@ model UserAccounts { } model UserRoles { - isAdmin Boolean @default(false) @map("is_admin") - isJurisdictionalAdmin Boolean @default(false) @map("is_jurisdictional_admin") - isLimitedJurisdictionalAdmin Boolean @default(false) @map("is_limited_jurisdictional_admin") - isPartner Boolean @default(false) @map("is_partner") - isSupportAdmin Boolean @default(false) @map("is_support_admin") + isAdmin Boolean @default(false) @map("is_admin") + isJurisdictionalAdmin Boolean @default(false) @map("is_jurisdictional_admin") + isLimitedJurisdictionalAdmin Boolean @default(false) @map("is_limited_jurisdictional_admin") + isPartner Boolean @default(false) @map("is_partner") + isSupportAdmin Boolean @default(false) @map("is_support_admin") // Role for maintainers of the code base. Should have access to everything as well as developer specific pages - isSuperAdmin Boolean @default(false) @map("is_super_admin") - userId String @id() @map("user_id") @db.Uuid - userAccounts UserAccounts @relation(fields: [userId], references: [id], onDelete: Cascade) + isSuperAdmin Boolean @default(false) @map("is_super_admin") + userId String @id() @map("user_id") @db.Uuid + userAccounts UserAccounts @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("user_roles") } @@ -1295,3 +1296,14 @@ enum ValidationMethodEnum { @@map("validation_method_enum") } + +enum NeighborhoodAmenitiesEnum { + groceryStores + publicTransportation + schools + parksAndCommunityCenters + pharmacies + healthCareResources + + @@map("neighborhood_amenities_enum") +} diff --git a/api/src/dtos/jurisdictions/jurisdiction.dto.ts b/api/src/dtos/jurisdictions/jurisdiction.dto.ts index 2e5c10e0f85..47a7306ff9b 100644 --- a/api/src/dtos/jurisdictions/jurisdiction.dto.ts +++ b/api/src/dtos/jurisdictions/jurisdiction.dto.ts @@ -10,7 +10,11 @@ import { IsBoolean, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { LanguagesEnum, UserRoleEnum } from '@prisma/client'; +import { + LanguagesEnum, + UserRoleEnum, + NeighborhoodAmenitiesEnum, +} from '@prisma/client'; import { FeatureFlag } from '../feature-flags/feature-flag.dto'; import { AbstractDTO } from '../shared/abstract.dto'; import { IdDTO } from '../shared/id.dto'; @@ -160,4 +164,18 @@ export class Jurisdiction extends AbstractDTO { @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @ApiProperty({ isArray: true }) requiredListingFields: string[]; + + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(NeighborhoodAmenitiesEnum, { + groups: [ValidationsGroupsEnum.default], + each: true, + }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: NeighborhoodAmenitiesEnum, + enumName: 'NeighborhoodAmenitiesEnum', + isArray: true, + }) + visibleNeighborhoodAmenities: NeighborhoodAmenitiesEnum[]; } diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 32d66b17a1d..2c0e5089a93 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -5960,6 +5960,9 @@ export interface JurisdictionCreate { /** */ requiredListingFields: [] + + /** */ + visibleNeighborhoodAmenities: NeighborhoodAmenitiesEnum[] } export interface JurisdictionUpdate { @@ -6019,6 +6022,9 @@ export interface JurisdictionUpdate { /** */ requiredListingFields: [] + + /** */ + visibleNeighborhoodAmenities: NeighborhoodAmenitiesEnum[] } export interface FeatureFlag { @@ -6113,6 +6119,9 @@ export interface Jurisdiction { /** */ requiredListingFields: [] + + /** */ + visibleNeighborhoodAmenities: NeighborhoodAmenitiesEnum[] } export interface MultiselectQuestionCreate { @@ -7541,6 +7550,15 @@ export enum UserRoleEnum { "supportAdmin" = "supportAdmin", } +export enum NeighborhoodAmenitiesEnum { + "groceryStores" = "groceryStores", + "publicTransportation" = "publicTransportation", + "schools" = "schools", + "parksAndCommunityCenters" = "parksAndCommunityCenters", + "pharmacies" = "pharmacies", + "healthCareResources" = "healthCareResources", +} + export enum FeatureFlagEnum { "disableCommonApplication" = "disableCommonApplication", "disableJurisdictionalAdmin" = "disableJurisdictionalAdmin", diff --git a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailNeighborhoodAmenities.tsx b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailNeighborhoodAmenities.tsx index 45d11f563c6..43d3ac159b0 100644 --- a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailNeighborhoodAmenities.tsx +++ b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailNeighborhoodAmenities.tsx @@ -1,83 +1,95 @@ -import React, { useContext } from "react" +import React, { useContext, useMemo } from "react" import { t } from "@bloom-housing/ui-components" import { FieldValue, Grid } from "@bloom-housing/ui-seeds" import { ListingContext } from "../../ListingContext" import { getDetailFieldString } from "./helpers" import SectionWithGrid from "../../../shared/SectionWithGrid" import { AuthContext } from "@bloom-housing/shared-helpers" -import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { + FeatureFlagEnum, + NeighborhoodAmenitiesEnum, + Listing, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { useJurisdiction } from "../../../../lib/hooks" + +type AmenityConfig = { + key: NeighborhoodAmenitiesEnum + labelKey: string + fieldId: string + getValue: (listing: Listing) => string | null | undefined +} + +const amenitiesConfig: AmenityConfig[] = [ + { + key: NeighborhoodAmenitiesEnum.groceryStores, + labelKey: "listings.amenities.groceryStores", + fieldId: "neighborhoodAmenities.groceryStores", + getValue: (listing) => listing.listingNeighborhoodAmenities?.groceryStores, + }, + { + key: NeighborhoodAmenitiesEnum.publicTransportation, + labelKey: "listings.amenities.publicTransportation", + fieldId: "neighborhoodAmenities.publicTransportation", + getValue: (listing) => listing.listingNeighborhoodAmenities?.publicTransportation, + }, + { + key: NeighborhoodAmenitiesEnum.schools, + labelKey: "listings.amenities.schools", + fieldId: "neighborhoodAmenities.schools", + getValue: (listing) => listing.listingNeighborhoodAmenities?.schools, + }, + { + key: NeighborhoodAmenitiesEnum.parksAndCommunityCenters, + labelKey: "listings.amenities.parksAndCommunityCenters", + fieldId: "neighborhoodAmenities.parksAndCommunityCenters", + getValue: (listing) => listing.listingNeighborhoodAmenities?.parksAndCommunityCenters, + }, + { + key: NeighborhoodAmenitiesEnum.pharmacies, + labelKey: "listings.amenities.pharmacies", + fieldId: "neighborhoodAmenities.pharmacies", + getValue: (listing) => listing.listingNeighborhoodAmenities?.pharmacies, + }, + { + key: NeighborhoodAmenitiesEnum.healthCareResources, + labelKey: "listings.amenities.healthCareResources", + fieldId: "neighborhoodAmenities.healthCareResources", + getValue: (listing) => listing.listingNeighborhoodAmenities?.healthCareResources, + }, +] const DetailNeighborhoodAmenities = () => { const listing = useContext(ListingContext) const { doJurisdictionsHaveFeatureFlagOn } = useContext(AuthContext) + const { data: jurisdictionData } = useJurisdiction(listing.jurisdictions.id) + const enableNeighborhoodAmenities = doJurisdictionsHaveFeatureFlagOn( FeatureFlagEnum.enableNeighborhoodAmenities, listing.jurisdictions.id ) - return enableNeighborhoodAmenities ? ( + const visibleAmenities = useMemo(() => { + const visibleAmenitiesList = jurisdictionData?.visibleNeighborhoodAmenities || [] + return amenitiesConfig.filter((amenity) => visibleAmenitiesList.includes(amenity.key)) + }, [jurisdictionData?.visibleNeighborhoodAmenities]) + + if (!enableNeighborhoodAmenities) { + return <> + } + + return ( - - - - {getDetailFieldString(listing.listingNeighborhoodAmenities?.groceryStores)} - - - - - - - {getDetailFieldString(listing.listingNeighborhoodAmenities?.publicTransportation)} - - - - - - - {getDetailFieldString(listing.listingNeighborhoodAmenities?.schools)} - - - - - - - {getDetailFieldString(listing.listingNeighborhoodAmenities?.parksAndCommunityCenters)} - - - - - - - {getDetailFieldString(listing.listingNeighborhoodAmenities?.pharmacies)} - - - - - - - {getDetailFieldString(listing.listingNeighborhoodAmenities?.healthCareResources)} - - - + {visibleAmenities.map((amenity) => ( + + + + {getDetailFieldString(amenity.getValue(listing))} + + + + ))} - ) : ( - <> ) } diff --git a/sites/partners/src/components/listings/PaperListingForm/sections/NeighborhoodAmenities.tsx b/sites/partners/src/components/listings/PaperListingForm/sections/NeighborhoodAmenities.tsx index 1f76e1952ff..9da70d5bb92 100644 --- a/sites/partners/src/components/listings/PaperListingForm/sections/NeighborhoodAmenities.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/sections/NeighborhoodAmenities.tsx @@ -1,10 +1,53 @@ -import React, { useContext } from "react" +import React, { useContext, useMemo } from "react" import { useFormContext } from "react-hook-form" import { t, Textarea } from "@bloom-housing/ui-components" import { Grid } from "@bloom-housing/ui-seeds" import SectionWithGrid from "../../../shared/SectionWithGrid" import { AuthContext } from "@bloom-housing/shared-helpers" -import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { + FeatureFlagEnum, + NeighborhoodAmenitiesEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { useJurisdiction } from "../../../../lib/hooks" + +type AmenityConfig = { + key: NeighborhoodAmenitiesEnum + labelKey: string + fieldName: string +} + +const amenitiesConfig: AmenityConfig[] = [ + { + key: NeighborhoodAmenitiesEnum.groceryStores, + labelKey: "listings.amenities.groceryStores", + fieldName: "listingNeighborhoodAmenities.groceryStores", + }, + { + key: NeighborhoodAmenitiesEnum.publicTransportation, + labelKey: "listings.amenities.publicTransportation", + fieldName: "listingNeighborhoodAmenities.publicTransportation", + }, + { + key: NeighborhoodAmenitiesEnum.schools, + labelKey: "listings.amenities.schools", + fieldName: "listingNeighborhoodAmenities.schools", + }, + { + key: NeighborhoodAmenitiesEnum.parksAndCommunityCenters, + labelKey: "listings.amenities.parksAndCommunityCenters", + fieldName: "listingNeighborhoodAmenities.parksAndCommunityCenters", + }, + { + key: NeighborhoodAmenitiesEnum.pharmacies, + labelKey: "listings.amenities.pharmacies", + fieldName: "listingNeighborhoodAmenities.pharmacies", + }, + { + key: NeighborhoodAmenitiesEnum.healthCareResources, + labelKey: "listings.amenities.healthCareResources", + fieldName: "listingNeighborhoodAmenities.healthCareResources", + }, +] const NeighborhoodAmenities = () => { const formMethods = useFormContext() @@ -14,88 +57,56 @@ const NeighborhoodAmenities = () => { const { register, watch } = formMethods const jurisdiction = watch("jurisdictions.id") + const { data: jurisdictionData } = useJurisdiction(jurisdiction) + const enableNeighborhoodAmenities = doJurisdictionsHaveFeatureFlagOn( FeatureFlagEnum.enableNeighborhoodAmenities, jurisdiction ) - return enableNeighborhoodAmenities ? ( + const visibleAmenities = useMemo(() => { + const visibleAmenitiesList = jurisdictionData?.visibleNeighborhoodAmenities || [] + return amenitiesConfig.filter((amenity) => visibleAmenitiesList.includes(amenity.key)) + }, [jurisdictionData?.visibleNeighborhoodAmenities]) + + // Group amenities into rows of 2 + const amenityRows = useMemo(() => { + const rows: AmenityConfig[][] = [] + for (let i = 0; i < visibleAmenities.length; i += 2) { + rows.push(visibleAmenities.slice(i, i + 2)) + } + return rows + }, [visibleAmenities]) + + if (!enableNeighborhoodAmenities) { + return <> + } + + return ( <>
- - -