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..6fcfa5173b 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.id, + }; + 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/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index 08af10c13f..075ac7a125 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -320,13 +320,15 @@ describe('Application Controller Tests', () => { expect(res.body.items.length).toBeGreaterThanOrEqual(2); const resApplicationA = res.body.items.find( - (item) => item.applicant.firstName === applicationA.applicant.firstName, + (item) => + item.applicant?.firstName === applicationA.applicant.firstName, ); expect(resApplicationA).not.toBeNull(); - res.body.items.find( - (item) => item.applicant.firstName === applicationB.applicant.firstName, + const resApplicationB = res.body.items.find( + (item) => + item.applicant?.firstName === applicationB.applicant.firstName, ); - expect(resApplicationA).not.toBeNull(); + expect(resApplicationB).not.toBeNull(); }); it('should get stored applications when no params sent', async () => { @@ -359,13 +361,15 @@ describe('Application Controller Tests', () => { expect(res.body.items.length).toBeGreaterThanOrEqual(2); const resApplicationA = res.body.items.find( - (item) => item.applicant.firstName === applicationA.applicant.firstName, + (item) => + item.applicant?.firstName === applicationA.applicant.firstName, ); expect(resApplicationA).not.toBeNull(); - res.body.items.find( - (item) => item.applicant.firstName === applicationB.applicant.firstName, + const resApplicationB = res.body.items.find( + (item) => + item.applicant?.firstName === applicationB.applicant.firstName, ); - expect(resApplicationA).not.toBeNull(); + expect(resApplicationB).not.toBeNull(); }); }); @@ -2078,14 +2082,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 +2174,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 +2190,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 +2204,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); 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/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/shared-helpers/src/locales/ar.json b/shared-helpers/src/locales/ar.json index a202f78e08..bb0edcce3c 100644 --- a/shared-helpers/src/locales/ar.json +++ b/shared-helpers/src/locales/ar.json @@ -194,6 +194,10 @@ "application.details.applicationStatus.waitlist": "قائمة الانتظار", "application.details.applicationStatus.waitlistDeclined": "قائمة الانتظار - مرفوض", "application.details.applicationStatusFaqLink": "ماذا تعني حالة طلبي؟", + "application.details.enterAtLeast3CharactersToSearch": "أدخل 3 أحرف على الأقل", + "application.details.searchApplicants": "البحث عن المتقدمين", + "application.details.searchApplicantsPlaceholder": "ابحث باستخدام الاسم الأول واسم العائلة", + "application.details.searchNoResults": "لم يتم العثور على نتائج", "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..a8a2a507f1 100644 --- a/shared-helpers/src/locales/bn.json +++ b/shared-helpers/src/locales/bn.json @@ -194,6 +194,10 @@ "application.details.applicationStatus.waitlist": "অপেক্ষা তালিকা", "application.details.applicationStatus.waitlistDeclined": "অপেক্ষা তালিকা - প্রত্যাখ্যান করা হয়েছে", "application.details.applicationStatusFaqLink": "আমার আবেদনের স্থিতির অর্থ কী?", + "application.details.enterAtLeast3CharactersToSearch": "কমপক্ষে ৩টি অক্ষর লিখুন", + "application.details.searchApplicants": "আবেদনকারীদের অনুসন্ধান করুন", + "application.details.searchApplicantsPlaceholder": "প্রথম এবং শেষ নাম অনুসারে অনুসন্ধান করুন", + "application.details.searchNoResults": "কোন ফলাফল পাওয়া যায়নি", "application.edited": "সম্পাদিত", "application.financial.income.instruction1": "পরিবারের মোট সদস্যদের মজুরি, সুবিধা এবং অন্যান্য উৎস থেকে আপনার মোট মোট (কর-পূর্ব) পরিবারের আয় যোগ করুন।", "application.financial.income.instruction2": "আপনাকে এখনই একটি আনুমানিক মোট প্রদান করতে হবে। আপনি নির্বাচিত হলে প্রকৃত মোট গণনা করা হবে।", @@ -745,8 +749,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..89c5113292 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -194,6 +194,10 @@ "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.details.searchNoResults": "No se encontraron resultados", "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 +749,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..1f1a101c2e 100644 --- a/shared-helpers/src/locales/fa.json +++ b/shared-helpers/src/locales/fa.json @@ -193,6 +193,10 @@ "application.details.applicationStatus.waitlist": "لیست انتظار", "application.details.applicationStatus.waitlistDeclined": "لیست انتظار - رد شد", "application.details.applicationStatusFaqLink": "وضعیت درخواست من به چه معناست؟", + "application.details.enterAtLeast3CharactersToSearch": "حداقل ۳ کاراکتر وارد کنید", + "application.details.searchApplicants": "جستجوی متقاضیان", + "application.details.searchApplicantsPlaceholder": "جستجو بر اساس نام و نام خانوادگی", + "application.details.searchNoResults": "هیچ نتیجه‌ای یافت نشد", "application.edited": "ویرایش شده", "application.financial.income.instruction1": "کل درآمد ناخالص خانوار (قبل از کسر مالیات) خود را که از دستمزد، مزایا و سایر منابع از همه اعضای خانوار دریافت می‌کنید، جمع کنید.", "application.financial.income.instruction2": "شما فقط باید در حال حاضر یک جمع تخمینی ارائه دهید. در صورت انتخاب شدن، جمع واقعی محاسبه خواهد شد.", @@ -742,7 +746,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/general.json b/shared-helpers/src/locales/general.json index 7d796ac357..bceb521f2b 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -193,6 +193,10 @@ "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.details.searchNoResults": "No results found", "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/locales/hy.json b/shared-helpers/src/locales/hy.json index cf862f33cd..87c68397fe 100644 --- a/shared-helpers/src/locales/hy.json +++ b/shared-helpers/src/locales/hy.json @@ -193,6 +193,10 @@ "application.details.applicationStatus.waitlist": "Սպասման ցուցակ", "application.details.applicationStatus.waitlistDeclined": "Սպասման ցուցակ - Մերժված է", "application.details.applicationStatusFaqLink": "Ի՞նչ է նշանակում իմ դիմումի կարգավիճակը։", + "application.details.enterAtLeast3CharactersToSearch": "Մուտքագրեք առնվազն 3 նիշ", + "application.details.searchApplicants": "Դիմորդների որոնում", + "application.details.searchApplicantsPlaceholder": "Որոնել անունով և ազգանունով", + "application.details.searchNoResults": "Արդյունքներ չեն գտնվել", "application.edited": "Խմբագրված է", "application.financial.income.instruction1": "Գումարեք ձեր տնային տնտեսության ընդհանուր համախառն (հարկումից առաջ) եկամուտը՝ ստացված աշխատավարձից, նպաստներից և տնային տնտեսության բոլոր անդամների այլ աղբյուրներից։", "application.financial.income.instruction2": "Դուք պետք է տրամադրեք միայն մոտավոր ընդհանուր գումարը հենց հիմա։ Եթե ընտրվեք, կհաշվարկվի իրական ընդհանուր գումարը։", @@ -742,7 +746,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..049fd95523 100644 --- a/shared-helpers/src/locales/ko.json +++ b/shared-helpers/src/locales/ko.json @@ -193,6 +193,10 @@ "application.details.applicationStatus.waitlist": "대기자 명단", "application.details.applicationStatus.waitlistDeclined": "대기자 명단 - 거절됨", "application.details.applicationStatusFaqLink": "내 신청 상태는 무엇을 의미하나요?", + "application.details.enterAtLeast3CharactersToSearch": "최소 3자 이상 입력하세요", + "application.details.searchApplicants": "지원자 검색", + "application.details.searchApplicantsPlaceholder": "이름과 성으로 검색하세요", + "application.details.searchNoResults": "검색 결과가 없습니다.", "application.edited": "편집됨", "application.financial.income.instruction1": "가구 구성원 모두의 임금, 수당 및 기타 소득을 합산하여 세전 총 가계 소득을 계산하십시오.", "application.financial.income.instruction2": "지금은 예상 총액만 입력하시면 됩니다. 실제 총액은 선정되신 경우에 계산됩니다.", @@ -742,7 +746,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..a058cf5930 100644 --- a/shared-helpers/src/locales/tl.json +++ b/shared-helpers/src/locales/tl.json @@ -194,6 +194,10 @@ "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.details.searchNoResults": "Walang nakitang resulta", "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 +750,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..edd01154cb 100644 --- a/shared-helpers/src/locales/vi.json +++ b/shared-helpers/src/locales/vi.json @@ -194,6 +194,10 @@ "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.details.searchNoResults": "Không tìm thấy kết quả nào.", "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 +751,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..ad4f8daf43 100644 --- a/shared-helpers/src/locales/zh.json +++ b/shared-helpers/src/locales/zh.json @@ -194,6 +194,10 @@ "application.details.applicationStatus.waitlist": "候補名單", "application.details.applicationStatus.waitlistDeclined": "候補名單 - 已拒絕", "application.details.applicationStatusFaqLink": "我的申请状态是什么意思?", + "application.details.enterAtLeast3CharactersToSearch": "请输入至少 3 个字符", + "application.details.searchApplicants": "搜索申请人", + "application.details.searchApplicantsPlaceholder": "按名字和姓氏搜索", + "application.details.searchNoResults": "未找到结果", "application.edited": "已修改", "application.financial.income.instruction1": "請將所有家庭成員的工資、福利和其他收入來源相加,得出您的家庭總收入(稅前)。", "application.financial.income.instruction2": "您現在只需要提供估計的總額。如果您被選中,您將需計算實際總數。", @@ -746,7 +750,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/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/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) }) }) 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", () => { diff --git a/sites/public/__tests__/components/applications/ApplicationsView.test.tsx b/sites/public/__tests__/components/applications/ApplicationsView.test.tsx index 9dd7c69cec..63b1d0a372 100644 --- a/sites/public/__tests__/components/applications/ApplicationsView.test.tsx +++ b/sites/public/__tests__/components/applications/ApplicationsView.test.tsx @@ -1,7 +1,8 @@ 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" @@ -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,17 +106,30 @@ function getApplications( function renderApplicationsView( filterType = ApplicationsIndexEnum.all, - enableApplicationStatus = false + enableApplicationStatus = false, + profileOverrides = {}, + messageContextOverrides = {} ) { return render( - - - + + + + ) } @@ -150,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() @@ -163,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", () => { @@ -545,4 +564,92 @@ 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 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[] = [] + + 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, + }) + }) + }) }) diff --git a/sites/public/src/components/account/ApplicationsView.module.scss b/sites/public/src/components/account/ApplicationsView.module.scss index 20ce8b9e8f..4809c927f5 100644 --- a/sites/public/src/components/account/ApplicationsView.module.scss +++ b/sites/public/src/components/account/ApplicationsView.module.scss @@ -41,10 +41,6 @@ } } -.application-no-results-text { - padding-bottom: var(--seeds-s6); -} - .application-count-tab { display: flex; justify-content: space-between; @@ -54,6 +50,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..0b1865c8ce 100644 --- a/sites/public/src/components/account/ApplicationsView.tsx +++ b/sites/public/src/components/account/ApplicationsView.tsx @@ -1,11 +1,12 @@ -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, AuthContext, + useToastyRef, RequireLogin, BloomCard, } from "@bloom-housing/shared-helpers" @@ -16,7 +17,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 +25,7 @@ export enum ApplicationsIndexEnum { closed, open, } + interface ApplicationsCount { total: number lottery: number @@ -39,15 +40,37 @@ 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() 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 +88,7 @@ const ApplicationsView = (props: ApplicationsViewProps) => { includeLotteryApps: !!showPublicLottery, page: page, limit: 10, + applicantNameSearch: isAdvocate && debouncedSearch ? debouncedSearch : undefined, }) .then((res) => { setApplications(res.items) @@ -73,11 +97,24 @@ 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, applicationsService, filterTypeString, showPublicLottery, page]) + }, [ + profile, + applicationsService, + filterTypeString, + showPublicLottery, + page, + debouncedSearch, + isAdvocate, + toastyRef, + ]) const selectionHandler = (index: number) => { const baseUrl = "/account/applications" @@ -130,8 +167,10 @@ const ApplicationsView = (props: ApplicationsViewProps) => { const { title, subtitle } = getPageHeader() const noApplicationsSection = () => { - let headerText = t("account.noApplications") - let buttonText = t("listings.browseListings") + let headerText = debouncedSearch + ? t("application.details.searchNoResults") + : t("account.noApplications") + let buttonText = debouncedSearch ? null : t("listings.browseListings") let buttonHref = "/listings" // only show custom message and redirect to "All my applications" if they have applied before if (applicationsCount?.total > 0) { @@ -159,12 +198,16 @@ const ApplicationsView = (props: ApplicationsViewProps) => { {`${t("account.errorFetchingApplications")}`} ) : ( <> - + {headerText} - + {buttonText && ( +
+ +
+ )} )} @@ -172,6 +215,8 @@ const ApplicationsView = (props: ApplicationsViewProps) => { ) } + const noUnfilteredResults = !loading && paginationMeta?.totalItems === 0 && !debouncedSearch + return ( @@ -216,13 +261,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} > <> + {hasLoadedOnce && isAdvocate && !noUnfilteredResults && ( +
+ + 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 +309,7 @@ const ApplicationsView = (props: ApplicationsViewProps) => { key={index} application={application} enableApplicationStatus={props.enableApplicationStatus} + showApplicantName={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"))