From 0e3d9c8282c1684705d1b74d3621c38f11aee0a5 Mon Sep 17 00:00:00 2001 From: Morgan Ludtke <42942267+ludtkemorgan@users.noreply.github.com> Date: Wed, 4 Dec 2024 08:58:35 -0600 Subject: [PATCH] fix: add home type feature to backend and partners (#4484) * fix: add hometype to listing create/update * fix: add home type to csv * fix: update jwt to retrieve feature flags * fix: partner changes * fix: have partner be behind feature flag * fix: backend test fix * fix: remove comment * fix: address comments * fix: review comments --- api/prisma/seed-staging.ts | 7 + api/src/dtos/listings/listing.dto.ts | 11 + api/src/passports/jwt.strategy.ts | 11 +- .../services/listing-csv-export.service.ts | 794 +++++++++--------- api/test/integration/listing.e2e-spec.ts | 1 + api/test/unit/passports/jwt.strategy.spec.ts | 44 +- .../unit/services/listing.service.spec.ts | 1 + shared-helpers/src/locales/es.json | 5 + shared-helpers/src/locales/general.json | 6 + shared-helpers/src/types/backend-swagger.ts | 16 + .../cypress/e2e/default/03-listing.spec.ts | 6 + sites/partners/cypress/fixtures/listing.json | 1 + .../sections/DetailUnits.tsx | 14 + .../listings/PaperListingForm/index.tsx | 21 +- .../PaperListingForm/sections/Units.tsx | 54 +- sites/partners/tsconfig.json | 1 + 16 files changed, 603 insertions(+), 390 deletions(-) diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index a4a5aa26be..fbfaa4c9ea 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -39,6 +39,7 @@ import { import { ValidationMethod } from '../src/enums/multiselect-questions/validation-method-enum'; import { randomNoun } from './seed-helpers/word-generator'; import { householdMemberFactorySingle } from './seed-helpers/household-member-factory'; +import { featureFlagFactory } from './seed-helpers/feature-flag-factory'; export const stagingSeed = async ( prismaClient: PrismaClient, @@ -56,6 +57,12 @@ export const stagingSeed = async ( const additionalJurisdiction = await prismaClient.jurisdictions.create({ data: jurisdictionFactory(otherJusisName), }); + // Seed feature flags + await prismaClient.featureFlags.create({ + data: featureFlagFactory('homeType', true, 'Home Type feature', [ + jurisdiction.id, + ]), + }); // create admin user await prismaClient.userAccounts.create({ data: await userFactory({ diff --git a/api/src/dtos/listings/listing.dto.ts b/api/src/dtos/listings/listing.dto.ts index 2d47d13804..980c2f439a 100644 --- a/api/src/dtos/listings/listing.dto.ts +++ b/api/src/dtos/listings/listing.dto.ts @@ -19,6 +19,7 @@ import { AbstractDTO } from '../shared/abstract.dto'; import { ApplicationAddressTypeEnum, ApplicationMethodsTypeEnum, + HomeTypeEnum, ListingsStatusEnum, LotteryStatusEnum, ReviewOrderTypeEnum, @@ -628,6 +629,16 @@ class Listing extends AbstractDTO { @ApiPropertyOptional() @IsString({ groups: [ValidationsGroupsEnum.default] }) communityDisclaimerDescription?: string; + + @Expose() + @IsEnum(HomeTypeEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiPropertyOptional({ + enum: HomeTypeEnum, + enumName: 'HomeTypeEnum', + }) + homeType?: HomeTypeEnum; } export { Listing as default, Listing }; diff --git a/api/src/passports/jwt.strategy.ts b/api/src/passports/jwt.strategy.ts index 5e210c5ed5..7fe5f57680 100644 --- a/api/src/passports/jwt.strategy.ts +++ b/api/src/passports/jwt.strategy.ts @@ -35,7 +35,16 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { include: { listings: true, userRoles: true, - jurisdictions: true, + jurisdictions: { + include: { + featureFlags: { + select: { + name: true, + active: true, + }, + }, + }, + }, }, where: { id: userId, diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts index 447ff368e7..ea36d56d2c 100644 --- a/api/src/services/listing-csv-export.service.ts +++ b/api/src/services/listing-csv-export.service.ts @@ -34,6 +34,7 @@ import Unit from '../dtos/units/unit.dto'; import Listing from '../dtos/listings/listing.dto'; import { mapTo } from '../utilities/mapTo'; import { ListingMultiselectQuestion } from '../dtos/listings/listing-multiselect-question.dto'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; views.csv = { ...views.details, @@ -83,7 +84,7 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { ): Promise { this.logger.warn('Generating Listing-Unit Zip'); const user = mapTo(User, req['user']); - await this.authorizeCSVExport(mapTo(User, req['user'])); + await this.authorizeCSVExport(user); const zipFileName = `listings-units-${user.id}-${new Date().getTime()}.zip`; const zipFilePath = join(process.cwd(), `src/temp/${zipFileName}`); @@ -124,6 +125,7 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { await this.createCsv(listingFilePath, queryParams, { listings: listings as unknown as Listing[], + user, }); const listingCsv = createReadStream(listingFilePath); @@ -156,9 +158,9 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { async createCsv( filename: string, queryParams: QueryParams, - optionParams: { listings: Listing[] }, + optionParams: { listings: Listing[]; user: User }, ): Promise { - const csvHeaders = await this.getCsvHeaders(); + const csvHeaders = await this.getCsvHeaders(optionParams.user); return new Promise((resolve, reject) => { // create stream @@ -316,7 +318,18 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { return fieldValue; }; - async getCsvHeaders(): Promise { + doAnyJurisdictionHaveFeatureFlagSet = ( + jurisdictions: Jurisdiction[], + featureFlagName: string, + ) => { + return jurisdictions.some((juris) => { + return juris.featureFlags.some( + (flag) => flag.name === featureFlagName && flag.active, + ); + }); + }; + + async getCsvHeaders(user: User): Promise { const headers: CsvHeader[] = [ { path: 'id', @@ -404,397 +417,416 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'listingsBuildingAddress.longitude', label: 'Longitude', }, - { - path: 'units.length', - label: 'Number of Units', - }, - { - path: 'reviewOrderType', - label: 'Listing Availability', - format: (val: string): string => - val === ListingReviewOrder.waitlist - ? 'Open Waitlist' - : 'Available Units', - }, - { - path: 'reviewOrderType', - label: 'Review Order', - format: (val: string): string => { - if (!val) return ''; - const spacedValue = val.replace(/([A-Z])/g, (match) => ` ${match}`); - const result = - spacedValue.charAt(0).toUpperCase() + spacedValue.slice(1); - return result; + ]; + + if ( + this.doAnyJurisdictionHaveFeatureFlagSet(user.jurisdictions, 'homeType') + ) { + headers.push({ + path: 'homeType', + label: 'Home Type', + }); + } + + headers.push( + ...[ + { + path: 'units.length', + label: 'Number of Units', }, - }, - { - path: 'listingEvents', - label: 'Lottery Date', - format: (val: ListingEvent[]): string => { - if (!val) return ''; - const lottery = val.filter( - (event) => event.type === ListingEventsTypeEnum.publicLottery, - ); - return lottery.length - ? formatLocalDate(lottery[0].startTime, 'MM-DD-YYYY', this.timeZone) - : ''; + { + path: 'reviewOrderType', + label: 'Listing Availability', + format: (val: string): string => + val === ListingReviewOrder.waitlist + ? 'Open Waitlist' + : 'Available Units', }, - }, - { - path: 'listingEvents', - label: 'Lottery Start', - format: (val: ListingEvent[]): string => { - if (!val) return ''; - const lottery = val.filter( - (event) => event.type === ListingEventsTypeEnum.publicLottery, - ); - return lottery.length - ? formatLocalDate(lottery[0].startTime, 'hh:mmA z', this.timeZone) - : ''; + { + path: 'reviewOrderType', + label: 'Review Order', + format: (val: string): string => { + if (!val) return ''; + const spacedValue = val.replace(/([A-Z])/g, (match) => ` ${match}`); + const result = + spacedValue.charAt(0).toUpperCase() + spacedValue.slice(1); + return result; + }, }, - }, - { - path: 'listingEvents', - label: 'Lottery End', - format: (val: ListingEvent[]): string => { - if (!val) return ''; - const lottery = val.filter( - (event) => event.type === ListingEventsTypeEnum.publicLottery, - ); - return lottery.length - ? formatLocalDate(lottery[0].endTime, 'hh:mmA z', this.timeZone) - : ''; + { + path: 'listingEvents', + label: 'Lottery Date', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + const lottery = val.filter( + (event) => event.type === ListingEventsTypeEnum.publicLottery, + ); + return lottery.length + ? formatLocalDate( + lottery[0].startTime, + 'MM-DD-YYYY', + this.timeZone, + ) + : ''; + }, }, - }, - { - path: 'listingEvents', - label: 'Lottery Notes', - format: (val: ListingEvent[]): string => { - if (!val) return ''; - const lottery = val.filter( - (event) => event.type === ListingEventsTypeEnum.publicLottery, - ); - return lottery.length ? lottery[0].note : ''; + { + path: 'listingEvents', + label: 'Lottery Start', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + const lottery = val.filter( + (event) => event.type === ListingEventsTypeEnum.publicLottery, + ); + return lottery.length + ? formatLocalDate(lottery[0].startTime, 'hh:mmA z', this.timeZone) + : ''; + }, }, - }, - { - path: 'listingMultiselectQuestions', - label: 'Housing Preferences', - format: (val: ListingMultiselectQuestion[]): string => { - return val - .filter( - (question) => - question.multiselectQuestions.applicationSection === - 'preferences', - ) - .map((question) => question.multiselectQuestions.text) - .join(','); + { + path: 'listingEvents', + label: 'Lottery End', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + const lottery = val.filter( + (event) => event.type === ListingEventsTypeEnum.publicLottery, + ); + return lottery.length + ? formatLocalDate(lottery[0].endTime, 'hh:mmA z', this.timeZone) + : ''; + }, }, - }, - { - path: 'listingMultiselectQuestions', - label: 'Housing Programs', - format: (val: ListingMultiselectQuestion[]): string => { - return val - .filter( - (question) => - question.multiselectQuestions.applicationSection === 'programs', - ) - .map((question) => question.multiselectQuestions.text) - .join(','); + { + path: 'listingEvents', + label: 'Lottery Notes', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + const lottery = val.filter( + (event) => event.type === ListingEventsTypeEnum.publicLottery, + ); + return lottery.length ? lottery[0].note : ''; + }, }, - }, - { - path: 'applicationFee', - label: 'Application Fee', - format: this.formatCurrency, - }, - { - path: 'depositHelperText', - label: 'Deposit Helper Text', - }, - { - path: 'depositMin', - label: 'Deposit Min', - format: this.formatCurrency, - }, - { - path: 'depositMax', - label: 'Deposit Max', - format: this.formatCurrency, - }, - { - path: 'costsNotIncluded', - label: 'Costs Not Included', - }, - { - path: 'amenities', - label: 'Property Amenities', - }, - { - path: 'accessibility', - label: 'Additional Accessibility', - }, - { - path: 'unitAmenities', - label: 'Unit Amenities', - }, - { - path: 'smokingPolicy', - label: 'Smoking Policy', - }, - { - path: 'petPolicy', - label: 'Pets Policy', - }, - { - path: 'servicesOffered', - label: 'Services Offered', - }, - { - path: 'creditHistory', - label: 'Eligibility Rules - Credit History', - }, - { - path: 'rentalHistory', - label: 'Eligibility Rules - Rental History', - }, - { - path: 'criminalBackground', - label: 'Eligibility Rules - Criminal Background', - }, - { - path: 'rentalAssistance', - label: 'Eligibility Rules - Rental Assistance', - }, - { - path: 'buildingSelectionCriteriaFileId', - label: 'Building Selection Criteria', - format: this.cloudinaryPdfFromId, - }, - { - path: 'programRules', - label: 'Important Program Rules', - }, - { - path: 'requiredDocuments', - label: 'Required Documents', - }, - { - path: 'specialNotes', - label: 'Special Notes', - }, - { - path: 'isWaitlistOpen', - label: 'Waitlist', - format: this.formatYesNo, - }, - { - path: 'leasingAgentName', - label: 'Leasing Agent Name', - }, - { - path: 'leasingAgentEmail', - label: 'Leasing Agent Email', - }, - { - path: 'leasingAgentPhone', - label: 'Leasing Agent Phone', - }, - { - path: 'leasingAgentTitle', - label: 'Leasing Agent Title', - }, - { - path: 'leasingAgentOfficeHours', - label: 'Leasing Agent Office Hours', - }, - { - path: 'listingsLeasingAgentAddress.street', - label: 'Leasing Agent Street Address', - }, - { - path: 'listingsLeasingAgentAddress.street2', - label: 'Leasing Agent Apt/Unit #', - }, - { - path: 'listingsLeasingAgentAddress.city', - label: 'Leasing Agent City', - }, - { - path: 'listingsLeasingAgentAddress.state', - label: 'Leasing Agent State', - }, - { - path: 'listingsLeasingAgentAddress.zipCode', - label: 'Leasing Agent Zip', - }, - { - path: 'listingsLeasingAgentAddress.street', - label: 'Leasing Agency Mailing Address', - }, - { - path: 'listingsLeasingAgentAddress.street2', - label: 'Leasing Agency Mailing Address Street 2', - }, - { - path: 'listingsLeasingAgentAddress.city', - label: 'Leasing Agency Mailing Address City', - }, - { - path: 'listingsLeasingAgentAddress.state', - label: 'Leasing Agency Mailing Address State', - }, - { - path: 'listingsLeasingAgentAddress.zipCode', - label: 'Leasing Agency Mailing Address Zip', - }, - { - path: 'listingsApplicationPickUpAddress.street', - label: 'Leasing Agency Pickup Address', - }, - { - path: 'listingsApplicationPickUpAddress.street2', - label: 'Leasing Agency Pickup Address Street 2', - }, - { - path: 'listingsApplicationPickUpAddress.city', - label: 'Leasing Agency Pickup Address City', - }, - { - path: 'listingsApplicationPickUpAddress.state', - label: 'Leasing Agency Pickup Address State', - }, - { - path: 'listingsApplicationPickUpAddress.zipCode', - label: 'Leasing Agency Pickup Address Zip', - }, - { - path: 'applicationPickUpAddressOfficeHours', - label: 'Leasing Pick Up Office Hours', - }, - { - path: 'digitalApplication', - label: 'Digital Application', - format: this.formatYesNo, - }, - { - path: 'applicationMethods', - label: 'Digital Application URL', - format: (val: ApplicationMethod[]): string => { - const method = val.filter( - (appMethod) => - appMethod.type === ApplicationMethodsTypeEnum.ExternalLink, - ); - return method.length ? method[0].externalReference : ''; + { + path: 'listingMultiselectQuestions', + label: 'Housing Preferences', + format: (val: ListingMultiselectQuestion[]): string => { + return val + .filter( + (question) => + question.multiselectQuestions.applicationSection === + 'preferences', + ) + .map((question) => question.multiselectQuestions.text) + .join(','); + }, }, - }, - { - path: 'paperApplication', - label: 'Paper Application', - format: this.formatYesNo, - }, - { - path: 'applicationMethods', - label: 'Paper Application URL', - format: (val: ApplicationMethod[]): string => { - const method = val.filter( - (appMethod) => appMethod.paperApplications.length > 0, - ); - const paperApps = method.length ? method[0].paperApplications : []; - return paperApps.length - ? paperApps - .map((app) => this.cloudinaryPdfFromId(app.assets.fileId)) - .join(', ') - : ''; + { + path: 'listingMultiselectQuestions', + label: 'Housing Programs', + format: (val: ListingMultiselectQuestion[]): string => { + return val + .filter( + (question) => + question.multiselectQuestions.applicationSection === + 'programs', + ) + .map((question) => question.multiselectQuestions.text) + .join(','); + }, }, - }, - { - path: 'referralOpportunity', - label: 'Referral Opportunity', - format: this.formatYesNo, - }, - { - path: 'applicationMailingAddressId', - label: 'Can applications be mailed in?', - format: this.formatYesNo, - }, - { - path: 'applicationPickUpAddressId', - label: 'Can applications be picked up?', - format: this.formatYesNo, - }, - { - path: 'applicationPickUpAddressId', - label: 'Can applications be dropped off?', - format: this.formatYesNo, - }, - { - path: 'postmarkedApplicationsReceivedByDate', - label: 'Postmark', - format: (val: string): string => - formatLocalDate(val, this.dateFormat, this.timeZone), - }, - { - path: 'additionalApplicationSubmissionNotes', - label: 'Additional Application Submission Notes', - }, - { - path: 'applicationDueDate', - label: 'Application Due Date', - format: (val: string): string => - formatLocalDate(val, 'MM-DD-YYYY', this.timeZone), - }, - { - path: 'applicationDueDate', - label: 'Application Due Time', - format: (val: string): string => - formatLocalDate(val, 'hh:mmA z', this.timeZone), - }, - { - path: 'listingEvents', - label: 'Open House', - format: (val: ListingEvent[]): string => { - if (!val) return ''; - return val - .filter((event) => event.type === ListingEventsTypeEnum.openHouse) - .map((event) => { - let openHouseStr = ''; - if (event.label) openHouseStr += `${event.label}`; - if (event.startTime) { - const date = formatLocalDate( - event.startTime, - 'MM-DD-YYYY', - this.timeZone, - ); - openHouseStr += `: ${date}`; - if (event.endTime) { - const startTime = formatLocalDate( + { + path: 'applicationFee', + label: 'Application Fee', + format: this.formatCurrency, + }, + { + path: 'depositHelperText', + label: 'Deposit Helper Text', + }, + { + path: 'depositMin', + label: 'Deposit Min', + format: this.formatCurrency, + }, + { + path: 'depositMax', + label: 'Deposit Max', + format: this.formatCurrency, + }, + { + path: 'costsNotIncluded', + label: 'Costs Not Included', + }, + { + path: 'amenities', + label: 'Property Amenities', + }, + { + path: 'accessibility', + label: 'Additional Accessibility', + }, + { + path: 'unitAmenities', + label: 'Unit Amenities', + }, + { + path: 'smokingPolicy', + label: 'Smoking Policy', + }, + { + path: 'petPolicy', + label: 'Pets Policy', + }, + { + path: 'servicesOffered', + label: 'Services Offered', + }, + { + path: 'creditHistory', + label: 'Eligibility Rules - Credit History', + }, + { + path: 'rentalHistory', + label: 'Eligibility Rules - Rental History', + }, + { + path: 'criminalBackground', + label: 'Eligibility Rules - Criminal Background', + }, + { + path: 'rentalAssistance', + label: 'Eligibility Rules - Rental Assistance', + }, + { + path: 'buildingSelectionCriteriaFileId', + label: 'Building Selection Criteria', + format: this.cloudinaryPdfFromId, + }, + { + path: 'programRules', + label: 'Important Program Rules', + }, + { + path: 'requiredDocuments', + label: 'Required Documents', + }, + { + path: 'specialNotes', + label: 'Special Notes', + }, + { + path: 'isWaitlistOpen', + label: 'Waitlist', + format: this.formatYesNo, + }, + { + path: 'leasingAgentName', + label: 'Leasing Agent Name', + }, + { + path: 'leasingAgentEmail', + label: 'Leasing Agent Email', + }, + { + path: 'leasingAgentPhone', + label: 'Leasing Agent Phone', + }, + { + path: 'leasingAgentTitle', + label: 'Leasing Agent Title', + }, + { + path: 'leasingAgentOfficeHours', + label: 'Leasing Agent Office Hours', + }, + { + path: 'listingsLeasingAgentAddress.street', + label: 'Leasing Agent Street Address', + }, + { + path: 'listingsLeasingAgentAddress.street2', + label: 'Leasing Agent Apt/Unit #', + }, + { + path: 'listingsLeasingAgentAddress.city', + label: 'Leasing Agent City', + }, + { + path: 'listingsLeasingAgentAddress.state', + label: 'Leasing Agent State', + }, + { + path: 'listingsLeasingAgentAddress.zipCode', + label: 'Leasing Agent Zip', + }, + { + path: 'listingsLeasingAgentAddress.street', + label: 'Leasing Agency Mailing Address', + }, + { + path: 'listingsLeasingAgentAddress.street2', + label: 'Leasing Agency Mailing Address Street 2', + }, + { + path: 'listingsLeasingAgentAddress.city', + label: 'Leasing Agency Mailing Address City', + }, + { + path: 'listingsLeasingAgentAddress.state', + label: 'Leasing Agency Mailing Address State', + }, + { + path: 'listingsLeasingAgentAddress.zipCode', + label: 'Leasing Agency Mailing Address Zip', + }, + { + path: 'listingsApplicationPickUpAddress.street', + label: 'Leasing Agency Pickup Address', + }, + { + path: 'listingsApplicationPickUpAddress.street2', + label: 'Leasing Agency Pickup Address Street 2', + }, + { + path: 'listingsApplicationPickUpAddress.city', + label: 'Leasing Agency Pickup Address City', + }, + { + path: 'listingsApplicationPickUpAddress.state', + label: 'Leasing Agency Pickup Address State', + }, + { + path: 'listingsApplicationPickUpAddress.zipCode', + label: 'Leasing Agency Pickup Address Zip', + }, + { + path: 'applicationPickUpAddressOfficeHours', + label: 'Leasing Pick Up Office Hours', + }, + { + path: 'digitalApplication', + label: 'Digital Application', + format: this.formatYesNo, + }, + { + path: 'applicationMethods', + label: 'Digital Application URL', + format: (val: ApplicationMethod[]): string => { + const method = val.filter( + (appMethod) => + appMethod.type === ApplicationMethodsTypeEnum.ExternalLink, + ); + return method.length ? method[0].externalReference : ''; + }, + }, + { + path: 'paperApplication', + label: 'Paper Application', + format: this.formatYesNo, + }, + { + path: 'applicationMethods', + label: 'Paper Application URL', + format: (val: ApplicationMethod[]): string => { + const method = val.filter( + (appMethod) => appMethod.paperApplications.length > 0, + ); + const paperApps = method.length ? method[0].paperApplications : []; + return paperApps.length + ? paperApps + .map((app) => this.cloudinaryPdfFromId(app.assets.fileId)) + .join(', ') + : ''; + }, + }, + { + path: 'referralOpportunity', + label: 'Referral Opportunity', + format: this.formatYesNo, + }, + { + path: 'applicationMailingAddressId', + label: 'Can applications be mailed in?', + format: this.formatYesNo, + }, + { + path: 'applicationPickUpAddressId', + label: 'Can applications be picked up?', + format: this.formatYesNo, + }, + { + path: 'applicationPickUpAddressId', + label: 'Can applications be dropped off?', + format: this.formatYesNo, + }, + { + path: 'postmarkedApplicationsReceivedByDate', + label: 'Postmark', + format: (val: string): string => + formatLocalDate(val, this.dateFormat, this.timeZone), + }, + { + path: 'additionalApplicationSubmissionNotes', + label: 'Additional Application Submission Notes', + }, + { + path: 'applicationDueDate', + label: 'Application Due Date', + format: (val: string): string => + formatLocalDate(val, 'MM-DD-YYYY', this.timeZone), + }, + { + path: 'applicationDueDate', + label: 'Application Due Time', + format: (val: string): string => + formatLocalDate(val, 'hh:mmA z', this.timeZone), + }, + { + path: 'listingEvents', + label: 'Open House', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + return val + .filter((event) => event.type === ListingEventsTypeEnum.openHouse) + .map((event) => { + let openHouseStr = ''; + if (event.label) openHouseStr += `${event.label}`; + if (event.startTime) { + const date = formatLocalDate( event.startTime, - 'hh:mmA', + 'MM-DD-YYYY', this.timeZone, ); - const endTime = formatLocalDate( - event.endTime, - 'hh:mmA z', - this.timeZone, - ); - openHouseStr += ` (${startTime} - ${endTime})`; + openHouseStr += `: ${date}`; + if (event.endTime) { + const startTime = formatLocalDate( + event.startTime, + 'hh:mmA', + this.timeZone, + ); + const endTime = formatLocalDate( + event.endTime, + 'hh:mmA z', + this.timeZone, + ); + openHouseStr += ` (${startTime} - ${endTime})`; + } } - } - return openHouseStr; - }) - .filter((str) => str.length) - .join(', '); + return openHouseStr; + }) + .filter((str) => str.length) + .join(', '); + }, }, - }, - { - path: 'userAccounts', - label: 'Partners Who Have Access', - format: (val: User[]): string => - val.map((user) => `${user.firstName} ${user.lastName}`).join(', '), - }, - ]; + { + path: 'userAccounts', + label: 'Partners Who Have Access', + format: (val: User[]): string => + val.map((user) => `${user.firstName} ${user.lastName}`).join(', '), + }, + ], + ); return headers; } diff --git a/api/test/integration/listing.e2e-spec.ts b/api/test/integration/listing.e2e-spec.ts index 3a2fb04b88..8a3613256f 100644 --- a/api/test/integration/listing.e2e-spec.ts +++ b/api/test/integration/listing.e2e-spec.ts @@ -349,6 +349,7 @@ describe('Listing Controller Tests', () => { Math.random() >= 0.5 ? 'example title' : undefined, communityDisclaimerDescription: Math.random() >= 0.5 ? 'example description' : undefined, + homeType: 'apartment', }; }; diff --git a/api/test/unit/passports/jwt.strategy.spec.ts b/api/test/unit/passports/jwt.strategy.spec.ts index e4fc6bcf76..a20fd4dc34 100644 --- a/api/test/unit/passports/jwt.strategy.spec.ts +++ b/api/test/unit/passports/jwt.strategy.spec.ts @@ -44,7 +44,16 @@ describe('Testing jwt strategy', () => { include: { userRoles: true, listings: true, - jurisdictions: true, + jurisdictions: { + include: { + featureFlags: { + select: { + active: true, + name: true, + }, + }, + }, + }, }, where: { id, @@ -83,7 +92,16 @@ describe('Testing jwt strategy', () => { include: { userRoles: true, listings: true, - jurisdictions: true, + jurisdictions: { + include: { + featureFlags: { + select: { + active: true, + name: true, + }, + }, + }, + }, }, where: { id, @@ -126,7 +144,16 @@ describe('Testing jwt strategy', () => { include: { userRoles: true, listings: true, - jurisdictions: true, + jurisdictions: { + include: { + featureFlags: { + select: { + active: true, + name: true, + }, + }, + }, + }, }, where: { id, @@ -175,7 +202,16 @@ describe('Testing jwt strategy', () => { include: { userRoles: true, listings: true, - jurisdictions: true, + jurisdictions: { + include: { + featureFlags: { + select: { + active: true, + name: true, + }, + }, + }, + }, }, where: { id, diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index 3d2c18c124..7ec3f29518 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -433,6 +433,7 @@ describe('Testing listing service', () => { phone: false, internet: true, }, + homeType: 'apartment', }; }; diff --git a/shared-helpers/src/locales/es.json b/shared-helpers/src/locales/es.json index 34c8369a30..aebdc8ae95 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -557,6 +557,10 @@ "footer.disclaimer": "Exención de responsabilidades", "footer.forGeneralQuestions": "Si requiere información general sobre el programa, nos puede llamar al 000-000-0000.", "footer.giveFeedback": "Proporcione sus comentarios", + "homeType.apartment": "Apartamento", + "homeType.duplex": "Dúplex", + "homeType.house": "Casa para una sola familia", + "homeType.townhome": "Casa adosada", "housingCounselors.call": "Llame al %{number}", "housingCounselors.languageServices": "Servicios de idiomas: ", "housingCounselors.subtitle": "Hable con un asesor de vivienda local en forma específica a sus necesidades.", @@ -631,6 +635,7 @@ "listings.forIncomeCalculations": "Para realizar el cálculo de los ingresos, el número de miembros del hogar incluye a todas las personas (de todas las edades) que residan en la vivienda.", "listings.forIncomeCalculationsBMR": "Los cálculos de los ingresos están basados en el tipo de vivienda", "listings.hideClosedListings": "Ocultar los Listados cerrados", + "listings.homeType": "Tipo de casa", "listings.householdMaximumIncome": "Ingresos máximos del hogar", "listings.householdSize": "Tamaño del hogar", "listings.importantProgramRules": "Reglas importantes del programa", diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index e52ae007ed..a622b81e1e 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -548,6 +548,10 @@ "footer.disclaimer": "Disclaimer", "footer.forGeneralQuestions": "For general program inquiries, you may call us at 000-000-0000.", "footer.giveFeedback": "Give Feedback", + "homeType.apartment": "Apartment", + "homeType.duplex": "Duplex", + "homeType.house": "Single-Family House", + "homeType.townhome": "Townhome", "housingCounselors.call": "Call %{number}", "housingCounselors.languageServices": "Language Services: ", "housingCounselors.subtitle": "Talk with a local housing counselor specific to your needs.", @@ -622,6 +626,7 @@ "listings.forIncomeCalculations": "For income calculations, household size includes everyone (all ages) living in the unit.", "listings.forIncomeCalculationsBMR": "Income calculations are based on unit type", "listings.hideClosedListings": "Hide Closed Listings", + "listings.homeType": "Home Type", "listings.householdMaximumIncome": "Household Maximum Income", "listings.householdSize": "Household Size", "listings.importantProgramRules": "Important Program Rules", @@ -791,6 +796,7 @@ "progressNav.srHeading": "Progress", "region.name": "Bloomington", "states.AL": "Alabama", + "states.AK": "Alaska", "states.AR": "Arkansas", "states.AZ": "Arizona", "states.CA": "California", diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index ab96c82c4f..81dd7fa5ec 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -3628,6 +3628,9 @@ export interface Listing { /** */ communityDisclaimerDescription?: string + + /** */ + homeType?: HomeTypeEnum } export interface PaginationMeta { @@ -4099,6 +4102,9 @@ export interface ListingCreate { /** */ communityDisclaimerDescription?: string + /** */ + homeType?: HomeTypeEnum + /** */ listingMultiselectQuestions?: IdDTO[] @@ -4382,6 +4388,9 @@ export interface ListingUpdate { /** */ communityDisclaimerDescription?: string + /** */ + homeType?: HomeTypeEnum + /** */ listingMultiselectQuestions?: IdDTO[] @@ -6339,6 +6348,13 @@ export enum UnitRentTypeEnum { "percentageOfIncome" = "percentageOfIncome", } +export enum HomeTypeEnum { + "apartment" = "apartment", + "duplex" = "duplex", + "house" = "house", + "townhome" = "townhome", +} + export enum AfsView { "pending" = "pending", "pendingNameAndDoB" = "pendingNameAndDoB", diff --git a/sites/partners/cypress/e2e/default/03-listing.spec.ts b/sites/partners/cypress/e2e/default/03-listing.spec.ts index ffc21a4d72..631040a870 100644 --- a/sites/partners/cypress/e2e/default/03-listing.spec.ts +++ b/sites/partners/cypress/e2e/default/03-listing.spec.ts @@ -160,6 +160,9 @@ describe("Listing Management Tests", () => { cy.getByID("reservedCommunityDescription").type(listing["reservedCommunityDescription"]) cy.getByTestId("unit-types").check() cy.getByTestId("listingAvailability.availableUnits").check() + if (listing["homeType"]) { + cy.getByID("homeType").select(listing["homeType"]) + } cy.getByID("addUnitsButton").contains("Add Unit").click() cy.getByID("number").type(listing["number"]) cy.getByID("unitTypes.id").select(listing["unitType.id"]) @@ -303,6 +306,9 @@ describe("Listing Management Tests", () => { cy.getByID("latitude").should("include.text", "37.7") cy.getByID("reservedCommunityType").contains(listing["reservedCommunityType.id"]) cy.getByID("reservedCommunityDescription").contains(listing["reservedCommunityDescription"]) + if (listing["homeType"]) { + cy.getByID("homeType").contains(listing["homeType"]) + } cy.getByTestId("unit-types-or-individual").contains("Unit Types") cy.getByTestId("listing-availability-question").contains("Available Units") cy.getByID("unitTable").contains(listing["number"]) diff --git a/sites/partners/cypress/fixtures/listing.json b/sites/partners/cypress/fixtures/listing.json index 27a2b04f82..681127bde4 100644 --- a/sites/partners/cypress/fixtures/listing.json +++ b/sites/partners/cypress/fixtures/listing.json @@ -10,6 +10,7 @@ "yearBuilt": "2021", "reservedCommunityType.id": "Seniors", "reservedCommunityDescription": "Basic Test Description", + "homeType": "Apartment", "number": "2", "unitType.id": "One Bedroom", "numBathrooms": "2", diff --git a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailUnits.tsx b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailUnits.tsx index 9879d2a94a..2a31989fde 100644 --- a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailUnits.tsx +++ b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailUnits.tsx @@ -1,6 +1,7 @@ import React, { useContext, useMemo } from "react" import { t, MinimalTable } from "@bloom-housing/ui-components" import { Button, FieldValue, Grid } from "@bloom-housing/ui-seeds" +import { AuthContext } from "@bloom-housing/shared-helpers" import { ListingContext } from "../../ListingContext" import { UnitDrawer } from "../DetailsUnitDrawer" import { ReviewOrderTypeEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" @@ -12,6 +13,7 @@ type DetailUnitsProps = { const DetailUnits = ({ setUnitDrawer }: DetailUnitsProps) => { const listing = useContext(ListingContext) + const { profile } = useContext(AuthContext) const unitTableHeaders = { number: "listings.unit.number", @@ -58,8 +60,20 @@ const DetailUnits = ({ setUnitDrawer }: DetailUnitsProps) => { return t("t.none") }, [listing]) + const enableHomeType = + profile?.jurisdictions + ?.find((j) => j.id === listing.jurisdictions.id) + ?.featureFlags?.find((flag) => flag.name === "homeType")?.active || false + return ( + {enableHomeType && ( + + + {listing.homeType ? t(`homeType.${listing.homeType}`) : t("t.none")} + + + )} const [customMapPositionChosen, setCustomMapPositionChosen] = useState( listing?.customMapPin || false ) + const [activeFeatureFlags, setActiveFeatureFlags] = useState(null) const setLatitudeLongitude = (latlong: LatitudeLongitude) => { if (!loading) { @@ -166,7 +168,23 @@ const ListingForm = ({ listing, editMode, setListingName }: ListingFormProps) => }, [listing?.units, listing?.listingEvents, setUnits, setOpenHouseEvents]) // eslint-disable-next-line @typescript-eslint/unbound-method - const { getValues, setError, clearErrors, reset } = formMethods + const { getValues, setError, clearErrors, reset, watch } = formMethods + + const selectedJurisdiction = watch("jurisdictions.id") + + // Set the active feature flags depending on if/what jurisdiction is selected + useEffect(() => { + const newFeatureFlags = profile.jurisdictions?.reduce((featureFlags, juris) => { + if (!selectedJurisdiction || selectedJurisdiction === juris.id) { + // filter only the active feature flags + const jurisFeatureFlags = juris.featureFlags?.filter((value) => value.active) + const flags = [...featureFlags, ...jurisFeatureFlags] + return [...new Set(flags)] + } + return featureFlags + }, []) + setActiveFeatureFlags(newFeatureFlags) + }, [profile.jurisdictions, selectedJurisdiction]) const triggerSubmitWithStatus: SubmitFunction = (action, status, newData) => { if (action !== "redirect" && status === ListingsStatusEnum.active) { @@ -333,6 +351,7 @@ const ListingForm = ({ listing, editMode, setListingName }: ListingFormProps) => units={units} setUnits={setUnits} disableUnitsAccordion={listing?.disableUnitsAccordion} + featureFlags={activeFeatureFlags} /> void disableUnitsAccordion: boolean + featureFlags?: FeatureFlag[] } -const FormUnits = ({ units, setUnits, disableUnitsAccordion }: UnitProps) => { +const FormUnits = ({ units, setUnits, disableUnitsAccordion, featureFlags }: UnitProps) => { const { addToast } = useContext(MessageContext) const [unitDrawerOpen, setUnitDrawerOpen] = useState(false) const [unitDeleteModal, setUnitDeleteModal] = useState(null) const [defaultUnit, setDefaultUnit] = useState(null) + const [homeTypeEnabled, setHomeTypeEnabled] = useState(false) const formMethods = useFormContext() // eslint-disable-next-line @typescript-eslint/unbound-method @@ -31,6 +43,13 @@ const FormUnits = ({ units, setUnits, disableUnitsAccordion }: UnitProps) => { name: "listingAvailabilityQuestion", }) + const homeTypes = [ + "", + ...Object.values(HomeTypeEnum).map((val) => { + return { value: val, label: t(`homeType.${val}`) } + }), + ] + const nextId = units && units.length > 0 ? units[units.length - 1]?.tempId + 1 : 1 const unitTableHeaders = { @@ -61,6 +80,17 @@ const FormUnits = ({ units, setUnits, disableUnitsAccordion }: UnitProps) => { } }, [units]) + // If hometype feature flag is not turned on for selected jurisdiction we need to reset the value + useEffect(() => { + if (featureFlags) { + const isHomeTypeEnabled = featureFlags.some((flag) => flag.name === "homeType") + setHomeTypeEnabled(isHomeTypeEnabled) + if (!isHomeTypeEnabled) { + setValue("homeType", "") + } + } + }, [featureFlags, setValue]) + const editUnit = useCallback( (tempId: number) => { setDefaultUnit(units.filter((unit) => unit.tempId === tempId)[0]) @@ -150,6 +180,23 @@ const FormUnits = ({ units, setUnits, disableUnitsAccordion }: UnitProps) => { <>
+ {homeTypeEnabled && ( + + + {homeTypes && ( +