From c8289d7d356dff56b6a517b8656871f6afcc85bd Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Fri, 27 Feb 2026 19:21:36 -0700 Subject: [PATCH 01/11] feat: applicant name search --- api/prisma/seed-staging.ts | 66 +++++++++--------- .../public-apps-view-params.dto.ts | 6 ++ api/src/services/application.service.ts | 54 +++++++++++++-- shared-helpers/src/locales/general.json | 3 + shared-helpers/src/types/backend-swagger.ts | 3 + .../account/ApplicationsView.module.scss | 16 +++++ .../components/account/ApplicationsView.tsx | 67 +++++++++++++++++-- .../src/components/account/StatusItem.tsx | 13 ++-- .../components/account/StatusItemWrapper.tsx | 8 ++- .../components/shared/FormSummaryDetails.tsx | 8 +-- 10 files changed, 189 insertions(+), 55 deletions(-) diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 0e626076ca..3fac28a622 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -95,6 +95,17 @@ export const defaultRaceEthnicityConfiguration: RaceEthnicityConfiguration = { ], }; +const applicationFactoryMany = async ( + count: number, + optionalParams?: Parameters[0], +): Promise => { + return Promise.all( + Array.from({ length: count }, async () => + applicationFactory(optionalParams), + ), + ); +}; + export const stagingSeed = async ( prismaClient: PrismaClient, jurisdictionName: string, @@ -596,7 +607,7 @@ export const stagingSeed = async ( jurisdictionsId: angelopolisJurisdiction.id, }, }); - await prismaClient.userAccounts.create({ + const advocate = await prismaClient.userAccounts.create({ data: await userFactory({ email: 'advocate@example.com', confirmedAt: new Date(), @@ -605,6 +616,7 @@ export const stagingSeed = async ( agencyId: agency.id, }), }); + // add jurisdiction specific translations and default ones await prismaClient.translations.create({ data: translationFactory({ @@ -929,7 +941,11 @@ export const stagingSeed = async ( // create pre-determined values const unitTypes = await unitTypeFactoryAll(prismaClient); await reservedCommunityTypeFactoryAll(mainJurisdiction.id, prismaClient); + const expiredApplicationDate = process.env.APPLICATION_DAYS_TILL_EXPIRY + ? dayjs(new Date()).subtract(10, 'days').toDate() + : undefined; // list of predefined listings WARNING: images only work if image setup is cloudinary on exygy account + const listingsToCreate: Parameters[] = [ [ angelopolisJurisdiction.id, @@ -982,12 +998,13 @@ export const stagingSeed = async ( hearingVisionAccessibilityNeedsProgramQuestion, ], applications: [ - await applicationFactory({ + ...(await applicationFactoryMany(2, { raceEthnicityConfiguration: angelopolisRaceEthnicityConfiguration, - }), - await applicationFactory({ + })), + ...(await applicationFactoryMany(20, { raceEthnicityConfiguration: angelopolisRaceEthnicityConfiguration, - }), + userId: advocate.id, + })), ], userAccounts: [{ id: partnerUser.id }], optionalFeatures: { carpetInUnit: true }, @@ -1056,8 +1073,7 @@ export const stagingSeed = async ( multiselectQuestions: [cityEmployeeQuestion], // has applications that are the same email and also same name/dob applications: [ - await applicationFactory(), - await applicationFactory(), + ...(await applicationFactoryMany(2)), await applicationFactory({ submissionType: ApplicationSubmissionTypeEnum.paper, }), @@ -1101,12 +1117,9 @@ export const stagingSeed = async ( birthYear: 1970, }, }), - await applicationFactory({ - applicant: { emailAddress: 'user2@example.com' }, - }), - await applicationFactory({ + ...(await applicationFactoryMany(2, { applicant: { emailAddress: 'user2@example.com' }, - }), + })), await applicationFactory({ applicant: { emailAddress: 'user3@example.com', @@ -1220,28 +1233,16 @@ export const stagingSeed = async ( applications: [ await applicationFactory({ isNewest: true, - expireAfter: process.env.APPLICATION_DAYS_TILL_EXPIRY - ? dayjs(new Date()).subtract(10, 'days').toDate() - : undefined, + expireAfter: expiredApplicationDate, }), // applications below should have their PII removed via the cron job - await applicationFactory({ - isNewest: false, - expireAfter: process.env.APPLICATION_DAYS_TILL_EXPIRY - ? dayjs(new Date()).subtract(10, 'days').toDate() - : undefined, - }), - await applicationFactory({ + ...(await applicationFactoryMany(2, { isNewest: false, - expireAfter: process.env.APPLICATION_DAYS_TILL_EXPIRY - ? dayjs(new Date()).subtract(10, 'days').toDate() - : undefined, - }), + expireAfter: expiredApplicationDate, + })), await applicationFactory({ isNewest: false, - expireAfter: process.env.APPLICATION_DAYS_TILL_EXPIRY - ? dayjs(new Date()).subtract(10, 'days').toDate() - : undefined, + expireAfter: expiredApplicationDate, householdMember: [ householdMemberFactorySingle(1, {}), householdMemberFactorySingle(2, {}), @@ -1280,12 +1281,9 @@ export const stagingSeed = async ( await applicationFactory({ multiselectQuestions: [workInCityQuestion, cityEmployeeQuestion], }), - await applicationFactory({ + ...(await applicationFactoryMany(2, { multiselectQuestions: [workInCityQuestion], - }), - await applicationFactory({ - multiselectQuestions: [workInCityQuestion], - }), + })), await applicationFactory(), ], multiselectQuestions: [ diff --git a/api/src/dtos/applications/public-apps-view-params.dto.ts b/api/src/dtos/applications/public-apps-view-params.dto.ts index 9c47c78793..908401dd3e 100644 --- a/api/src/dtos/applications/public-apps-view-params.dto.ts +++ b/api/src/dtos/applications/public-apps-view-params.dto.ts @@ -35,4 +35,10 @@ export class PublicAppsViewQueryParams extends PaginationQueryParams { { toClassOnly: true }, ) includeLotteryApps?: boolean; + + @Expose() + @ApiPropertyOptional() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + applicantNameSearch?: string; } diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index 5c750db741..6623ef06b9 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -424,12 +424,48 @@ export class ApplicationService { if (!user) { throw new ForbiddenException(); } - const whereClause = this.buildWhereClause(params); + const normalizedParams = { + ...params, + userId: user.isAdvocate ? user.id : params.userId, + }; + const whereClause = this.buildWhereClause(normalizedParams); + + if (user.isAdvocate && normalizedParams.applicantNameSearch) { + const searchTerms = normalizedParams.applicantNameSearch + .trim() + .split(/\s+/) + .filter(Boolean); + + if (Array.isArray(whereClause.AND) && searchTerms.length > 0) { + whereClause.AND.push({ + AND: searchTerms.map((term) => ({ + OR: [ + { + applicant: { + firstName: { + contains: term, + mode: 'insensitive', + }, + }, + }, + { + applicant: { + lastName: { + contains: term, + mode: 'insensitive', + }, + }, + }, + ], + })), + }); + } + } const buildCountWhereClause = (filterType: ApplicationsFilterEnum) => this.buildPublicAppsViewWhereClause( { - ...params, + ...normalizedParams, filterType, }, whereClause, @@ -452,12 +488,12 @@ export class ApplicationService { const totalCount = openCount + closedCount + lotteryCount; const displayWhereClause = this.buildPublicAppsViewWhereClause( - params, + normalizedParams, whereClause, ); let displayCount = totalCount; - switch (params.filterType) { + switch (normalizedParams.filterType) { case ApplicationsFilterEnum.open: displayCount = openCount; break; @@ -472,8 +508,8 @@ export class ApplicationService { break; } - const limit = params.limit ?? 10; - let page = params.page ?? 1; + const limit = normalizedParams.limit ?? 10; + let page = normalizedParams.page ?? 1; if (displayCount && limit && page > 1) { if (Math.ceil(displayCount / limit) < page) { @@ -491,6 +527,12 @@ export class ApplicationService { updatedAt: true, status: true, markedAsDuplicate: true, + applicant: { + select: { + firstName: true, + lastName: true, + }, + }, listings: { select: { id: true, diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 7d796ac357..76fa854b1e 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -193,6 +193,9 @@ "application.details.applicationStatus.waitlist": "Wait list", "application.details.applicationStatus.waitlistDeclined": "Wait list - Declined", "application.details.applicationStatusFaqLink": "What does my application status mean?", + "application.details.enterAtLeast3CharactersToSearch": "Enter at least 3 characters", + "application.details.searchApplicants": "Search applicants", + "application.details.searchApplicantsPlaceholder": "Search by first and last name", "application.edited": "Edited", "application.financial.income.instruction1": "Add up your total gross (pre-tax) household income from wages, benefits and other sources from all household members.", "application.financial.income.instruction2": "You only need to provide an estimated total right now. The actual total will be calculated if you are selected.", diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 62ec2554aa..e31de2e2da 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1506,6 +1506,8 @@ export class ApplicationsService { filterType?: ApplicationsFilterEnum /** */ includeLotteryApps?: boolean + /** */ + applicantNameSearch?: string } = {} as any, options: IRequestOptions = {} ): Promise { @@ -1519,6 +1521,7 @@ export class ApplicationsService { userId: params["userId"], filterType: params["filterType"], includeLotteryApps: params["includeLotteryApps"], + applicantNameSearch: params["applicantNameSearch"], } /** 适配ios13,get请求不允许带body */ diff --git a/sites/public/src/components/account/ApplicationsView.module.scss b/sites/public/src/components/account/ApplicationsView.module.scss index 20ce8b9e8f..0f7846c1bd 100644 --- a/sites/public/src/components/account/ApplicationsView.module.scss +++ b/sites/public/src/components/account/ApplicationsView.module.scss @@ -54,6 +54,22 @@ display: none; } +.application-search-container { + padding: var(--seeds-s4) var(--seeds-s12) 0; + @media (max-width: theme("screens.sm")) { + padding: var(--seeds-s2) var(--seeds-s4) 0; + } +} + +.application-search-input { + width: 100%; + border: 1px solid var(--seeds-border-color); + border-radius: var(--seeds-rounded); + padding: var(--seeds-s2) var(--seeds-s3); + background-color: var(--seeds-input-bg-color); + margin-top: var(--seeds-s2); +} + .pagination-section { padding: var(--seeds-s4) var(--seeds-s12); background-color: var(--seeds-bg-color-canvas); diff --git a/sites/public/src/components/account/ApplicationsView.tsx b/sites/public/src/components/account/ApplicationsView.tsx index ecc9f23aa6..6da80d29d6 100644 --- a/sites/public/src/components/account/ApplicationsView.tsx +++ b/sites/public/src/components/account/ApplicationsView.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useState, Fragment, useContext } from "react" +import React, { useEffect, useState, useContext } from "react" import { useRouter } from "next/router" import { t } from "@bloom-housing/ui-components" -import { Button, Card, LoadingState, Heading, Tabs, Link } from "@bloom-housing/ui-seeds" +import { Button, Card, LoadingState, Heading, Tabs } from "@bloom-housing/ui-seeds" import { PageView, pushGtmEvent, @@ -16,7 +16,6 @@ import { } from "@bloom-housing/shared-helpers/src/types/backend-swagger" import { StatusItemWrapper, AppWithListing } from "./StatusItemWrapper" import { UserStatus } from "../../lib/constants" - import styles from "./ApplicationsView.module.scss" export enum ApplicationsIndexEnum { @@ -25,6 +24,7 @@ export enum ApplicationsIndexEnum { closed, open, } + interface ApplicationsCount { total: number lottery: number @@ -42,12 +42,32 @@ const ApplicationsView = (props: ApplicationsViewProps) => { const [applications, setApplications] = useState() const [applicationsCount, setApplicationsCount] = useState() const [paginationMeta, setPaginationMeta] = useState() + const [searchInput, setSearchInput] = useState("") + const [debouncedSearch, setDebouncedSearch] = useState("") const [loading, setLoading] = useState(true) const [error, setError] = useState() const router = useRouter() const showPublicLottery = process.env.showPublicLottery const filterTypeString = ApplicationsIndexEnum[props.filterType] const page = Number(router.query.page) || 1 + const isAdvocate = !!profile?.isAdvocate + const minimumSearchCharacters = 3 + const searchDebounceMs = 500 + + useEffect(() => { + if (!isAdvocate) { + setDebouncedSearch("") + return + } + + const trimmedSearch = searchInput.trim() + + const timeoutId = setTimeout(() => { + setDebouncedSearch(trimmedSearch.length >= minimumSearchCharacters ? trimmedSearch : "") + }, searchDebounceMs) + + return () => clearTimeout(timeoutId) + }, [searchInput, isAdvocate]) useEffect(() => { if (profile) { @@ -65,6 +85,7 @@ const ApplicationsView = (props: ApplicationsViewProps) => { includeLotteryApps: !!showPublicLottery, page: page, limit: 10, + applicantNameSearch: isAdvocate && debouncedSearch ? debouncedSearch : undefined, }) .then((res) => { setApplications(res.items) @@ -77,7 +98,15 @@ const ApplicationsView = (props: ApplicationsViewProps) => { }) .finally(() => setLoading(false)) } - }, [profile, applicationsService, filterTypeString, showPublicLottery, page]) + }, [ + profile, + applicationsService, + filterTypeString, + showPublicLottery, + page, + debouncedSearch, + isAdvocate, + ]) const selectionHandler = (index: number) => { const baseUrl = "/account/applications" @@ -216,13 +245,14 @@ const ApplicationsView = (props: ApplicationsViewProps) => { - {props.enableApplicationStatus && ( + {/* // TODO: When application status copy is available and on the FAQ page, we can re-enable this */} + {/* {props.enableApplicationStatus && (
{t("application.details.applicationStatusFaqLink")}
- )} + )} */} { headingPriority={1} > <> + {isAdvocate && + !(!loading && paginationMeta?.totalItems === 0 && !debouncedSearch) && ( +
+ + setSearchInput(event.target.value)} + data-testid="applicant-name-search" + aria-describedby="search-sub-note" + /> +

+ {t("application.details.enterAtLeast3CharactersToSearch")} +

+
+ )} {applications?.map((application, index) => { return ( @@ -240,6 +294,7 @@ const ApplicationsView = (props: ApplicationsViewProps) => { key={index} application={application} enableApplicationStatus={props.enableApplicationStatus} + showApplicantName={profile?.isAdvocate} /> ) })} diff --git a/sites/public/src/components/account/StatusItem.tsx b/sites/public/src/components/account/StatusItem.tsx index 786289ccf2..75c849ee4a 100644 --- a/sites/public/src/components/account/StatusItem.tsx +++ b/sites/public/src/components/account/StatusItem.tsx @@ -10,6 +10,7 @@ import { import { TagVariant } from "@bloom-housing/ui-seeds/src/text/Tag" interface StatusItemProps { + applicantName?: string applicationDueDate?: string applicationURL: string confirmationNumber?: string | number @@ -69,9 +70,13 @@ const StatusItem = (props: StatusItemProps) => {
- - {props.listingName} - +
+ + {props.listingName} + + {props.applicantName &&

{props.applicantName}

} +
+

{tagText}

@@ -116,7 +121,7 @@ const StatusItem = (props: StatusItemProps) => {
-
diff --git a/sites/public/src/components/account/StatusItemWrapper.tsx b/sites/public/src/components/account/StatusItemWrapper.tsx index efb1068a53..c93faa0d34 100644 --- a/sites/public/src/components/account/StatusItemWrapper.tsx +++ b/sites/public/src/components/account/StatusItemWrapper.tsx @@ -16,6 +16,7 @@ export interface AppWithListing extends Application { interface StatusItemWrapperProps { application: AppWithListing enableApplicationStatus?: boolean + showApplicantName?: boolean } const StatusItemWrapper = (props: StatusItemWrapperProps) => { @@ -62,13 +63,18 @@ const StatusItemWrapper = (props: StatusItemWrapperProps) => { return ( { const labels = [] - if (application.accessibility.mobility) labels.push(t("application.ada.mobility")) - if (application.accessibility.vision) labels.push(t("application.ada.vision")) - if (application.accessibility.hearing) labels.push(t("application.ada.hearing")) - if (application.accessibility.other && enableAdaOtherOption) + if (application.accessibility?.mobility) labels.push(t("application.ada.mobility")) + if (application.accessibility?.vision) labels.push(t("application.ada.vision")) + if (application.accessibility?.hearing) labels.push(t("application.ada.hearing")) + if (application.accessibility?.other && enableAdaOtherOption) labels.push(t("application.ada.other")) if (labels.length === 0) labels.push(t("t.no")) From 9fe9e05a77de9bcd9ec1c1492f80d7a64f05b5f2 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Fri, 27 Feb 2026 20:20:28 -0700 Subject: [PATCH 02/11] test: flaky test --- .../permission-as-admin.e2e-spec.ts | 8 ++ ...n-as-juris-admin-correct-juris.e2e-spec.ts | 8 ++ ...ion-as-juris-admin-wrong-juris.e2e-spec.ts | 8 ++ ...ited-juris-admin-correct-juris.e2e-spec.ts | 8 ++ ...imited-juris-admin-wrong-juris.e2e-spec.ts | 8 ++ .../permission-as-no-user.e2e-spec.ts | 8 ++ ...ion-as-partner-correct-listing.e2e-spec.ts | 8 ++ ...ssion-as-partner-wrong-listing.e2e-spec.ts | 8 ++ .../permission-as-public.e2e-spec.ts | 8 ++ .../permission-as-support-admin.e2e-spec.ts | 8 ++ .../applications/ApplicationsView.test.tsx | 80 ++++++++++++++++++- 11 files changed, 157 insertions(+), 3 deletions(-) diff --git a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts index 150ca9ae5e..c5f8569af0 100644 --- a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts @@ -40,6 +40,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { @@ -75,6 +76,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as Admin User', () => { let app: INestApplication; let prisma: PrismaService; @@ -88,6 +94,8 @@ describe('Testing Permissioning of endpoints as Admin User', () => { }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts index 0678da19a1..a53f290141 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts @@ -39,6 +39,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { @@ -73,6 +74,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as Jurisdictional Admin in the correct jurisdiction', () => { let app: INestApplication; let prisma: PrismaService; @@ -85,6 +91,8 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts index 3b0a68b302..10ecfae410 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts @@ -39,6 +39,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { generateJurisdiction, @@ -72,6 +73,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wrong jurisdiction', () => { let app: INestApplication; let prisma: PrismaService; @@ -85,6 +91,8 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts index 811f2970fd..2f70d6e45a 100644 --- a/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts @@ -39,6 +39,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { @@ -72,6 +73,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in the correct jurisdiction', () => { let app: INestApplication; let prisma: PrismaService; @@ -84,6 +90,8 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts index 30781be37b..b6932b2cf0 100644 --- a/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts @@ -39,6 +39,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { generateJurisdiction, @@ -72,6 +73,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in the wrong jurisdiction', () => { let app: INestApplication; let prisma: PrismaService; @@ -85,6 +91,8 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts index afc7b6d579..31c9f1a19e 100644 --- a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts @@ -36,6 +36,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { unitRentTypeFactory } from '../../../prisma/seed-helpers/unit-rent-type-factory'; import { UnitRentTypeCreate } from '../../../src/dtos/unit-rent-types/unit-rent-type-create.dto'; @@ -74,6 +75,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as logged out user', () => { let app: INestApplication; let prisma: PrismaService; @@ -87,6 +93,8 @@ describe('Testing Permissioning of endpoints as logged out user', () => { }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts index 3933ee0ab2..b4f155613e 100644 --- a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts @@ -40,6 +40,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { @@ -76,6 +77,11 @@ const testEmailService = { lotteryPublishedApplicant: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as partner with correct listing', () => { let app: INestApplication; let prisma: PrismaService; @@ -95,6 +101,8 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts index 4ef05a939e..b4c0aa841d 100644 --- a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts @@ -40,6 +40,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { generateJurisdiction, @@ -72,6 +73,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as partner with wrong listing', () => { let app: INestApplication; let prisma: PrismaService; @@ -89,6 +95,8 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts index 6dcf078993..5d9f1bd4c0 100644 --- a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts @@ -40,6 +40,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { generateJurisdiction, @@ -75,6 +76,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as public user', () => { let app: INestApplication; let prisma: PrismaService; @@ -89,6 +95,8 @@ describe('Testing Permissioning of endpoints as public user', () => { }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts index 64bd555384..36159e7adc 100644 --- a/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts @@ -40,6 +40,7 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { @@ -77,6 +78,11 @@ const testEmailService = { applicationConfirmation: jest.fn(), }; +const testCronJobService = { + startCronJob: jest.fn().mockResolvedValue(undefined), + markCronJobAsStarted: jest.fn().mockResolvedValue(undefined), +}; + describe('Testing Permissioning of endpoints as Support Admin User', () => { let app: INestApplication; let prisma: PrismaService; @@ -90,6 +96,8 @@ describe('Testing Permissioning of endpoints as Support Admin User', () => { }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(CronJobService) + .useValue(testCronJobService) .compile(); app = moduleFixture.createNestApplication(); diff --git a/sites/public/__tests__/components/applications/ApplicationsView.test.tsx b/sites/public/__tests__/components/applications/ApplicationsView.test.tsx index 9dd7c69cec..df79c97ee2 100644 --- a/sites/public/__tests__/components/applications/ApplicationsView.test.tsx +++ b/sites/public/__tests__/components/applications/ApplicationsView.test.tsx @@ -1,5 +1,6 @@ import React from "react" import { cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { mockNextRouter, render, waitFor, within, screen } from "../../testUtils" import { AuthContext } from "@bloom-housing/shared-helpers" import ApplicationsView, { @@ -16,7 +17,6 @@ import { } from "@bloom-housing/shared-helpers/src/types/backend-swagger" import { setupServer } from "msw/lib/node" import { rest } from "msw" -import userEvent from "@testing-library/user-event" const server = setupServer() window.scrollTo = jest.fn() @@ -106,12 +106,13 @@ function getApplications( function renderApplicationsView( filterType = ApplicationsIndexEnum.all, - enableApplicationStatus = false + enableApplicationStatus = false, + profileOverrides = {} ) { return render( @@ -545,4 +546,77 @@ describe("", () => { expect(screen.queryByText("Your confirmation number is:")).not.toBeInTheDocument() }) }) + + describe("Advocate applicant search", () => { + it("should not show applicant search for non-advocate users", async () => { + server.use( + rest.get("http://localhost:3100/applications/publicAppsView", (_req, res, ctx) => { + return res(ctx.json(getApplications(1, 0, 0))) + }) + ) + + renderApplicationsView(ApplicationsIndexEnum.all, false, { isAdvocate: false }) + + expect(await screen.findAllByRole("link", { name: /view application/i })).toHaveLength(1) + expect(screen.queryByPlaceholderText("Search by first and last name")).not.toBeInTheDocument() + }) + + it("should hide search bar for advocate only when unfiltered fetch returns zero total applications", async () => { + server.use( + rest.get("http://localhost:3100/applications/publicAppsView", (_req, res, ctx) => { + return res(ctx.json(getApplications(0, 0, 0))) + }) + ) + + renderApplicationsView(ApplicationsIndexEnum.all, false, { isAdvocate: true }) + + expect( + await screen.findByText("It looks like you haven't applied to any listings yet.") + ).toBeInTheDocument() + expect(screen.queryByPlaceholderText("Search by first and last name")).not.toBeInTheDocument() + }) + + it("should keep search bar visible for advocate when unfiltered fetch has applications", async () => { + server.use( + rest.get("http://localhost:3100/applications/publicAppsView", (_req, res, ctx) => { + return res(ctx.json(getApplications(1, 0, 0))) + }) + ) + + renderApplicationsView(ApplicationsIndexEnum.all, false, { isAdvocate: true }) + + expect(await screen.findAllByRole("link", { name: /view application/i })).toHaveLength(1) + expect(screen.getByPlaceholderText("Search by first and last name")).toBeInTheDocument() + }) + + it("should send applicantNameSearch only when debounced input has at least 3 characters", async () => { + const applicantNameSearchParams: string[] = [] + + server.use( + rest.get("http://localhost:3100/applications/publicAppsView", (req, res, ctx) => { + applicantNameSearchParams.push(req.url.searchParams.get("applicantNameSearch") || "") + return res(ctx.json(getApplications(1, 0, 0))) + }), + rest.get("http://localhost/api/adapter/applications/publicAppsView", (req, res, ctx) => { + applicantNameSearchParams.push(req.url.searchParams.get("applicantNameSearch") || "") + return res(ctx.json(getApplications(1, 0, 0))) + }) + ) + + renderApplicationsView(ApplicationsIndexEnum.all, false, { isAdvocate: true }) + + const searchInput = await screen.findByPlaceholderText("Search by first and last name") + expect(applicantNameSearchParams).toEqual([""]) + + await userEvent.type(searchInput, "ab") + await new Promise((resolve) => setTimeout(resolve, 650)) + expect(applicantNameSearchParams).toEqual([""]) + + await userEvent.clear(searchInput) + await userEvent.type(searchInput, "abc") + await waitFor(() => expect(applicantNameSearchParams).toEqual(["", "abc"]), { + timeout: 2000, + }) + }) + }) }) From c0775b01d5dedeea2a1e448534e687a6cfbb0d18 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Fri, 27 Feb 2026 20:29:29 -0700 Subject: [PATCH 03/11] fix: test --- api/test/unit/services/application.service.spec.ts | 6 ++++++ sites/partners/cypress/e2e/default/04-application.spec.ts | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/api/test/unit/services/application.service.spec.ts b/api/test/unit/services/application.service.spec.ts index bbb4eced5b..70d0ddff1a 100644 --- a/api/test/unit/services/application.service.spec.ts +++ b/api/test/unit/services/application.service.spec.ts @@ -1049,6 +1049,12 @@ describe('Testing application service', () => { confirmationCode: true, accessibleUnitWaitlistNumber: true, conventionalUnitWaitlistNumber: true, + applicant: { + select: { + firstName: true, + lastName: true, + }, + }, updatedAt: true, status: true, markedAsDuplicate: true, diff --git a/sites/partners/cypress/e2e/default/04-application.spec.ts b/sites/partners/cypress/e2e/default/04-application.spec.ts index 330dcb3920..f2bfcf7da4 100644 --- a/sites/partners/cypress/e2e/default/04-application.spec.ts +++ b/sites/partners/cypress/e2e/default/04-application.spec.ts @@ -10,14 +10,14 @@ describe("Application Management Tests", () => { }) it("Application grid should display correct number of results", () => { - cy.getByID("lbTotalPages").contains("2") + cy.getByID("lbTotalPages").contains("22") cy.get(".applications-table") .first() .find(".ag-center-cols-container") .first() .find(".ag-row") .should((elems) => { - expect(elems).to.have.length(2) + expect(elems).to.have.length(8) }) }) From aa56b0752977a402bbcaf905e20b38a4647a2730 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Fri, 27 Feb 2026 20:35:19 -0700 Subject: [PATCH 04/11] feat: translations --- shared-helpers/src/locales/ar.json | 3 +++ shared-helpers/src/locales/bn.json | 7 +++++-- shared-helpers/src/locales/es.json | 7 +++++-- shared-helpers/src/locales/fa.json | 5 ++++- shared-helpers/src/locales/hy.json | 5 ++++- shared-helpers/src/locales/ko.json | 5 ++++- shared-helpers/src/locales/tl.json | 5 ++++- shared-helpers/src/locales/vi.json | 5 ++++- shared-helpers/src/locales/zh.json | 5 ++++- 9 files changed, 37 insertions(+), 10 deletions(-) diff --git a/shared-helpers/src/locales/ar.json b/shared-helpers/src/locales/ar.json index a202f78e08..4271564b85 100644 --- a/shared-helpers/src/locales/ar.json +++ b/shared-helpers/src/locales/ar.json @@ -194,6 +194,9 @@ "application.details.applicationStatus.waitlist": "قائمة الانتظار", "application.details.applicationStatus.waitlistDeclined": "قائمة الانتظار - مرفوض", "application.details.applicationStatusFaqLink": "ماذا تعني حالة طلبي؟", + "application.details.enterAtLeast3CharactersToSearch": "أدخل 3 أحرف على الأقل", + "application.details.searchApplicants": "البحث عن المتقدمين", + "application.details.searchApplicantsPlaceholder": "ابحث باستخدام الاسم الأول واسم العائلة", "application.edited": "تم تحريره", "application.financial.income.instruction1": "اجمع إجمالي دخل الأسرة (قبل الضريبة) من الأجور والمزايا والمصادر الأخرى من جميع أفراد الأسرة.", "application.financial.income.instruction2": "ما عليك سوى تقديم إجمالي تقديري الآن. سيتم احتساب الإجمالي الفعلي إذا تم اختيارك.", diff --git a/shared-helpers/src/locales/bn.json b/shared-helpers/src/locales/bn.json index aa6fc59981..03383d8a68 100644 --- a/shared-helpers/src/locales/bn.json +++ b/shared-helpers/src/locales/bn.json @@ -194,6 +194,9 @@ "application.details.applicationStatus.waitlist": "অপেক্ষা তালিকা", "application.details.applicationStatus.waitlistDeclined": "অপেক্ষা তালিকা - প্রত্যাখ্যান করা হয়েছে", "application.details.applicationStatusFaqLink": "আমার আবেদনের স্থিতির অর্থ কী?", + "application.details.enterAtLeast3CharactersToSearch": "কমপক্ষে ৩টি অক্ষর লিখুন", + "application.details.searchApplicants": "আবেদনকারীদের অনুসন্ধান করুন", + "application.details.searchApplicantsPlaceholder": "প্রথম এবং শেষ নাম অনুসারে অনুসন্ধান করুন", "application.edited": "সম্পাদিত", "application.financial.income.instruction1": "পরিবারের মোট সদস্যদের মজুরি, সুবিধা এবং অন্যান্য উৎস থেকে আপনার মোট মোট (কর-পূর্ব) পরিবারের আয় যোগ করুন।", "application.financial.income.instruction2": "আপনাকে এখনই একটি আনুমানিক মোট প্রদান করতে হবে। আপনি নির্বাচিত হলে প্রকৃত মোট গণনা করা হবে।", @@ -745,8 +748,8 @@ "listing.tags.accessible": "প্রবেশযোগ্য", "listingFilters.clear": "পরিষ্কার", "listingFilters.program.Families": "পরিবার", - "listingFilters.program.hearingVisionAccessibilityNeeds": "শ্রবণ/দৃষ্টি অ্যাক্সেসযোগ্যতার প্রয়োজন", - "listingFilters.program.mobilityAccessibilityNeeds":"গতিশীলতা অ্যাক্সেসযোগ্যতার প্রয়োজন", + "listingFilters.program.hearingVisionAccessibilityNeeds": "শ্রবণ/দৃষ্টি অ্যাক্সেসযোগ্যতার প্রয়োজন", + "listingFilters.program.mobilityAccessibilityNeeds": "গতিশীলতা অ্যাক্সেসযোগ্যতার প্রয়োজন", "listingFilters.program.Referral only": "শুধুমাত্র রেফারেল", "listingFilters.program.Residents with Disabilities": "প্রতিবন্ধী বাসিন্দারা", "listingFilters.program.Seniors 55+": "প্রবীণ 55+", diff --git a/shared-helpers/src/locales/es.json b/shared-helpers/src/locales/es.json index 553493f6c4..267168348f 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -194,6 +194,9 @@ "application.details.applicationStatus.waitlist": "Lista de espera", "application.details.applicationStatus.waitlistDeclined": "Lista de espera - Rechazada", "application.details.applicationStatusFaqLink": "¿Qué significa el estado de mi solicitud?", + "application.details.enterAtLeast3CharactersToSearch": "Introduzca al menos 3 caracteres", + "application.details.searchApplicants": "Buscar solicitantes", + "application.details.searchApplicantsPlaceholder": "Buscar por nombre y apellido", "application.edited": "Editada", "application.financial.income.instruction1": "Sume los ingresos brutos totales (antes de impuestos) provenientes de salarios, beneficios y otras fuentes de todos los miembros del hogar.", "application.financial.income.instruction2": "En este momento solo tiene que proporcionar un total aproximado. El total real será calculado si usted es seleccionado(a).", @@ -745,8 +748,8 @@ "listing.tags.accessible": "Accesible", "listingFilters.clear": "Borrar", "listingFilters.program.Families": "Familias", - "listingFilters.program.hearingVisionAccessibilityNeeds": "Necesidades de accesibilidad auditiva/visual", - "listingFilters.program.mobilityAccessibilityNeeds":"Necesidades de accesibilidad a la movilidad", + "listingFilters.program.hearingVisionAccessibilityNeeds": "Necesidades de accesibilidad auditiva/visual", + "listingFilters.program.mobilityAccessibilityNeeds": "Necesidades de accesibilidad a la movilidad", "listingFilters.program.Referral only": "Sólo por referencia", "listingFilters.program.Residents with Disabilities": "Residentes con discapacidades", "listingFilters.program.Seniors 55+": "Adultos mayores de 55 años", diff --git a/shared-helpers/src/locales/fa.json b/shared-helpers/src/locales/fa.json index ae2c97e579..61829c63ed 100644 --- a/shared-helpers/src/locales/fa.json +++ b/shared-helpers/src/locales/fa.json @@ -193,6 +193,9 @@ "application.details.applicationStatus.waitlist": "لیست انتظار", "application.details.applicationStatus.waitlistDeclined": "لیست انتظار - رد شد", "application.details.applicationStatusFaqLink": "وضعیت درخواست من به چه معناست؟", + "application.details.enterAtLeast3CharactersToSearch": "حداقل ۳ کاراکتر وارد کنید", + "application.details.searchApplicants": "جستجوی متقاضیان", + "application.details.searchApplicantsPlaceholder": "جستجو بر اساس نام و نام خانوادگی", "application.edited": "ویرایش شده", "application.financial.income.instruction1": "کل درآمد ناخالص خانوار (قبل از کسر مالیات) خود را که از دستمزد، مزایا و سایر منابع از همه اعضای خانوار دریافت می‌کنید، جمع کنید.", "application.financial.income.instruction2": "شما فقط باید در حال حاضر یک جمع تخمینی ارائه دهید. در صورت انتخاب شدن، جمع واقعی محاسبه خواهد شد.", @@ -742,7 +745,7 @@ "listingFilters.clear": "پاک کردن", "listingFilters.program.Families": "خانواده‌ها", "listingFilters.program.hearingVisionAccessibilityNeeds": "نیازهای دسترسی به شنوایی/بینایی", - "listingFilters.program.mobilityAccessibilityNeeds":"نیازهای دسترسی به تحرک", + "listingFilters.program.mobilityAccessibilityNeeds": "نیازهای دسترسی به تحرک", "listingFilters.program.Referral only": "فقط ارجاع", "listingFilters.program.Residents with Disabilities": "ساکنان دارای معلولیت", "listingFilters.program.Seniors 55+": "سالمندان ۵۵ سال به بالا", diff --git a/shared-helpers/src/locales/hy.json b/shared-helpers/src/locales/hy.json index cf862f33cd..76ed50ffe0 100644 --- a/shared-helpers/src/locales/hy.json +++ b/shared-helpers/src/locales/hy.json @@ -193,6 +193,9 @@ "application.details.applicationStatus.waitlist": "Սպասման ցուցակ", "application.details.applicationStatus.waitlistDeclined": "Սպասման ցուցակ - Մերժված է", "application.details.applicationStatusFaqLink": "Ի՞նչ է նշանակում իմ դիմումի կարգավիճակը։", + "application.details.enterAtLeast3CharactersToSearch": "Մուտքագրեք առնվազն 3 նիշ", + "application.details.searchApplicants": "Դիմորդների որոնում", + "application.details.searchApplicantsPlaceholder": "Որոնել անունով և ազգանունով", "application.edited": "Խմբագրված է", "application.financial.income.instruction1": "Գումարեք ձեր տնային տնտեսության ընդհանուր համախառն (հարկումից առաջ) եկամուտը՝ ստացված աշխատավարձից, նպաստներից և տնային տնտեսության բոլոր անդամների այլ աղբյուրներից։", "application.financial.income.instruction2": "Դուք պետք է տրամադրեք միայն մոտավոր ընդհանուր գումարը հենց հիմա։ Եթե ընտրվեք, կհաշվարկվի իրական ընդհանուր գումարը։", @@ -742,7 +745,7 @@ "listingFilters.clear": "Մաքրել", "listingFilters.program.Families": "Ընտանիքներ", "listingFilters.program.hearingVisionAccessibilityNeeds": "Լսողության/տեսողության հասանելիության կարիքները", - "listingFilters.program.mobilityAccessibilityNeeds":"Շարժունակության մատչելիության կարիքները", + "listingFilters.program.mobilityAccessibilityNeeds": "Շարժունակության մատչելիության կարիքները", "listingFilters.program.Referral only": "Միայն ուղղորդում", "listingFilters.program.Residents with Disabilities": "Հաշմանդամություն ունեցող բնակիչներ", "listingFilters.program.Seniors 55+": "55+ տարեկան տարեցներ", diff --git a/shared-helpers/src/locales/ko.json b/shared-helpers/src/locales/ko.json index 84cee468d6..5703d43638 100644 --- a/shared-helpers/src/locales/ko.json +++ b/shared-helpers/src/locales/ko.json @@ -193,6 +193,9 @@ "application.details.applicationStatus.waitlist": "대기자 명단", "application.details.applicationStatus.waitlistDeclined": "대기자 명단 - 거절됨", "application.details.applicationStatusFaqLink": "내 신청 상태는 무엇을 의미하나요?", + "application.details.enterAtLeast3CharactersToSearch": "최소 3자 이상 입력하세요", + "application.details.searchApplicants": "지원자 검색", + "application.details.searchApplicantsPlaceholder": "이름과 성으로 검색하세요", "application.edited": "편집됨", "application.financial.income.instruction1": "가구 구성원 모두의 임금, 수당 및 기타 소득을 합산하여 세전 총 가계 소득을 계산하십시오.", "application.financial.income.instruction2": "지금은 예상 총액만 입력하시면 됩니다. 실제 총액은 선정되신 경우에 계산됩니다.", @@ -742,7 +745,7 @@ "listingFilters.clear": "분명한", "listingFilters.program.Families": "가족", "listingFilters.program.hearingVisionAccessibilityNeeds": "청각/시각 접근성 요구 사항", - "listingFilters.program.mobilityAccessibilityNeeds":"이동성 접근성 요구 사항", + "listingFilters.program.mobilityAccessibilityNeeds": "이동성 접근성 요구 사항", "listingFilters.program.Referral only": "추천 전용", "listingFilters.program.Residents with Disabilities": "장애인 거주자", "listingFilters.program.Seniors 55+": "55세 이상 시니어", diff --git a/shared-helpers/src/locales/tl.json b/shared-helpers/src/locales/tl.json index 7a22a3edf0..23741a649c 100644 --- a/shared-helpers/src/locales/tl.json +++ b/shared-helpers/src/locales/tl.json @@ -194,6 +194,9 @@ "application.details.applicationStatus.waitlist": "Waitlist", "application.details.applicationStatus.waitlistDeclined": "Waitlist - Tinanggihan", "application.details.applicationStatusFaqLink": "Ano ang ibig sabihin ng katayuan ng aking aplikasyon?", + "application.details.enterAtLeast3CharactersToSearch": "Maglagay ng kahit man lang 3 karakter", + "application.details.searchApplicants": "Maghanap ng mga aplikante", + "application.details.searchApplicantsPlaceholder": "Maghanap ayon sa pangalan at apelyido", "application.edited": "Binago", "application.financial.income.instruction1": "Idagdag ang iyong kabuuang (bago ang buwis) na kita ng sambahayan mula sa sahod, benepisyo at iba pang pinagkukunan mula sa lahat ng miyembro ng sambahayan.", "application.financial.income.instruction2": "Kailangan mo lamang magbigay ng tinantyang kabuuan ngayon. Ang aktuwal na kabuuan ay kukuwentahin kung ikaw ay napili.", @@ -746,7 +749,7 @@ "listingFilters.clear": "Maaliwalas", "listingFilters.program.Families": "Mga pamilya", "listingFilters.program.hearingVisionAccessibilityNeeds": "Mga pangangailangan sa accessibility sa pandinig/pangitain", - "listingFilters.program.mobilityAccessibilityNeeds":"Mga pangangailangan sa kadaliang mapakilos", + "listingFilters.program.mobilityAccessibilityNeeds": "Mga pangangailangan sa kadaliang mapakilos", "listingFilters.program.Referral only": "Referral lamang", "listingFilters.program.Residents with Disabilities": "Mga residenteng may kapansanan", "listingFilters.program.Seniors 55+": "Mga nakatatanda 55+", diff --git a/shared-helpers/src/locales/vi.json b/shared-helpers/src/locales/vi.json index 3329da9805..51f702c70d 100644 --- a/shared-helpers/src/locales/vi.json +++ b/shared-helpers/src/locales/vi.json @@ -194,6 +194,9 @@ "application.details.applicationStatus.waitlist": "Danh sách chờ", "application.details.applicationStatus.waitlistDeclined": "Danh sách chờ - Đã từ chối", "application.details.applicationStatusFaqLink": "Trạng thái hồ sơ của tôi có ý nghĩa gì?", + "application.details.enterAtLeast3CharactersToSearch": "Nhập ít nhất 3 ký tự", + "application.details.searchApplicants": "Tìm kiếm ứng viên", + "application.details.searchApplicantsPlaceholder": "Tìm kiếm theo tên và họ", "application.edited": "Đã chỉnh sửa", "application.financial.income.instruction1": "Tính tổng thu nhập hộ gia đình (trước thuế) từ tiền lương, tiền trợ cấp và các nguồn khác từ tất cả các thành viên trong hộ gia đình.", "application.financial.income.instruction2": "Ngay bây giờ, quý vị chỉ cần cung cấp tổng số tiền ước tính. Tổng số tiền thực tế sẽ được tính nếu quý vị được chọn.", @@ -747,7 +750,7 @@ "listingFilters.clear": "Xóa", "listingFilters.program.Families": "Gia đình", "listingFilters.program.hearingVisionAccessibilityNeeds": "Nhu cầu tiếp cận thính giác/thị giác", - "listingFilters.program.mobilityAccessibilityNeeds":"Nhu cầu tiếp cận di động", + "listingFilters.program.mobilityAccessibilityNeeds": "Nhu cầu tiếp cận di động", "listingFilters.program.Referral only": "Chỉ giới thiệu", "listingFilters.program.Residents with Disabilities": "Người dân khuyết tật", "listingFilters.program.Seniors 55+": "Người cao tuổi từ 55 tuổi trở lên", diff --git a/shared-helpers/src/locales/zh.json b/shared-helpers/src/locales/zh.json index 2e118c9de9..270619a3a8 100644 --- a/shared-helpers/src/locales/zh.json +++ b/shared-helpers/src/locales/zh.json @@ -194,6 +194,9 @@ "application.details.applicationStatus.waitlist": "候補名單", "application.details.applicationStatus.waitlistDeclined": "候補名單 - 已拒絕", "application.details.applicationStatusFaqLink": "我的申请状态是什么意思?", + "application.details.enterAtLeast3CharactersToSearch": "请输入至少 3 个字符", + "application.details.searchApplicants": "搜索申请人", + "application.details.searchApplicantsPlaceholder": "按名字和姓氏搜索", "application.edited": "已修改", "application.financial.income.instruction1": "請將所有家庭成員的工資、福利和其他收入來源相加,得出您的家庭總收入(稅前)。", "application.financial.income.instruction2": "您現在只需要提供估計的總額。如果您被選中,您將需計算實際總數。", @@ -746,7 +749,7 @@ "listingFilters.clear": "清除", "listingFilters.program.Families": "家庭", "listingFilters.program.hearingVisionAccessibilityNeeds": "聽力/視力無障礙需求", - "listingFilters.program.mobilityAccessibilityNeeds":"流動性無障礙需求", + "listingFilters.program.mobilityAccessibilityNeeds": "流動性無障礙需求", "listingFilters.program.Referral only": "仅限推荐人", "listingFilters.program.Residents with Disabilities": "残疾居民", "listingFilters.program.Seniors 55+": "55岁以上的老年人", From b00b46d77080451ba1816faf22bb85f355f6f899 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Fri, 27 Feb 2026 21:54:58 -0700 Subject: [PATCH 05/11] feat: translations --- api/src/services/application.service.ts | 2 +- api/test/integration/application.e2e-spec.ts | 87 +++++++++++++++++-- .../account/EditAdvocateAccount.test.tsx | 14 +-- .../account/EditPublicAccount.test.tsx | 10 ++- 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index 6623ef06b9..6fcfa5173b 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -426,7 +426,7 @@ export class ApplicationService { } const normalizedParams = { ...params, - userId: user.isAdvocate ? user.id : params.userId, + userId: user.id, }; const whereClause = this.buildWhereClause(normalizedParams); diff --git a/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index 08af10c13f..45d0bb3c90 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -2078,14 +2078,32 @@ describe('Application Controller Tests', () => { }); describe('publicAppsView endpoint', () => { + const createAndLoginUser = async () => { + const user = await prisma.userAccounts.create({ + data: await userFactory({ + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + email: user.email, + password: 'Abcdef12345!', + } as Login) + .expect(201); + + return { user, cookies: resLogIn.headers['set-cookie'] }; + }; + it('should retrieve applications and counts when they exist', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, ); - const user = await prisma.userAccounts.create({ - data: await userFactory(), - }); + const { user, cookies } = await createAndLoginUser(); const juris = await prisma.jurisdictions.create({ data: jurisdictionFactory(), }); @@ -2152,7 +2170,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications/publicAppsView?${query}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', adminCookies) + .set('Cookie', cookies) .expect(200); expect(res.body.applicationsCount.total).toEqual(3); @@ -2168,12 +2186,10 @@ describe('Application Controller Tests', () => { }); it('should not retrieve applications nor error when none exist', async () => { - const userA = await prisma.userAccounts.create({ - data: await userFactory(), - }); + const { user, cookies } = await createAndLoginUser(); const queryParams: PublicAppsViewQueryParams = { - userId: userA.id, + userId: user.id, filterType: ApplicationsFilterEnum.all, includeLotteryApps: true, page: 1, @@ -2184,7 +2200,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications/publicAppsView?${query}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', adminCookies) + .set('Cookie', cookies) .expect(200); expect(res.body.applicationsCount.total).toEqual(0); @@ -2192,6 +2208,59 @@ describe('Application Controller Tests', () => { expect(res.body.meta.currentPage).toEqual(1); expect(res.body.meta.totalItems).toEqual(0); }); + + it('should ignore query userId and scope results to the authenticated user', async () => { + const unitTypeA = await unitTypeFactorySingle( + prisma, + UnitTypeEnum.oneBdrm, + ); + const { user: authenticatedUser, cookies } = await createAndLoginUser(); + const otherUser = await prisma.userAccounts.create({ + data: await userFactory(), + }); + const juris = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + await reservedCommunityTypeFactoryAll(juris.id, prisma); + const listingOpen = await listingFactory(juris.id, prisma, { + status: ListingsStatusEnum.active, + }); + const listingOpenCreated = await prisma.listings.create({ + data: listingOpen, + }); + + const app = await applicationFactory({ + unitTypeId: unitTypeA.id, + userId: authenticatedUser.id, + listingId: listingOpenCreated.id, + }); + + await prisma.applications.create({ + data: app, + include: { + applicant: true, + }, + }); + + const queryParams: PublicAppsViewQueryParams = { + userId: otherUser.id, + filterType: ApplicationsFilterEnum.all, + includeLotteryApps: true, + page: 1, + limit: 10, + }; + const query = stringify(queryParams as any); + + const res = await request(app.getHttpServer()) + .get(`/applications/publicAppsView?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.applicationsCount.total).toEqual(1); + expect(res.body.items.length).toEqual(1); + expect(res.body.items[0].userId).toEqual(authenticatedUser.id); + }); }); describe('removePIICronJob endpoint', () => { diff --git a/sites/public/__tests__/components/account/EditAdvocateAccount.test.tsx b/sites/public/__tests__/components/account/EditAdvocateAccount.test.tsx index 41c4755b89..fe78c2be02 100644 --- a/sites/public/__tests__/components/account/EditAdvocateAccount.test.tsx +++ b/sites/public/__tests__/components/account/EditAdvocateAccount.test.tsx @@ -87,12 +87,14 @@ describe("EditAdvocateAccount", () => { }) // These are the IDs of the form sections we expect to appear - expect(document.getElementById("update-name")).toBeInTheDocument() - expect(document.getElementById("update-agency")).toBeInTheDocument() - expect(document.getElementById("update-address")).toBeInTheDocument() - expect(document.getElementById("update-phone-number")).toBeInTheDocument() - expect(document.getElementById("update-email")).toBeInTheDocument() - expect(document.getElementById("update-password")).toBeInTheDocument() + await waitFor(() => { + expect(document.getElementById("update-name")).toBeInTheDocument() + expect(document.getElementById("update-agency")).toBeInTheDocument() + expect(document.getElementById("update-address")).toBeInTheDocument() + expect(document.getElementById("update-phone-number")).toBeInTheDocument() + expect(document.getElementById("update-email")).toBeInTheDocument() + expect(document.getElementById("update-password")).toBeInTheDocument() + }) }) it("should submit agency updates through updateAdvocate", async () => { diff --git a/sites/public/__tests__/components/account/EditPublicAccount.test.tsx b/sites/public/__tests__/components/account/EditPublicAccount.test.tsx index cb70b04a65..ede3fdd282 100644 --- a/sites/public/__tests__/components/account/EditPublicAccount.test.tsx +++ b/sites/public/__tests__/components/account/EditPublicAccount.test.tsx @@ -71,10 +71,12 @@ describe("EditPublicAccount", () => { }) // These are the IDs of the form sections we expect to appear - expect(document.getElementById("update-name")).toBeInTheDocument() - expect(document.getElementById("update-birthdate")).toBeInTheDocument() - expect(document.getElementById("update-email")).toBeInTheDocument() - expect(document.getElementById("update-password")).toBeInTheDocument() + await waitFor(() => { + expect(document.getElementById("update-name")).toBeInTheDocument() + expect(document.getElementById("update-birthdate")).toBeInTheDocument() + expect(document.getElementById("update-email")).toBeInTheDocument() + expect(document.getElementById("update-password")).toBeInTheDocument() + }) }) describe("Name form", () => { From aa34adb9eb3fb7f6639edab6f0af3c8358f08130 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Fri, 27 Feb 2026 22:02:48 -0700 Subject: [PATCH 06/11] test: be test --- api/test/integration/application.e2e-spec.ts | 87 ++------------------ 1 file changed, 9 insertions(+), 78 deletions(-) diff --git a/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index 45d0bb3c90..08af10c13f 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -2078,32 +2078,14 @@ describe('Application Controller Tests', () => { }); describe('publicAppsView endpoint', () => { - const createAndLoginUser = async () => { - const user = await prisma.userAccounts.create({ - data: await userFactory({ - mfaEnabled: false, - confirmedAt: new Date(), - }), - }); - - const resLogIn = await request(app.getHttpServer()) - .post('/auth/login') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - email: user.email, - password: 'Abcdef12345!', - } as Login) - .expect(201); - - return { user, cookies: resLogIn.headers['set-cookie'] }; - }; - it('should retrieve applications and counts when they exist', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, ); - const { user, cookies } = await createAndLoginUser(); + const user = await prisma.userAccounts.create({ + data: await userFactory(), + }); const juris = await prisma.jurisdictions.create({ data: jurisdictionFactory(), }); @@ -2170,7 +2152,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications/publicAppsView?${query}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) + .set('Cookie', adminCookies) .expect(200); expect(res.body.applicationsCount.total).toEqual(3); @@ -2186,10 +2168,12 @@ describe('Application Controller Tests', () => { }); it('should not retrieve applications nor error when none exist', async () => { - const { user, cookies } = await createAndLoginUser(); + const userA = await prisma.userAccounts.create({ + data: await userFactory(), + }); const queryParams: PublicAppsViewQueryParams = { - userId: user.id, + userId: userA.id, filterType: ApplicationsFilterEnum.all, includeLotteryApps: true, page: 1, @@ -2200,7 +2184,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications/publicAppsView?${query}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) + .set('Cookie', adminCookies) .expect(200); expect(res.body.applicationsCount.total).toEqual(0); @@ -2208,59 +2192,6 @@ describe('Application Controller Tests', () => { expect(res.body.meta.currentPage).toEqual(1); expect(res.body.meta.totalItems).toEqual(0); }); - - it('should ignore query userId and scope results to the authenticated user', async () => { - const unitTypeA = await unitTypeFactorySingle( - prisma, - UnitTypeEnum.oneBdrm, - ); - const { user: authenticatedUser, cookies } = await createAndLoginUser(); - const otherUser = await prisma.userAccounts.create({ - data: await userFactory(), - }); - const juris = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), - }); - await reservedCommunityTypeFactoryAll(juris.id, prisma); - const listingOpen = await listingFactory(juris.id, prisma, { - status: ListingsStatusEnum.active, - }); - const listingOpenCreated = await prisma.listings.create({ - data: listingOpen, - }); - - const app = await applicationFactory({ - unitTypeId: unitTypeA.id, - userId: authenticatedUser.id, - listingId: listingOpenCreated.id, - }); - - await prisma.applications.create({ - data: app, - include: { - applicant: true, - }, - }); - - const queryParams: PublicAppsViewQueryParams = { - userId: otherUser.id, - filterType: ApplicationsFilterEnum.all, - includeLotteryApps: true, - page: 1, - limit: 10, - }; - const query = stringify(queryParams as any); - - const res = await request(app.getHttpServer()) - .get(`/applications/publicAppsView?${query}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(200); - - expect(res.body.applicationsCount.total).toEqual(1); - expect(res.body.items.length).toEqual(1); - expect(res.body.items[0].userId).toEqual(authenticatedUser.id); - }); }); describe('removePIICronJob endpoint', () => { From 26088d88604984946523ee2b5d40b80272ddbdc0 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Fri, 27 Feb 2026 22:15:42 -0700 Subject: [PATCH 07/11] fix: error toasts --- .../applications/ApplicationsView.test.tsx | 51 +++++++++++++++---- .../components/account/ApplicationsView.tsx | 13 ++++- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/sites/public/__tests__/components/applications/ApplicationsView.test.tsx b/sites/public/__tests__/components/applications/ApplicationsView.test.tsx index df79c97ee2..63b1d0a372 100644 --- a/sites/public/__tests__/components/applications/ApplicationsView.test.tsx +++ b/sites/public/__tests__/components/applications/ApplicationsView.test.tsx @@ -2,7 +2,7 @@ import React from "react" import { cleanup } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { mockNextRouter, render, waitFor, within, screen } from "../../testUtils" -import { AuthContext } from "@bloom-housing/shared-helpers" +import { AuthContext, MessageContext } from "@bloom-housing/shared-helpers" import ApplicationsView, { ApplicationsIndexEnum, } from "../../../src/components/account/ApplicationsView" @@ -107,17 +107,29 @@ function getApplications( function renderApplicationsView( filterType = ApplicationsIndexEnum.all, enableApplicationStatus = false, - profileOverrides = {} + profileOverrides = {}, + messageContextOverrides = {} ) { return render( - - - + + + + ) } @@ -151,12 +163,14 @@ describe("", () => { }) it("should render the page with application fetching error", async () => { + const addToast = jest.fn() + server.use( rest.get("http://localhost:3100/applications/publicAppsView", (_req, res, ctx) => { return res(ctx.status(500)) // Return status code 500 to mock an server fetching error }) ) - renderApplicationsView() + renderApplicationsView(ApplicationsIndexEnum.all, false, {}, { addToast }) // Dashboard heading expect(screen.getByRole("heading", { level: 1, name: /my applications/i })).toBeInTheDocument() @@ -164,10 +178,14 @@ describe("", () => { screen.getByText("See listings for properties for which you’ve applied.") ).toBeInTheDocument() - // Application section (Missing fallback component) expect( await screen.findByRole("heading", { level: 2, name: /error fetching applications/i }) ).toBeInTheDocument() + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/error fetching applications/i), { + variant: "alert", + }) + }) }) describe("should render page with proper missing applications message", () => { @@ -589,6 +607,21 @@ describe("", () => { expect(screen.getByPlaceholderText("Search by first and last name")).toBeInTheDocument() }) + it("should not show search on initial load, then show it after first fetch for advocates with applications", async () => { + server.use( + rest.get("http://localhost:3100/applications/publicAppsView", (_req, res, ctx) => { + return res(ctx.delay(150), ctx.json(getApplications(1, 0, 0))) + }) + ) + + renderApplicationsView(ApplicationsIndexEnum.all, false, { isAdvocate: true }) + + expect(screen.queryByPlaceholderText("Search by first and last name")).not.toBeInTheDocument() + + expect(await screen.findAllByRole("link", { name: /view application/i })).toHaveLength(1) + expect(screen.getByPlaceholderText("Search by first and last name")).toBeInTheDocument() + }) + it("should send applicantNameSearch only when debounced input has at least 3 characters", async () => { const applicantNameSearchParams: string[] = [] diff --git a/sites/public/src/components/account/ApplicationsView.tsx b/sites/public/src/components/account/ApplicationsView.tsx index 6da80d29d6..9b30ad9ce5 100644 --- a/sites/public/src/components/account/ApplicationsView.tsx +++ b/sites/public/src/components/account/ApplicationsView.tsx @@ -6,6 +6,7 @@ import { PageView, pushGtmEvent, AuthContext, + useToastyRef, RequireLogin, BloomCard, } from "@bloom-housing/shared-helpers" @@ -39,11 +40,13 @@ interface ApplicationsViewProps { const ApplicationsView = (props: ApplicationsViewProps) => { const { applicationsService, profile } = useContext(AuthContext) + const toastyRef = useToastyRef() const [applications, setApplications] = useState() const [applicationsCount, setApplicationsCount] = useState() const [paginationMeta, setPaginationMeta] = useState() const [searchInput, setSearchInput] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") + const [hasLoadedOnce, setHasLoadedOnce] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState() const router = useRouter() @@ -94,9 +97,13 @@ const ApplicationsView = (props: ApplicationsViewProps) => { }) .catch((err) => { console.error(`Error fetching applications: ${err}`) + toastyRef.current.addToast(t("account.errorFetchingApplications"), { variant: "alert" }) setError(err) }) - .finally(() => setLoading(false)) + .finally(() => { + setLoading(false) + setHasLoadedOnce(true) + }) } }, [ profile, @@ -106,6 +113,7 @@ const ApplicationsView = (props: ApplicationsViewProps) => { page, debouncedSearch, isAdvocate, + toastyRef, ]) const selectionHandler = (index: number) => { @@ -263,7 +271,8 @@ const ApplicationsView = (props: ApplicationsViewProps) => { headingPriority={1} > <> - {isAdvocate && + {hasLoadedOnce && + isAdvocate && !(!loading && paginationMeta?.totalItems === 0 && !debouncedSearch) && (
From 162b8b3a2314304c07fb603df137485feb92c4f3 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Wed, 4 Mar 2026 14:02:32 -0700 Subject: [PATCH 11/11] refactor: pr review --- api/prisma/seed-dev.ts | 27 ++-- .../seed-helpers/application-factory.ts | 21 +++ api/prisma/seed-staging.ts | 46 +----- api/test/integration/application.e2e-spec.ts | 151 ++++++++++++++++++ .../components/account/ApplicationsView.tsx | 11 +- 5 files changed, 198 insertions(+), 58 deletions(-) diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 61d2426eba..a9d5686f22 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -12,7 +12,7 @@ import { listingFactory } from './seed-helpers/listing-factory'; import { unitTypeFactoryAll } from './seed-helpers/unit-type-factory'; import { randomName } from './seed-helpers/word-generator'; import { randomInt } from 'node:crypto'; -import { applicationFactory } from './seed-helpers/application-factory'; +import { applicationFactoryMany } from './seed-helpers/application-factory'; import { translationFactory } from './seed-helpers/translation-factory'; import { reservedCommunityTypeFactoryAll } from './seed-helpers/reserved-community-type-factory'; import { householdMemberFactoryMany } from './seed-helpers/household-member-factory'; @@ -156,18 +156,19 @@ export const devSeeding = async ( for (let index = 0; index < LISTINGS_TO_SEED; index++) { const applications = []; - for (let j = 0; j < APPLICATIONS_PER_LISTINGS; j++) { - const householdSize = randomInt(1, 6); - const householdMembers = await householdMemberFactoryMany( - householdSize - 1, - ); - const app = await applicationFactory({ - unitTypeId: unitTypes[randomInt(0, 5)].id, - householdMember: householdMembers, - multiselectQuestions, - }); - applications.push(app); - } + applications.push( + ...(await applicationFactoryMany(APPLICATIONS_PER_LISTINGS, async () => { + const householdSize = randomInt(1, 6); + const householdMembers = await householdMemberFactoryMany( + householdSize - 1, + ); + return { + unitTypeId: unitTypes[randomInt(0, 5)].id, + householdMember: householdMembers, + multiselectQuestions, + }; + })), + ); const listing = await listingFactory(jurisdiction.id, prismaClient, { amiChart: amiChart, diff --git a/api/prisma/seed-helpers/application-factory.ts b/api/prisma/seed-helpers/application-factory.ts index 9d3acdf661..f9559b71ec 100644 --- a/api/prisma/seed-helpers/application-factory.ts +++ b/api/prisma/seed-helpers/application-factory.ts @@ -165,3 +165,24 @@ export const applicantFactory = ( ...overrides, }; }; + +export const applicationFactoryMany = async ( + count: number, + optionalParams?: + | Parameters[0] + | (( + index: number, + ) => + | Parameters[0] + | Promise[0]>), +): Promise => { + return Promise.all( + Array.from({ length: count }, async (_, index) => + applicationFactory( + typeof optionalParams === 'function' + ? await optionalParams(index) + : optionalParams, + ), + ), + ); +}; diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 3fac28a622..18195e38bf 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -16,7 +16,10 @@ import { amiChartFactory } from './seed-helpers/ami-chart-factory'; import { userFactory } from './seed-helpers/user-factory'; import { unitTypeFactoryAll } from './seed-helpers/unit-type-factory'; import { multiselectQuestionFactory } from './seed-helpers/multiselect-question-factory'; -import { applicationFactory } from './seed-helpers/application-factory'; +import { + applicationFactory, + applicationFactoryMany, +} from './seed-helpers/application-factory'; import { translationFactory } from './seed-helpers/translation-factory'; import { propertyFactory } from './seed-helpers/property-factory'; import { reservedCommunityTypeFactoryAll } from './seed-helpers/reserved-community-type-factory'; @@ -95,17 +98,6 @@ export const defaultRaceEthnicityConfiguration: RaceEthnicityConfiguration = { ], }; -const applicationFactoryMany = async ( - count: number, - optionalParams?: Parameters[0], -): Promise => { - return Promise.all( - Array.from({ length: count }, async () => - applicationFactory(optionalParams), - ), - ); -}; - export const stagingSeed = async ( prismaClient: PrismaClient, jurisdictionName: string, @@ -1120,33 +1112,7 @@ export const stagingSeed = async ( ...(await applicationFactoryMany(2, { applicant: { emailAddress: 'user2@example.com' }, })), - await applicationFactory({ - applicant: { - emailAddress: 'user3@example.com', - firstName: 'first3', - lastName: 'last3', - birthDay: 1, - birthMonth: 1, - birthYear: 1970, - }, - householdMember: [ - householdMemberFactorySingle(1, { - firstName: 'householdFirst1', - lastName: 'householdLast1', - birthDay: 5, - birthMonth: 5, - birthYear: 1950, - }), - householdMemberFactorySingle(2, { - firstName: 'householdFirst2', - lastName: 'householdLast2', - birthDay: 8, - birthMonth: 8, - birthYear: 1980, - }), - ], - }), - await applicationFactory({ + ...(await applicationFactoryMany(2, { applicant: { emailAddress: 'user3@example.com', firstName: 'first3', @@ -1171,7 +1137,7 @@ export const stagingSeed = async ( birthYear: 1980, }), ], - }), + })), await applicationFactory({ applicant: { emailAddress: 'user4@example.com', diff --git a/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index 075ac7a125..50b3f2c90c 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -2189,6 +2189,157 @@ describe('Application Controller Tests', () => { expect(res.body.meta.totalPages).toEqual(1); }); + it('should filter applications by applicantNameSearch for advocate users', async () => { + const unitTypeA = await unitTypeFactorySingle( + prisma, + UnitTypeEnum.oneBdrm, + ); + const advocateUser = await prisma.userAccounts.create({ + data: await userFactory({ + isAdvocate: true, + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + email: advocateUser.email, + password: 'Abcdef12345!', + } as Login) + .expect(201); + const cookies = resLogIn.headers['set-cookie']; + + const juris = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + await reservedCommunityTypeFactoryAll(juris.id, prisma); + + const listingOpen = await prisma.listings.create({ + data: await listingFactory(juris.id, prisma, { + status: ListingsStatusEnum.active, + }), + }); + + await prisma.applications.create({ + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + userId: advocateUser.id, + listingId: listingOpen.id, + applicant: { + firstName: 'Taylor', + lastName: 'Match', + emailAddress: 'taylor.match@example.com', + }, + }), + }); + + await prisma.applications.create({ + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + userId: advocateUser.id, + listingId: listingOpen.id, + applicant: { + firstName: 'Jordan', + lastName: 'Other', + emailAddress: 'jordan.other@example.com', + }, + }), + }); + + const queryParams: PublicAppsViewQueryParams = { + userId: advocateUser.id, + applicantNameSearch: 'Taylor Match', + filterType: ApplicationsFilterEnum.all, + includeLotteryApps: true, + page: 1, + limit: 10, + }; + const query = stringify(queryParams as any); + + const res = await request(app.getHttpServer()) + .get(`/applications/publicAppsView?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.applicationsCount.total).toEqual(1); + expect(res.body.applicationsCount.open).toEqual(1); + expect(res.body.applicationsCount.closed).toEqual(0); + expect(res.body.applicationsCount.lottery).toEqual(0); + expect(res.body.items.length).toEqual(1); + expect(res.body.items[0].applicant.firstName).toEqual('Taylor'); + expect(res.body.items[0].applicant.lastName).toEqual('Match'); + }); + + it('should ignore applicantNameSearch for non-advocate users', async () => { + const unitTypeA = await unitTypeFactorySingle( + prisma, + UnitTypeEnum.oneBdrm, + ); + const { user, cookies } = await createAndLoginUser(); + const juris = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + await reservedCommunityTypeFactoryAll(juris.id, prisma); + + const listingOpen = await prisma.listings.create({ + data: await listingFactory(juris.id, prisma, { + status: ListingsStatusEnum.active, + }), + }); + + await prisma.applications.create({ + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + userId: user.id, + listingId: listingOpen.id, + applicant: { + firstName: 'Casey', + lastName: 'Visible', + emailAddress: 'casey.visible@example.com', + }, + }), + }); + + await prisma.applications.create({ + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + userId: user.id, + listingId: listingOpen.id, + applicant: { + firstName: 'Lisa', + lastName: 'AlsoVisible', + emailAddress: 'lisa.alsovisible@example.com', + }, + }), + }); + + const queryParams: PublicAppsViewQueryParams = { + userId: user.id, + applicantNameSearch: 'Casey', + filterType: ApplicationsFilterEnum.all, + includeLotteryApps: true, + page: 1, + limit: 10, + }; + const query = stringify(queryParams as any); + + const res = await request(app.getHttpServer()) + .get(`/applications/publicAppsView?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.applicationsCount.total).toEqual(2); + expect(res.body.applicationsCount.open).toEqual(2); + expect(res.body.applicationsCount.closed).toEqual(0); + expect(res.body.applicationsCount.lottery).toEqual(0); + expect(res.body.items.length).toEqual(2); + }); + it('should not retrieve applications nor error when none exist', async () => { const { user, cookies } = await createAndLoginUser(); diff --git a/sites/public/src/components/account/ApplicationsView.tsx b/sites/public/src/components/account/ApplicationsView.tsx index 0b1865c8ce..3933a0df3c 100644 --- a/sites/public/src/components/account/ApplicationsView.tsx +++ b/sites/public/src/components/account/ApplicationsView.tsx @@ -19,6 +19,9 @@ import { StatusItemWrapper, AppWithListing } from "./StatusItemWrapper" import { UserStatus } from "../../lib/constants" import styles from "./ApplicationsView.module.scss" +const MINIMUM_SEARCH_CHARACTERS = 3 +const SEARCH_DEBOUNCE_MS = 500 + export enum ApplicationsIndexEnum { all = 0, lottery, @@ -54,8 +57,6 @@ const ApplicationsView = (props: ApplicationsViewProps) => { const filterTypeString = ApplicationsIndexEnum[props.filterType] const page = Number(router.query.page) || 1 const isAdvocate = !!profile?.isAdvocate - const minimumSearchCharacters = 3 - const searchDebounceMs = 500 useEffect(() => { if (!isAdvocate) { @@ -66,8 +67,8 @@ const ApplicationsView = (props: ApplicationsViewProps) => { const trimmedSearch = searchInput.trim() const timeoutId = setTimeout(() => { - setDebouncedSearch(trimmedSearch.length >= minimumSearchCharacters ? trimmedSearch : "") - }, searchDebounceMs) + setDebouncedSearch(trimmedSearch.length >= MINIMUM_SEARCH_CHARACTERS ? trimmedSearch : "") + }, SEARCH_DEBOUNCE_MS) return () => clearTimeout(timeoutId) }, [searchInput, isAdvocate]) @@ -261,7 +262,7 @@ const ApplicationsView = (props: ApplicationsViewProps) => { - {/* // TODO: When application status copy is available and on the FAQ page, we can re-enable this */} + {/* // TODO: When application status copy is available and on the FAQ page, we can re-enable this under ticket https://github.com/bloom-housing/bloom/issues/6000 */} {/* {props.enableApplicationStatus && (