diff --git a/.gitignore b/.gitignore index 50cf69c6c7..ffcbdc3d13 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ storybook-static # Cypress test output videos **/cypress/videos **/cypress/screenshots +**/cypress/downloads # Complied Typescript dist diff --git a/.travis.yml b/.travis.yml index 155efbdee7..0bf423f94c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,65 +1,67 @@ language: node_js node_js: -- 14 + - 14 cache: yarn: true services: -- redis-server + - redis-server before_install: -- sudo sed -i -e '/local.*peer/s/postgres/all/' -e 's/peer\|md5/trust/g' /etc/postgresql/*/main/pg_hba.conf -- sudo systemctl restart postgresql@11-main -- sleep 1 + - sudo sed -i -e '/local.*peer/s/postgres/all/' -e 's/peer\|md5/trust/g' /etc/postgresql/*/main/pg_hba.conf + - sudo systemctl restart postgresql@11-main + - sleep 1 before_script: -- cp sites/public/.env.template sites/public/.env -- cp sites/partners/.env.template sites/partners/.env -- cp backend/core/.env.template backend/core/.env + - cp sites/public/.env.template sites/public/.env + - cp sites/partners/.env.template sites/partners/.env + - cp backend/core/.env.template backend/core/.env jobs: include: - - script: yarn build:app:public - name: Build public site - - script: yarn build:app:partners - name: Build partners site - - script: yarn test:backend:core:testdbsetup && yarn test:backend:core - name: Backend unit tests - - script: yarn test:e2e:backend:core - name: Backend e2e tests - - script: yarn test:app:public:unit - name: Public site unit tests - - stage: longer tests - name: Partners site Cypress tests - script: - - yarn cypress install - - cd backend/core - - yarn db:reseed:detroit - - yarn nest start & - - cd ../../sites/partners - - yarn build - - yarn start -p 3001 & - - yarn wait-on "http-get://localhost:3001" && yarn cypress run - - kill $(jobs -p) || true - - stage: longer tests - name: Public site Cypress tests - script: - - yarn cypress install - - yarn db:reseed - - cd backend/core - - yarn nest start & - - cd ../../sites/public - - yarn build - - yarn start -p 3000 & - - yarn wait-on "http-get://localhost:3000" && yarn cypress run - - kill $(jobs -p) || true + - script: yarn build:app:public + name: Build public site + - script: yarn build:app:partners + name: Build partners site + - script: yarn test:backend:core:testdbsetup && yarn test:backend:core + name: Backend unit tests + - script: yarn test:e2e:backend:core + name: Backend e2e tests + - script: yarn test:app:public:unit + name: Public site unit tests + - script: yarn test:app:partners:unit + name: Partners site unit tests + - stage: longer tests + name: Partners site Cypress tests + script: + - yarn cypress install + - cd backend/core + - yarn db:reseed:detroit + - yarn nest start & + - cd ../../sites/partners + - yarn build + - yarn start -p 3001 & + - yarn wait-on "http-get://localhost:3001" && yarn cypress run + - kill $(jobs -p) || true + - stage: longer tests + name: Public site Cypress tests + script: + - yarn cypress install + - yarn db:reseed + - cd backend/core + - yarn nest start & + - cd ../../sites/public + - yarn build + - yarn start -p 3000 & + - yarn wait-on "http-get://localhost:3000" && yarn cypress run + - kill $(jobs -p) || true addons: - postgresql: '11' + postgresql: "11" apt: packages: - - postgresql-11 - - postgresql-client-11 - - libgconf-2-4 + - postgresql-11 + - postgresql-client-11 + - libgconf-2-4 env: global: PGPORT=5433 PGUSER=travis TEST_DATABASE_URL=postgres://localhost:5433/bloom_test REDIS_TLS_URL=redis://127.0.0.1:6379/0 NEW_RELIC_ENABLED=false - NEW_RELIC_LOG_ENABLED=false + NEW_RELIC_LOG_ENABLED=false \ No newline at end of file diff --git a/backend/core/package.json b/backend/core/package.json index 23f19f6ea1..dc29b29d5e 100644 --- a/backend/core/package.json +++ b/backend/core/package.json @@ -80,6 +80,7 @@ "ioredis": "^5.2.3", "joi": "^17.3.0", "jwt-simple": "^0.5.6", + "jszip": "^3.10.1", "lodash": "^4.17.21", "mapbox": "^1.0.0-beta10", "nanoid": "^3.1.12", diff --git a/backend/core/src/applications/applications.controller.ts b/backend/core/src/applications/applications.controller.ts index c410d5840e..8788c86ac7 100644 --- a/backend/core/src/applications/applications.controller.ts +++ b/backend/core/src/applications/applications.controller.ts @@ -21,7 +21,6 @@ import { ApplicationDto } from "./dto/application.dto" import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum" import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" import { applicationPreferenceApiExtraModels } from "./types/application-preference-api-extra-models" -import { ListingsService } from "../listings/listings.service" import { ApplicationCsvExporterService } from "./services/application-csv-exporter.service" import { ApplicationsService } from "./services/applications.service" import { ActivityLogInterceptor } from "../activity-log/interceptors/activity-log.interceptor" @@ -49,7 +48,6 @@ import { IdDto } from "../shared/dto/id.dto" export class ApplicationsController { constructor( private readonly applicationsService: ApplicationsService, - private readonly listingsService: ListingsService, private readonly applicationCsvExporter: ApplicationCsvExporterService ) {} diff --git a/backend/core/src/applications/services/application-csv-exporter.service.ts b/backend/core/src/applications/services/application-csv-exporter.service.ts index 04585bc77a..de22f41c26 100644 --- a/backend/core/src/applications/services/application-csv-exporter.service.ts +++ b/backend/core/src/applications/services/application-csv-exporter.service.ts @@ -148,8 +148,8 @@ export class ApplicationCsvExporterService { app.application_submission_type === "electronical" ? "electronic" : app.application_submission_type, - "Application Submission Date": dayjs(app.application_submission_date).format( - "MM-DD-YYYY h:mm:ssA" + "Application Submission Date (UTC)": dayjs(app.application_submission_date).format( + "MM-DD-YYYY hh:mm:ssA" ), "Primary Applicant First Name": app.applicant_first_name, "Primary Applicant Middle Name": app.applicant_middle_name, diff --git a/backend/core/src/auth/auth.module.ts b/backend/core/src/auth/auth.module.ts index 1eb6e831ab..6df6beaa96 100644 --- a/backend/core/src/auth/auth.module.ts +++ b/backend/core/src/auth/auth.module.ts @@ -25,6 +25,8 @@ import { UserPreferencesController } from "./controllers/user-preferences.contro import { UserPreferencesService } from "./services/user-preferences.services" import { UserPreferences } from "./entities/user-preferences.entity" import { UserRepository } from "./repositories/user-repository" +import { UserCsvExporterService } from "./services/user-csv-exporter.service" +import { CsvBuilder } from "../applications/services/csv-builder.service" @Module({ imports: [ @@ -62,6 +64,8 @@ import { UserRepository } from "./repositories/user-repository" PasswordService, SmsMfaService, UserPreferencesService, + CsvBuilder, + UserCsvExporterService, ], exports: [AuthzService, AuthService, UserService, UserPreferencesService], controllers: [AuthController, UserController, UserProfileController, UserPreferencesController], diff --git a/backend/core/src/auth/controllers/user.controller.spec.ts b/backend/core/src/auth/controllers/user.controller.spec.ts index ed9f2f49b2..79f5cdd180 100644 --- a/backend/core/src/auth/controllers/user.controller.spec.ts +++ b/backend/core/src/auth/controllers/user.controller.spec.ts @@ -6,6 +6,7 @@ import { UserService } from "../services/user.service" import { AuthzService } from "../services/authz.service" import { ActivityLogService } from "../../activity-log/services/activity-log.service" import { EmailService } from "../../email/email.service" +import { UserCsvExporterService } from "../services/user-csv-exporter.service" // Cypress brings in Chai types for the global expect, but we want to use jest // expect here so we need to re-declare it. @@ -22,6 +23,7 @@ describe("User Controller", () => { { provide: AuthService, useValue: {} }, { provide: AuthzService, useValue: {} }, { provide: UserService, useValue: {} }, + { provide: UserCsvExporterService, useValue: {} }, { provide: EmailService, useValue: {} }, { provide: ActivityLogService, useValue: {} }, ], diff --git a/backend/core/src/auth/controllers/user.controller.ts b/backend/core/src/auth/controllers/user.controller.ts index 59ee78e530..d8c3f54c7b 100644 --- a/backend/core/src/auth/controllers/user.controller.ts +++ b/backend/core/src/auth/controllers/user.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Header, Param, Post, Put, @@ -45,6 +46,8 @@ import { DefaultAuthGuard } from "../guards/default.guard" import { UserProfileAuthzGuard } from "../guards/user-profile-authz.guard" import { ActivityLogInterceptor } from "../../activity-log/interceptors/activity-log.interceptor" import { IdDto } from "../../shared/dto/id.dto" +import { UserCsvExporterService } from "../services/user-csv-exporter.service" +import { Compare } from "../../shared/dto/filter.dto" @Controller("user") @ApiBearerAuth() @@ -52,7 +55,10 @@ import { IdDto } from "../../shared/dto/id.dto" @ResourceType("user") @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) export class UserController { - constructor(private readonly userService: UserService) {} + constructor( + private readonly userService: UserService, + private readonly userCsvExporter: UserCsvExporterService + ) {} @Get() @UseGuards(DefaultAuthGuard, UserProfileAuthzGuard) @@ -158,6 +164,27 @@ export class UserController { ) } + @Get("/csv") + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "List users in CSV", operationId: "listAsCsv" }) + @Header("Content-Type", "text/csv") + async listAsCsv(@Request() req: ExpressRequest): Promise { + const users = await this.userService.list( + { + page: 1, + limit: 300, + filter: [ + { + isPortalUser: true, + $comparison: Compare["="], + }, + ], + }, + new AuthContext(req.user as User) + ) + return this.userCsvExporter.exportFromObject(users) + } + @Post("/invite") @UseGuards(OptionalAuthGuard, AuthzGuard) @ApiOperation({ summary: "Invite user", operationId: "invite" }) diff --git a/backend/core/src/auth/services/user-csv-exporter.service.ts b/backend/core/src/auth/services/user-csv-exporter.service.ts new file mode 100644 index 0000000000..c2787b13e1 --- /dev/null +++ b/backend/core/src/auth/services/user-csv-exporter.service.ts @@ -0,0 +1,36 @@ +import { Injectable, Scope } from "@nestjs/common" +import dayjs from "dayjs" +import { Pagination } from "nestjs-typeorm-paginate" +import { CsvBuilder } from "../../applications/services/csv-builder.service" +import { User } from "../entities/user.entity" + +@Injectable({ scope: Scope.REQUEST }) +export class UserCsvExporterService { + constructor(private readonly csvBuilder: CsvBuilder) {} + + exportFromObject(users: Pagination): string { + const userObj = users.items.reduce((obj, user) => { + const status = [] + if (user.roles?.isAdmin) { + status.push("Administrator") + } + if (user.roles?.isPartner) { + status.push("Partner") + } + obj[user.id] = { + "First Name": user.firstName, + "Last Name": user.lastName, + Email: user.email, + Role: status.join(", "), + "Date Created (UTC)": dayjs(user.createdAt).format("MM-DD-YYYY hh:mmA"), + Status: user.confirmedAt ? "Confirmed" : "Unconfirmed", + "Listing Names": + user.leasingAgentInListings?.map((listing) => listing.name).join(", ") || "", + "Listing Ids": user.leasingAgentInListings?.map((listing) => listing.id).join(", ") || "", + "Last Logged In (UTC)": dayjs(user.lastLoginAt).format("MM-DD-YYYY hh:mmA"), + } + return obj + }, {}) + return this.csvBuilder.buildFromIdIndex(userObj) + } +} diff --git a/backend/core/src/listings/helpers.ts b/backend/core/src/listings/helpers.ts new file mode 100644 index 0000000000..35d4cadf9c --- /dev/null +++ b/backend/core/src/listings/helpers.ts @@ -0,0 +1,98 @@ +import dayjs from "dayjs" +import { MinMax } from "../../types" +import { UnitGroupAmiLevelDto } from "../../src/units-summary/dto/unit-group-ami-level.dto" +import { PaperApplication } from "../../src/paper-applications/entities/paper-application.entity" + +export const isDefined = (item: number | string): boolean => { + return item !== null && item !== undefined && item !== "" +} + +export const cloudinaryPdfFromId = (publicId: string): string => { + if (isDefined(publicId)) { + const cloudName = process.env.cloudinaryCloudName || process.env.CLOUDINARY_CLOUD_NAME + return `https://res.cloudinary.com/${cloudName}/image/upload/${publicId}.pdf` + } else return "" +} + +export const formatDate = (rawDate: string, format: string): string => { + if (isDefined(rawDate)) { + return dayjs(rawDate).format(format) + } else return "" +} + +export const getPaperAppUrls = (paperApps: PaperApplication[]) => { + if (!paperApps || paperApps?.length === 0) return "" + const urlArr = paperApps.map((paperApplication) => + cloudinaryPdfFromId(paperApplication.file?.fileId) + ) + const formattedResults = urlArr.join(", ") + return formattedResults +} + +export const getRentTypes = (amiLevels: UnitGroupAmiLevelDto[]): string => { + if (!amiLevels || amiLevels?.length === 0) return "" + const uniqueTypes = [] + amiLevels?.forEach((elem) => { + if (!uniqueTypes.includes(elem.monthlyRentDeterminationType)) + uniqueTypes.push(elem.monthlyRentDeterminationType) + }) + const formattedResults = uniqueTypes.map((elem) => convertToTitleCase(elem)).join(", ") + return formattedResults +} + +export const formatYesNo = (value: boolean | null): string => { + if (value === null || typeof value == "undefined") return "" + else if (value) return "Yes" + else return "No" +} + +export const formatStatus = { + active: "Public", + pending: "Draft", +} + +export const formatBedroom = { + oneBdrm: "1 BR", + twoBdrm: "2 BR", + threeBdrm: "3 BR", + fourBdrm: "4 BR", + fiveBdrm: "5 BR", + studio: "Studio", +} + +export const formatCurrency = (value: string): string => { + return value ? `$${value}` : "" +} + +export const convertToTitleCase = (value: string): string => { + if (!isDefined(value)) return "" + const spacedValue = value.replace(/([A-Z])/g, (match) => ` ${match}`) + const result = spacedValue.charAt(0).toUpperCase() + spacedValue.slice(1) + return result +} + +export const formatRange = ( + min: string | number, + max: string | number, + prefix: string, + postfix: string +): string => { + if (!isDefined(min) && !isDefined(max)) return "" + if (min == max || !isDefined(max)) return `${prefix}${min}${postfix}` + if (!isDefined(min)) return `${prefix}${max}${postfix}` + return `${prefix}${min}${postfix} - ${prefix}${max}${postfix}` +} + +export function formatRentRange(rent: MinMax, percent: MinMax): string { + let toReturn = "" + if (rent) { + toReturn += formatRange(rent.min, rent.max, "", "") + } + if (rent && percent) { + toReturn += ", " + } + if (percent) { + toReturn += formatRange(percent.min, percent.max, "", "%") + } + return toReturn +} diff --git a/backend/core/src/listings/listings-csv-exporter.service.ts b/backend/core/src/listings/listings-csv-exporter.service.ts new file mode 100644 index 0000000000..609435d379 --- /dev/null +++ b/backend/core/src/listings/listings-csv-exporter.service.ts @@ -0,0 +1,186 @@ +import { Injectable, Scope } from "@nestjs/common" +import { CsvBuilder } from "../applications/services/csv-builder.service" +import { + cloudinaryPdfFromId, + formatCurrency, + formatDate, + formatRange, + formatRentRange, + formatStatus, + formatYesNo, + getRentTypes, + convertToTitleCase, + formatBedroom, + getPaperAppUrls, +} from "./helpers" +@Injectable({ scope: Scope.REQUEST }) +export class ListingsCsvExporterService { + constructor(private readonly csvBuilder: CsvBuilder) {} + + exportListingsFromObject(listings: any[], users: any[]): string { + // restructure user information to listingId->user rather than user->listingId + const partnerAccessHelper = {} + users.forEach((user) => { + const userName = `${user.firstName} ${user.lastName}` + user.leasingAgentInListings.forEach((listing) => { + partnerAccessHelper[listing.id] + ? partnerAccessHelper[listing.id].push(userName) + : (partnerAccessHelper[listing.id] = [userName]) + }) + }) + const listingObj = listings.map((listing) => { + return { + ID: listing.id, + "Created At Date (UTC)": formatDate(listing.createdAt, "MM-DD-YYYY hh:mm:ssA"), + "Listing Status": formatStatus[listing.status], + "Publish Date (UTC)": formatDate(listing.publishedAt, "MM-DD-YYYY hh:mm:ssA"), + Verified: formatYesNo(listing.isVerified), + "Verified Date (UTC)": formatDate(listing.verifiedAt, "MM-DD-YYYY hh:mm:ssA"), + "Last Updated (UTC)": formatDate(listing.updatedAt, "MM-DD-YYYY hh:mm:ssA"), + "Listing Name": listing.name, + "Developer/Property Owner": listing.property.developer, + "Street Address": listing.property.buildingAddress?.street, + City: listing.property.buildingAddress?.city, + State: listing.property.buildingAddress?.state, + Zip: listing.property.buildingAddress?.zipCode, + "Year Built": listing.property.yearBuilt, + Neighborhood: listing.property.neighborhood, + Region: listing.property.region, + Latitude: listing.property.buildingAddress?.latitude, + Longitude: listing.property.buildingAddress?.longitude, + "Home Type": convertToTitleCase(listing.homeType), + "Accept Section 8": formatYesNo(listing.section8Acceptance), + "Number Of Unit Groups": listing.unitGroups?.length, + "Community Types": listing.listingPrograms + ?.map((listingProgram) => listingProgram.program.title) + .join(", "), + "Application Fee": formatCurrency(listing.applicationFee), + "Deposit Min": formatCurrency(listing.depositMin), + "Deposit Max": formatCurrency(listing.depositMax), + "Deposit Helper": listing.depositHelperText, + "Costs Not Included": listing.costsNotIncluded, + "Utilities Included": Object.entries(listing.utilities ?? {}) + .filter((entry) => entry[1] === true) + .map((entry) => convertToTitleCase(entry[0])) + .join(", "), + "Property Amenities": listing.property.amenities, + "Additional Accessibility Details": listing.property.accessibility, + "Unit Amenities": listing.property.unitAmenities, + "Smoking Policy": listing.property.smokingPolicy, + "Pets Policy": listing.property.petPolicy, + "Services Offered": listing.property.servicesOffered, + "Accessibility Features": Object.entries(listing.features ?? {}) + ?.filter((entry) => entry[1] === true) + .map((entry) => convertToTitleCase(entry[0])) + .join(", "), + "Grocery Stores": listing.neighborhoodAmenities?.groceryStores, + "Public Transportation": listing.neighborhoodAmenities?.publicTransportation, + Schools: listing.neighborhoodAmenities?.schools, + "Parks and Community Centers": listing.neighborhoodAmenities?.parksAndCommunityCenters, + Pharmacies: listing.neighborhoodAmenities?.pharmacies, + "Health Care Resources": listing.neighborhoodAmenities?.healthCareResources, + "Credit History": listing.creditHistory, + "Rental History": listing.rentalHistory, + "Criminal Background": listing.criminalBackground, + "Building Selection Criteria": cloudinaryPdfFromId( + listing.buildingSelectionCriteriaFile?.fileId + ), + "Required Documents": listing.requiredDocuments, + "Important Program Rules": listing.programRules, + "Special Notes": listing.specialNotes, + "Review Order": convertToTitleCase(listing.reviewOrderType), + "Lottery Date": formatDate(listing.events[0]?.startTime, "MM-DD-YYYY"), + "Lottery Start (UTC)": formatDate(listing.events[0]?.startTime, "hh:mmA"), + "Lottery End (UTC)": formatDate(listing.events[0]?.endTime, "hh:mmA"), + "Lottery Notes": listing.events[0]?.note, + Waitlist: formatYesNo(listing.isWaitlistOpen), + "Max Waitlist Size": listing.waitlistMaxSize, + "How many people on the current list": listing.waitlistCurrentSize, + "How many open spots on the waitlist": listing.waitlistOpenSpots, + "Marketing Status": convertToTitleCase(listing.marketingType), + "Marketing Season": convertToTitleCase(listing.marketingSeason), + "Marketing Date": formatDate(listing.marketingDate, "YYYY"), + "Leasing Company": listing.leasingAgentName, + "Leasing Email": listing.leasingAgentEmail, + "Leasing Phone": listing.leasingAgentPhone, + "Leasing Agent Title": listing.leasingAgentTitle, + "Leasing Agent Company Hours": listing.leasingAgentOfficeHours, + "Leasing Agency Website": listing.managementWebsite, + "Leasing Agency Street Address": listing.leasingAgentAddress?.street, + "Leasing Agency Street 2": listing.leasingAgentAddress?.street2, + "Leasing Agency City": listing.leasingAgentAddress?.city, + "Leasing Agency Zip": listing.leasingAgentAddress?.zipCode, + "Leasing Agency Mailing Address": listing.applicationMailingAddress?.street, + "Leasing Agency Mailing Address Street 2": listing.applicationMailingAddress?.street2, + "Leasing Agency Mailing Address City": listing.applicationMailingAddress?.city, + "Leasing Agency Mailing Address Zip": listing.applicationMailingAddress?.zipCode, + "Leasing Agency Pickup Address": listing.applicationPickUpAddress?.street, + "Leasing Agency Pickup Address Street 2": listing.applicationPickUpAddress?.street2, + "Leasing Agency Pickup Address City": listing.applicationPickUpAddress?.city, + "Leasing Agency Pickup Address Zip": listing.applicationPickUpAddress?.zipCode, + "Leasing Pick Up Office Hours": listing.applicationPickUpAddressOfficeHours, + "Postmark (UTC)": formatDate( + listing.postmarkedApplicationsReceivedByDate, + "MM-DD-YYYY hh:mm:ssA" + ), + "Digital Application": formatYesNo(listing.digitalApplication), + "Digital Application URL": listing.applicationMethods[1]?.externalReference, + "Paper Application": formatYesNo(listing.paperApplication), + "Paper Application URL": getPaperAppUrls(listing.applicationMethods[0]?.paperApplications), + "Partners Who Have Access": partnerAccessHelper[listing.id]?.join(", "), + } + }) + return this.csvBuilder.buildFromIdIndex(listingObj) + } + + exportUnitsFromObject(listings: any[]): string { + const reformattedListings = [] + listings.forEach((listing) => { + listing.unitGroups.forEach((unitGroup, idx) => { + reformattedListings.push({ + id: listing.id, + name: listing.name, + unitGroup, + unitGroupSummary: listing.unitSummaries.unitGroupSummary[idx], + }) + }) + }) + + const unitsFormatted = reformattedListings.map((listing) => { + return { + "Listing ID": listing.id, + "Listing Name": listing.name, + "Unit Group ID": listing.unitGroup.id, + "Unit Types": listing.unitGroupSummary?.unitTypes + .map((unitType) => formatBedroom[unitType]) + .join(", "), + "AMI Chart": [ + ...new Set(listing.unitGroup?.amiLevels.map((level) => level.amiChart?.name)), + ].join(", "), + "AMI Level": formatRange( + listing.unitGroupSummary?.amiPercentageRange?.min, + listing.unitGroupSummary?.amiPercentageRange?.max, + "", + "%" + ), + "Rent Type": getRentTypes(listing.unitGroup?.amiLevels), + "Monthly Rent": formatRentRange( + listing.unitGroupSummary.rentRange, + listing.unitGroupSummary.rentAsPercentIncomeRange + ), + "Affordable Unit Group Quantity": listing.unitGroup?.totalCount, + "Unit Group Vacancies": listing.unitGroup?.totalAvailable, + "Waitlist Status": formatYesNo(listing.unitGroup?.openWaitlist), + "Minimum Occupancy": listing.unitGroup?.minOccupancy, + "Maximum Occupancy": listing.unitGroup?.maxOccupancy, + "Minimum Sq ft": listing.unitGroup?.sqFeetMin, + "Maximum Sq ft": listing.unitGroup?.sqFeetMax, + "Minimum Floor": listing.unitGroup?.floorMin, + "Maximum Floor": listing.unitGroup?.floorMax, + "Minimum Bathrooms": listing.unitGroup?.bathroomMin, + "Maximum Bathrooms": listing.unitGroup?.bathroomMax, + } + }) + return this.csvBuilder.buildFromIdIndex(unitsFormatted) + } +} diff --git a/backend/core/src/listings/listings.controller.spec.ts b/backend/core/src/listings/listings.controller.spec.ts index cef5f0a222..de4d68f343 100644 --- a/backend/core/src/listings/listings.controller.spec.ts +++ b/backend/core/src/listings/listings.controller.spec.ts @@ -5,6 +5,7 @@ import { ListingsService } from "./listings.service" import { AuthzService } from "../auth/services/authz.service" import { CacheModule } from "@nestjs/common" import { ActivityLogService } from "../activity-log/services/activity-log.service" +import { ListingsCsvExporterService } from "./listings-csv-exporter.service" // Cypress brings in Chai types for the global expect, but we want to use jest // expect here so we need to re-declare it. @@ -27,6 +28,7 @@ describe("Listings Controller", () => { { provide: AuthzService, useValue: {} }, { provide: ListingsService, useValue: {} }, { provide: ActivityLogService, useValue: {} }, + { provide: ListingsCsvExporterService, useValue: {} }, ], controllers: [ListingsController], }).compile() diff --git a/backend/core/src/listings/listings.controller.ts b/backend/core/src/listings/listings.controller.ts index 6ae07138de..6e0e229d93 100644 --- a/backend/core/src/listings/listings.controller.ts +++ b/backend/core/src/listings/listings.controller.ts @@ -13,6 +13,7 @@ import { ValidationPipe, ClassSerializerInterceptor, Headers, + Header, } from "@nestjs/common" import { ListingsService } from "./listings.service" import { ApiBearerAuth, ApiExtraModels, ApiOperation, ApiTags } from "@nestjs/swagger" @@ -34,6 +35,7 @@ import { ListingCreateValidationPipe } from "./validation-pipes/listing-create-v import { ListingUpdateValidationPipe } from "./validation-pipes/listing-update-validation-pipe" import { ActivityLogInterceptor } from "../activity-log/interceptors/activity-log.interceptor" import { ActivityLogMetadata } from "../activity-log/decorators/activity-log-metadata.decorator" +import { ListingsCsvExporterService } from "../listings/listings-csv-exporter.service" @Controller("listings") @ApiTags("listings") @@ -43,7 +45,10 @@ import { ActivityLogMetadata } from "../activity-log/decorators/activity-log-met @ActivityLogMetadata([{ targetPropertyName: "status", propertyPath: "status" }]) @UseInterceptors(ActivityLogInterceptor) export class ListingsController { - constructor(private readonly listingsService: ListingsService) {} + constructor( + private readonly listingsService: ListingsService, + private readonly listingsCsvExporter: ListingsCsvExporterService + ) {} @Get("meta") @ApiOperation({ summary: "Returns Listing Metadata", operationId: "metadata" }) @@ -71,6 +76,20 @@ export class ListingsController { return mapTo(ListingDto, listing) } + @Get(`csv`) + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Retrieve listings and units in csv", operationId: "listAsCsv" }) + @Header("Content-Type", "text/csv") + async listAsCsv(): Promise<{ listingCsv: string; unitCsv: string }> { + const data = await this.listingsService.rawListWithFlagged() + const listingCsv = this.listingsCsvExporter.exportListingsFromObject( + data?.listingData, + data?.userAccessData + ) + const unitCsv = this.listingsCsvExporter.exportUnitsFromObject(data?.unitData) + return { listingCsv, unitCsv } + } + @Get(`:id`) @ApiOperation({ summary: "Get listing by id", operationId: "retrieve" }) @UseInterceptors(ClassSerializerInterceptor) diff --git a/backend/core/src/listings/listings.module.ts b/backend/core/src/listings/listings.module.ts index a7db392b1d..78e92fdc23 100644 --- a/backend/core/src/listings/listings.module.ts +++ b/backend/core/src/listings/listings.module.ts @@ -18,6 +18,8 @@ import { UnitGroup } from "../units-summary/entities/unit-group.entity" import { UnitType } from "../unit-types/entities/unit-type.entity" import { Program } from "../program/entities/program.entity" import { ListingUtilities } from "./entities/listing-utilities.entity" +import { ListingsCsvExporterService } from "./listings-csv-exporter.service" +import { CsvBuilder } from "../../src/applications/services/csv-builder.service" @Module({ imports: [ @@ -39,7 +41,12 @@ import { ListingUtilities } from "./entities/listing-utilities.entity" SmsModule, ActivityLogModule, ], - providers: [ListingsService, ListingsNotificationsConsumer], + providers: [ + ListingsService, + ListingsNotificationsConsumer, + CsvBuilder, + ListingsCsvExporterService, + ], exports: [ListingsService], controllers: [ListingsController], }) diff --git a/backend/core/src/listings/listings.service.ts b/backend/core/src/listings/listings.service.ts index df746256aa..c98e98edc0 100644 --- a/backend/core/src/listings/listings.service.ts +++ b/backend/core/src/listings/listings.service.ts @@ -1,4 +1,11 @@ -import { HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common" +import { + HttpException, + HttpStatus, + Inject, + Injectable, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common" import { InjectRepository } from "@nestjs/typeorm" import { Pagination } from "nestjs-typeorm-paginate" import { In, Repository, SelectQueryBuilder } from "typeorm" @@ -23,6 +30,9 @@ import { ListingMetadataDto } from "./dto/listings-metadata.dto" import { UnitType } from "../unit-types/entities/unit-type.entity" import { Program } from "../program/entities/program.entity" import { ListingSeasonEnum } from "./types/listing-season-enum" +import { User } from "../auth/entities/user.entity" +import { REQUEST } from "@nestjs/core" +import { Request as ExpressRequest } from "express" @Injectable() export class ListingsService { @@ -32,6 +42,8 @@ export class ListingsService { @InjectRepository(UnitGroup) private readonly unitGroupRepository: Repository, @InjectRepository(UnitType) private readonly unitTypeRepository: Repository, @InjectRepository(Program) private readonly programRepository: Repository, + @InjectRepository(User) private readonly userRepository: Repository, + @Inject(REQUEST) private req: ExpressRequest, private readonly translationService: TranslationsService ) {} @@ -251,6 +263,71 @@ export class ListingsService { return result } + async rawListWithFlagged() { + const userAccess = await this.userRepository + .createQueryBuilder("user") + .select("user.id") + .leftJoin("user.roles", "userRole") + .where("user.id = :id", { id: (this.req.user as User)?.id }) + .andWhere("userRole.is_admin = :is_admin", { is_admin: true }) + .getOne() + + if (!userAccess) { + throw new UnauthorizedException() + } + + // generated out list of permissioned listings + const permissionedListings = await this.listingRepository + .createQueryBuilder("listing") + .select("listing.id") + .getMany() + + // pulled out on the ids + const listingIds = permissionedListings.map((listing) => listing.id) + + // Building and excecuting query for listings csv + const listingsQb = getView( + this.listingRepository.createQueryBuilder("listing"), + "listingsExport" + ).getViewQb() + const listingData = await listingsQb + .where("listing.id IN (:...listingIds)", { listingIds }) + .getMany() + + // User data to determine listing access for csv + const userAccessData = await this.userRepository + .createQueryBuilder("user") + .select([ + "user.id", + "user.firstName", + "user.lastName", + "userRoles.isAdmin", + "userRoles.isPartner", + "leasingAgentInListings.id", + ]) + .leftJoin("user.leasingAgentInListings", "leasingAgentInListings") + .leftJoin("user.jurisdictions", "jurisdictions") + .leftJoin("user.roles", "userRoles") + .where("userRoles.is_partner = :is_partner", { is_partner: true }) + .getMany() + + // Building and excecuting query for units csv + const unitsQb = getView( + this.listingRepository.createQueryBuilder("listing"), + "unitsExport" + ).getViewQb() + const unitDataRaw = await unitsQb + .where("listing.id IN (:...listingIds)", { listingIds }) + .getMany() + const unitData = await this.addUnitSummariesToListings(unitDataRaw) + + return { + unitData, + listingData, + userAccessData, + } + } + private async addUnitSummaries(listing: Listing) { if (Array.isArray(listing.unitGroups) && listing.unitGroups.length > 0) { const amiChartIds = listing.unitGroups.reduce((acc: string[], curr: UnitGroup) => { diff --git a/backend/core/src/listings/tests/listings.service.spec.ts b/backend/core/src/listings/tests/listings.service.spec.ts index 383cb27562..d1b73f37df 100644 --- a/backend/core/src/listings/tests/listings.service.spec.ts +++ b/backend/core/src/listings/tests/listings.service.spec.ts @@ -15,6 +15,7 @@ import { UnitGroup } from "../../units-summary/entities/unit-group.entity" import { UnitType } from "../../unit-types/entities/unit-type.entity" import { Program } from "../../program/entities/program.entity" import { BullModule, getQueueToken } from "@nestjs/bull" +import { User } from "../../../src/auth/entities/user.entity" // Cypress brings in Chai types for the global expect, but we want to use jest // expect here so we need to re-declare it. @@ -144,6 +145,7 @@ describe("ListingsService", () => { provide: TranslationsService, useValue: { translateListing: jest.fn() }, }, + { provide: getRepositoryToken(User), useValue: jest.fn() }, ], imports: [BullModule.registerQueue({ name: "listings-notifications" })], }) diff --git a/backend/core/src/listings/views/config.ts b/backend/core/src/listings/views/config.ts index 6a1170cf5a..4a35b76ae8 100644 --- a/backend/core/src/listings/views/config.ts +++ b/backend/core/src/listings/views/config.ts @@ -286,4 +286,173 @@ views.publicListings = { ], } +views.listingsExport = { + select: [ + "listing.id", + "listing.createdAt", + "listing.status", + "listing.publishedAt", + "listing.isVerified", + "listing.verifiedAt", + "listing.updatedAt", + "listing.name", + "property.developer", + "reservedCommunityType.id", + "reservedCommunityType.name", + "property.id", + ...getBaseAddressSelect(["buildingAddress"]), + "property.yearBuilt", + "property.neighborhood", + "property.region", + "listing.homeType", + "listing.section8Acceptance", + "unitGroups.totalCount", + "listingPrograms.ordinal", + "listingProgramsProgram.id", + "listingProgramsProgram.title", + "listing.applicationFee", + "listing.depositMin", + "listing.depositMax", + "listing.depositHelperText", + "listing.costsNotIncluded", + "utilities.id", + "utilities.water", + "utilities.gas", + "utilities.trash", + "utilities.sewer", + "utilities.electricity", + "utilities.cable", + "utilities.phone", + "utilities.internet", + "property.amenities", + "property.accessibility", + "property.unitAmenities", + "property.smokingPolicy", + "property.petPolicy", + "property.servicesOffered", + "features.id", + "features.elevator", + "features.wheelchairRamp", + "features.serviceAnimalsAllowed", + "features.accessibleParking", + "features.parkingOnSite", + "features.inUnitWasherDryer", + "features.laundryInBuilding", + "features.barrierFreeEntrance", + "features.rollInShower", + "features.grabBars", + "features.heatingInUnit", + "features.acInUnit", + "features.hearing", + "features.visual", + "features.mobility", + "features.loweredLightSwitch", + "features.barrierFreeBathroom", + "features.wideDoorways", + "features.loweredCabinets", + "neighborhoodAmenities.groceryStores", + "neighborhoodAmenities.pharmacies", + "neighborhoodAmenities.healthCareResources", + "neighborhoodAmenities.parksAndCommunityCenters", + "neighborhoodAmenities.schools", + "neighborhoodAmenities.publicTransportation", + "listing.creditHistory", + "listing.rentalHistory", + "listing.criminalBackground", + "listing.buildingSelectionCriteria", + "buildingSelectionCriteriaFile.id", + "buildingSelectionCriteriaFile.fileId", + "listing.requiredDocuments", + "listing.programRules", + "listing.specialNotes", + "listing.reviewOrderType", + "listingEvents.startTime", + "listingEvents.endTime", + "listingEvents.note", + "listing.applicationDueDate", + "listing.isWaitlistOpen", + "listing.waitlistMaxSize", + "listing.waitlistCurrentSize", + "listing.waitlistOpenSpots", + "listing.marketingType", + "listing.marketingDate", + "listing.marketingSeason", + "listing.managementCompany", + "listing.managementWebsite", + "listing.leasingAgentEmail", + "listing.leasingAgentName", + "listing.leasingAgentOfficeHours", + "listing.leasingAgentPhone", + "listing.leasingAgentTitle", + ...getBaseAddressSelect([ + "leasingAgentAddress", + "applicationPickUpAddress", + "applicationMailingAddress", + "applicationDropOffAddress", + ]), + "listing.applicationPickUpAddressOfficeHours", + "listing.postmarkedApplicationsReceivedByDate", + "listing.digitalApplication", + "applicationMethods.id", + "applicationMethods.externalReference", + "listing.paperApplication", + "paperApplications.id", + "paperApplicationFile.id", + "paperApplicationFile.fileId", + ], + leftJoins: [ + { join: "listing.buildingSelectionCriteriaFile", alias: "buildingSelectionCriteriaFile" }, + { join: "listing.reservedCommunityType", alias: "reservedCommunityType" }, + { join: "listing.neighborhoodAmenities", alias: "neighborhoodAmenities" }, + { join: "listing.property", alias: "property" }, + { join: "property.buildingAddress", alias: "buildingAddress" }, + { join: "listing.utilities", alias: "utilities" }, + { join: "listing.unitGroups", alias: "unitGroups" }, + { join: "listing.listingPrograms", alias: "listingPrograms" }, + { join: "listingPrograms.program", alias: "listingProgramsProgram" }, + { join: "listing.events", alias: "listingEvents" }, + { join: "listing.features", alias: "features" }, + { join: "listing.leasingAgentAddress", alias: "leasingAgentAddress" }, + { join: "listing.applicationPickUpAddress", alias: "applicationPickUpAddress" }, + { join: "listing.applicationMailingAddress", alias: "applicationMailingAddress" }, + { join: "listing.applicationDropOffAddress", alias: "applicationDropOffAddress" }, + { join: "listing.applicationMethods", alias: "applicationMethods" }, + { join: "applicationMethods.paperApplications", alias: "paperApplications" }, + { join: "paperApplications.file", alias: "paperApplicationFile" }, + ], +} + +views.unitsExport = { + select: [ + "listing.id", + "listing.name", + "unitGroups.id", + "unitGroups.totalCount", + "unitGroups.totalAvailable", + "unitGroups.openWaitlist", + "unitGroups.minOccupancy", + "unitGroups.maxOccupancy", + "unitGroups.sqFeetMin", + "unitGroups.sqFeetMax", + "unitGroups.floorMin", + "unitGroups.floorMax", + "unitGroups.bathroomMin", + "unitGroups.bathroomMax", + "summaryUnitType.id", + "summaryUnitType.name", + "unitGroupsAmiLevels.id", + "unitGroupsAmiLevels.amiPercentage", + "unitGroupsAmiLevels.monthlyRentDeterminationType", + "unitGroupsAmiLevels.flatRentValue", + "unitGroupsAmiLevelsCharts.id", + "unitGroupsAmiLevelsCharts.name", + ], + leftJoins: [ + { join: "listing.unitGroups", alias: "unitGroups" }, + { join: "unitGroups.amiLevels", alias: "unitGroupsAmiLevels" }, + { join: "unitGroupsAmiLevels.amiChart", alias: "unitGroupsAmiLevelsCharts" }, + { join: "unitGroups.unitType", alias: "summaryUnitType" }, + ], +} + export { views } diff --git a/backend/core/src/listings/views/types.ts b/backend/core/src/listings/views/types.ts index fd09b805c7..01aafc9c7b 100644 --- a/backend/core/src/listings/views/types.ts +++ b/backend/core/src/listings/views/types.ts @@ -6,6 +6,8 @@ export enum ListingViewEnum { full = "full", partnerList = "partnerList", publicListings = "publicListings", + listingsExport = "listingsExport", + unitsExport = "unitsExport", } export type Views = { diff --git a/backend/core/src/listings/views/view.ts b/backend/core/src/listings/views/view.ts index a13df92e0b..970ea86cbf 100644 --- a/backend/core/src/listings/views/view.ts +++ b/backend/core/src/listings/views/view.ts @@ -14,6 +14,10 @@ export function getView(qb: SelectQueryBuilder, view?: string) { return new PublicListingsView(qb) case views.partnerList: return new PartnerListView(qb) + case views.listingsExport: + return new ListingsExportView(qb) + case views.unitsExport: + return new UnitsExportView(qb) case views.full: default: return new FullView(qb) @@ -68,6 +72,20 @@ export class PartnerListView extends BaseListingView { } } +export class ListingsExportView extends BaseListingView { + constructor(qb: SelectQueryBuilder) { + super(qb) + this.view = views.listingsExport + } +} + +export class UnitsExportView extends BaseListingView { + constructor(qb: SelectQueryBuilder) { + super(qb) + this.view = views.unitsExport + } +} + export class FullView extends BaseListingView { constructor(qb: SelectQueryBuilder) { super(qb) diff --git a/backend/core/src/seeder/detroit-seed.ts b/backend/core/src/seeder/detroit-seed.ts index 5a06ca3005..ecdfc25b9d 100644 --- a/backend/core/src/seeder/detroit-seed.ts +++ b/backend/core/src/seeder/detroit-seed.ts @@ -177,7 +177,7 @@ async function seed() { ) await userRepo.save(admin) - const roles: UserRoles = { user: admin, isPartner: true, isAdmin: true } + const roles: UserRoles = { user: admin, isPartner: false, isAdmin: true } await rolesRepo.save(roles) await userService.confirm({ token: admin.confirmationToken }) } diff --git a/backend/core/src/shared/query-filter/index.ts b/backend/core/src/shared/query-filter/index.ts index 439243a53e..0c590a3b19 100644 --- a/backend/core/src/shared/query-filter/index.ts +++ b/backend/core/src/shared/query-filter/index.ts @@ -96,7 +96,7 @@ export function addFilters, FilterFieldMap>( continue //custom user filters case UserFilterKeys.isPortalUser: - addIsPortalUserQuery(qb, filterValue) + addIsPortalUserQuery(qb, filterValue.toString()) continue } diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index 2ee16c27ee..90ad98c9ef 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -1024,6 +1024,21 @@ export class UserService { axios(configs, resolve, reject) }) } + /** + * List users in CSV + */ + listAsCsv(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/csv" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } /** * Invite user */ @@ -1302,6 +1317,21 @@ export class ListingsService { axios(configs, resolve, reject) }) } + /** + * Retrieve listings and units in csv + */ + listAsCsv(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/listings/csv" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } /** * Get listing by id */ @@ -4773,6 +4803,9 @@ export interface ListingFilterParams { /** */ section8Acceptance?: boolean + + /** */ + homeType?: string } export interface MinMax { @@ -5651,6 +5684,9 @@ export interface Listing { /** */ isVerified?: boolean + /** */ + verifiedAt?: Date + /** */ temporaryListingId?: number @@ -6121,6 +6157,9 @@ export interface ListingCreate { /** */ isVerified?: boolean + /** */ + verifiedAt?: Date + /** */ temporaryListingId?: number @@ -6613,6 +6652,9 @@ export interface ListingUpdate { /** */ isVerified?: boolean + /** */ + verifiedAt?: Date + /** */ temporaryListingId?: number diff --git a/detroit-ui-components/src/blocks/ImageCard.tsx b/detroit-ui-components/src/blocks/ImageCard.tsx index 82b9a7f937..a4a8bc6a9f 100644 --- a/detroit-ui-components/src/blocks/ImageCard.tsx +++ b/detroit-ui-components/src/blocks/ImageCard.tsx @@ -101,6 +101,16 @@ const ImageCard = (props: ImageCardProps) => { return props.images?.slice(0, 3) }, [props.images]) + const getAltText = (index: number, displayedImages?: ImageItem[], description?: string) => { + if (description) { + return description + } + if (displayedImages && displayedImages.length > 1) { + return `${props.description} - ${index + 1}` + } + return props.description || "" + } + const image = ( <>
@@ -133,11 +143,7 @@ const ImageCard = (props: ImageCardProps) => { { )) ) : ( @@ -189,14 +195,7 @@ const ImageCard = (props: ImageCardProps) => {

{image.mobileUrl && } - { + {getAltText(index,

))} diff --git a/detroit-ui-components/src/forms/Field.tsx b/detroit-ui-components/src/forms/Field.tsx index b9b7370249..62a2a47fcb 100644 --- a/detroit-ui-components/src/forms/Field.tsx +++ b/detroit-ui-components/src/forms/Field.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react" +import React, { ChangeEvent, useMemo } from "react" import { ErrorMessage } from "@bloom-housing/ui-components" import { UseFormMethods, RegisterOptions } from "react-hook-form" @@ -27,6 +27,7 @@ export interface FieldProps { prepend?: string inputProps?: Record describedBy?: string + ariaLabel?: string getValues?: UseFormMethods["getValues"] setValue?: UseFormMethods["setValue"] dataTestId?: string @@ -58,20 +59,21 @@ const Field = (props: FieldProps) => { if (props.bordered && (props.type === "radio" || props.type === "checkbox")) controlClasses.push("field-border") - const formatValue = () => { - if (props.getValues && props.setValue) { - const currencyValue = props.getValues(props.name) - const numericIncome = parseFloat(currencyValue) - if (!isNaN(numericIncome)) { - props.setValue(props.name, numericIncome.toFixed(2)) - } + const filterNumbers = (e: ChangeEvent) => { + if (props.setValue) { + props.setValue(props.name, e.target.value.replace(/[a-z]|[A-Z]/g, "").match(/^\d*\.?\d?\d?/g)) } } let inputProps = { ...props.inputProps } - if (props.type === "currency") inputProps = { ...inputProps, step: 0.01, onBlur: formatValue } + if (props.type === "currency") { + inputProps = { + ...inputProps, + onChange: filterNumbers, + } + } - const type = (props.type === "currency" && "number") || props.type || "text" + const type = (props.type === "currency" && "text") || props.type || "text" const isRadioOrCheckbox = ["radio", "checkbox"].includes(type) const label = useMemo(() => { @@ -116,6 +118,7 @@ const Field = (props: FieldProps) => { { } const ModalHeader = (props: { title: string; uniqueId?: string; className?: string }) => { + const modalHeader = useRef(null) + useEffect(() => modalHeader?.current?.focus(), [props.title]) + const classNames = ["modal__title"] if (props.className) classNames.push(props.className) return ( <>
-

+

{props.title}

diff --git a/detroit-ui-components/src/tables/GroupedTable.tsx b/detroit-ui-components/src/tables/GroupedTable.tsx index 1c3c7d1e69..f1e831f146 100644 --- a/detroit-ui-components/src/tables/GroupedTable.tsx +++ b/detroit-ui-components/src/tables/GroupedTable.tsx @@ -80,7 +80,7 @@ export const GroupedTable = (props: GroupedTableProps) => { return (
- +
{headerLabels} diff --git a/detroit-ui-components/src/tables/StandardTable.tsx b/detroit-ui-components/src/tables/StandardTable.tsx index da76819c05..18e8b8257c 100644 --- a/detroit-ui-components/src/tables/StandardTable.tsx +++ b/detroit-ui-components/src/tables/StandardTable.tsx @@ -73,6 +73,8 @@ export interface StandardTableProps { translateData?: boolean /** An id applied to the table */ id?: string + /** An accessible label applied to the table */ + ariaLabel?: string } const headerName = (header: string | TableHeadersOptions) => { @@ -221,7 +223,7 @@ export const StandardTable = (props: StandardTableProps) => { return (
-
+
{headerLabels} diff --git a/shared-helpers/src/AuthContext.ts b/shared-helpers/src/AuthContext.ts index 9d56f0d85c..85cd1cb4cf 100644 --- a/shared-helpers/src/AuthContext.ts +++ b/shared-helpers/src/AuthContext.ts @@ -135,6 +135,7 @@ const reducer = createReducer( initialStateLoaded: false, storageType: "session", language: "en", + accessToken: undefined, } as AuthState, { SAVE_TOKEN: (state, { payload }) => { diff --git a/sites/partners/.jest/setup-tests.js b/sites/partners/.jest/setup-tests.js index aaf2af2ea1..f93af99e95 100644 --- a/sites/partners/.jest/setup-tests.js +++ b/sites/partners/.jest/setup-tests.js @@ -1 +1,26 @@ // Future home of additional Jest config +import { addTranslation } from "@bloom-housing/ui-components" +import generalTranslations from "../../../detroit-ui-components/src/locales/general.json" +import { configure } from "@testing-library/dom" +import { serviceOptions } from "@bloom-housing/backend-core" +import axios from "axios" +import "@testing-library/jest-dom/extend-expect" +import general from "../src/page_content/locale_overrides/general.json" +addTranslation({ ...generalTranslations, ...general }) + +process.env.cloudinaryCloudName = "exygy" +process.env.cloudinarySignedPreset = "test123" +process.env.backendApiBase = "http://localhost:3100" + +global.beforeEach(() => { + serviceOptions.axios = axios.create({ + baseURL: "http://localhost:3100", + }) +}) + +configure({ testIdAttribute: "data-test-id" }) + +// Need to set __next on base div to handle the overlay +const portalRoot = document.createElement("div") +portalRoot.setAttribute("id", "__next") +document.body.appendChild(portalRoot) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/DetailUnits.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/DetailUnits.test.tsx index de498b76e0..ff8a16e919 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/DetailUnits.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/DetailUnits.test.tsx @@ -1,15 +1,19 @@ import React from "react" -import { fireEvent, render, within } from "@testing-library/react" +import { render, within } from "@testing-library/react" import { DetailUnits } from "../../../../../src/components/listings/PaperListingDetails/sections/DetailUnits" import { ListingContext } from "../../../../../src/components/listings/ListingContext" -import { listing, unit } from "../../../../testHelpers" +import { listing } from "../../../../testHelpers" import { ListingReviewOrder } from "@bloom-housing/backend-core" describe("DetailUnits", () => { it("should render the detail units when no units exist", () => { const results = render( @@ -17,14 +21,11 @@ describe("DetailUnits", () => { // Above the table expect(results.getByText("Listing Units")).toBeInTheDocument() - expect( - results.getByText("Do you want to show unit types or individual units?") - ).toBeInTheDocument() - expect(results.getByText("Individual Units")).toBeInTheDocument() - expect(results.getByText("What is the listing availability?")).toBeInTheDocument() - expect(results.getByText("Open Waitlist")).toBeInTheDocument() + expect(results.getByText("Home Type")).toBeInTheDocument() + expect(results.getByText("Apartment")).toBeInTheDocument() // Table + expect(results.getByText("Unit Groups")).toBeInTheDocument() expect(results.getByText("None")).toBeInTheDocument() }) @@ -44,31 +45,28 @@ describe("DetailUnits", () => { // Above the table expect(results.getByText("Listing Units")).toBeInTheDocument() - expect( - results.getByText("Do you want to show unit types or individual units?") - ).toBeInTheDocument() - expect(results.getByText("Unit Types")).toBeInTheDocument() - expect(results.getByText("What is the listing availability?")).toBeInTheDocument() - expect(results.getByText("Available Units")).toBeInTheDocument() + expect(results.getByText("Home Type")).toBeInTheDocument() + expect(results.getByText("Apartment")).toBeInTheDocument() // Table const table = results.getByRole("table") const headAndBody = within(table).getAllByRole("rowgroup") expect(headAndBody).toHaveLength(2) const [head, body] = headAndBody - expect(within(head).getAllByRole("columnheader")).toHaveLength(7) + expect(within(head).getAllByRole("columnheader")).toHaveLength(8) const rows = within(body).getAllByRole("row") - expect(rows).toHaveLength(6) + expect(rows).toHaveLength(1) // Validate first row - const [unitNumber, type, ami, rent, sqft, ada, action] = within(rows[0]).getAllByRole("cell") + const [type, unitNumber, ami, rent, occupancy, sqft, bath, actions] = within( + rows[0] + ).getAllByRole("cell") + expect(type).toHaveTextContent("1 Bedroom") expect(unitNumber).toBeEmptyDOMElement() - expect(type).toHaveTextContent("Studio") - expect(ami).toHaveTextContent(unit.amiPercentage) - expect(rent).toHaveTextContent(unit.monthlyRent) - expect(sqft).toHaveTextContent(unit.sqFeet) - expect(ada).toBeEmptyDOMElement() - - fireEvent.click(within(action).getByText("View")) - expect(callUnitDrawer).toBeCalledWith(unit) + expect(ami).toBeEmptyDOMElement() + expect(rent).toBeEmptyDOMElement() + expect(occupancy).toHaveTextContent("1 - 3") + expect(sqft).toBeEmptyDOMElement() + expect(bath).toHaveTextContent("1 - 2") + expect(actions).toBeEmptyDOMElement() }) }) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/LotteryResults.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/LotteryResults.test.tsx deleted file mode 100644 index 951567b15d..0000000000 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/LotteryResults.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from "react" -import { fireEvent, render } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { rest } from "msw" -import { setupServer } from "msw/node" -import LotteryResults from "../../../../../src/components/listings/PaperListingForm/sections/LotteryResults" -import { FormProvider, useForm } from "react-hook-form" -import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" -import { ListingEvent, ListingEventType } from "@bloom-housing/backend-core" - -const FormComponent = ({ children, values }: { values?: FormListing; children }) => { - const formMethods = useForm({ - defaultValues: { ...formDefaults, ...values }, - shouldUnregister: false, - }) - return {children} -} - -const server = setupServer() - -// Enable API mocking before tests. -beforeAll(() => { - server.listen() -}) - -// Reset any runtime request handlers we may add during the tests. -afterEach(() => server.resetHandlers()) - -// Disable API mocking after the tests are done. -afterAll(() => server.close()) - -describe("LotteryResults", () => { - it("Should not render anything when drawerState is false", () => { - const submitFn = jest.fn() - const showDrawerFn = jest.fn() - const results = render( - - - - ) - - expect(results.container.innerHTML).toEqual("") - }) - - it("Should Render Lottery Results in open state", () => { - const submitFn = jest.fn() - const showDrawerFn = jest.fn() - const results = render( - - - - ) - - expect(results.getByText("Add Results")).toBeTruthy() - expect(results.getByText("Upload Results")).toBeTruthy() - expect(results.getByText("Select PDF file")).toBeTruthy() - expect(results.getByText("Drag files here", { exact: false })).toBeTruthy() - expect(results.getByText("choose from folder")).toBeTruthy() - expect(results.getByText("Save")).toBeTruthy() - expect(results.getByText("Cancel")).toBeTruthy() - }) - - it("Should call showDrawer function when cancel is clicked", () => { - const submitFn = jest.fn() - const showDrawerFn = jest.fn() - const results = render( - - - - ) - - expect(results.getByText("Add Results")).toBeTruthy() - - fireEvent.click(results.getByText("Cancel")) - - expect(showDrawerFn).toBeCalledWith(false) - }) - - it("Should show Edit result when one exists on load", () => { - const lotteryResultEvent: ListingEvent = { - type: ListingEventType.lotteryResults, - id: "lotteryId", - createdAt: new Date(), - updatedAt: new Date(), - } - const submitFn = jest.fn() - const showDrawerFn = jest.fn() - const results = render( - - - - ) - - expect(results.getByText("Edit Results")).toBeTruthy() - expect(results.getByText("Preview")).toBeTruthy() - expect(results.getByText("lotteryId")).toBeTruthy() - expect(results.getByText("Post")).toBeTruthy() - expect(results.queryByText("Save")).toBeFalsy() - }) - - // This test needs to be skipped until this is fixed in MSW https://github.com/mswjs/interceptors/issues/187 - // or we move off of axios onto a fetch based api - it.skip("Should upload pdf when file dropped in", async () => { - server.use( - rest.post("https://api.cloudinary.com/v1_1/exygy/upload", (_req, res, ctx) => { - return res( - ctx.json({ public_id: "dev/Untitled_document_ltgz0q", url: "http://example.com" }) - ) - }), - rest.post("http://localhost:3100/assets/presigned-upload-metadata", (_req, res, ctx) => { - return res(ctx.status(201), ctx.json("")) - }) - ) - - const submitFn = jest.fn() - const showDrawerFn = jest.fn() - const results = render( - - - - ) - - expect(results.getByText("Add Results")).toBeTruthy() - expect(results.getByText("Drag files here", { exact: false })).toBeTruthy() - const file = new File(["hello"], "sample.pdf", { type: "application/pdf" }) - await userEvent.upload(results.getByTestId("dropzone-input"), file) - - await results.findByAltText("PDF preview") - expect(results.getByText("Untitled_document_ltgz0q")).toBeInTheDocument() - expect(results.getByText("Delete")).toBeInTheDocument() - }) -}) diff --git a/sites/partners/__tests__/lib/listings/AdditionalMetadataFormatter.test.ts b/sites/partners/__tests__/lib/listings/AdditionalMetadataFormatter.test.ts index 756c0da7c8..d493ee42cd 100644 --- a/sites/partners/__tests__/lib/listings/AdditionalMetadataFormatter.test.ts +++ b/sites/partners/__tests__/lib/listings/AdditionalMetadataFormatter.test.ts @@ -1,7 +1,6 @@ -import { Preference, ReservedCommunityType } from "@bloom-housing/backend-core/types" import { LatitudeLongitude } from "@bloom-housing/ui-components" import AdditionalMetadataFormatter from "../../../src/lib/listings/AdditionalMetadataFormatter" -import { FormListing, FormMetadata } from "../../../src/lib/listings/formTypes" +import { FormListing } from "../../../src/lib/listings/formTypes" const latLong: LatitudeLongitude = { latitude: 37.36537, @@ -12,29 +11,12 @@ const formatData = (data, metadata) => { return new AdditionalMetadataFormatter({ ...data }, metadata).format().data } -const fixtureData = { reservedCommunityType: { id: "12345" } } as FormListing +const fixtureData = { + reservedCommunityType: { id: "12345" }, + neighborhoodAmenities: {}, +} as FormListing describe("AdditionalMetadataFormatter", () => { - it("should format preferences", () => { - const metadata = { - latLong, - preferences: [ - { - title: "Preference 1", - }, - { - title: "Preference 2", - }, - ], - programs: [], - } as FormMetadata - - expect(formatData(fixtureData, metadata).listingPreferences).toEqual([ - { preference: { title: "Preference 1" }, ordinal: 1 }, - { preference: { title: "Preference 2" }, ordinal: 2 }, - ]) - }) - it("should format buildingAddress", () => { const address = { street: "123 Anywhere St.", city: "Anytown", state: "CA" } const data = { diff --git a/sites/partners/__tests__/pages/listings/index.test.tsx b/sites/partners/__tests__/pages/listings/index.test.tsx new file mode 100644 index 0000000000..d16a29e7a1 --- /dev/null +++ b/sites/partners/__tests__/pages/listings/index.test.tsx @@ -0,0 +1,154 @@ +import { + ACCESS_TOKEN_LOCAL_STORAGE_KEY, + AuthProvider, + ConfigProvider, +} from "@bloom-housing/shared-helpers" + +import { fireEvent, render } from "@testing-library/react" +import { rest } from "msw" +import { setupServer } from "msw/node" +import ListingsList from "../../../src/pages/index" +import React from "react" +import { listing } from "../../testHelpers" + +//Mock the jszip package used for Export +const mockFile = jest.fn() +let mockFolder: jest.Mock +function mockJszip() { + mockFolder = jest.fn(mockJszip) + return { + folder: mockFolder, + file: mockFile, + generateAsync: jest.fn().mockImplementation(() => { + const blob = {} + const response = { blob } + return Promise.resolve(response) + }), + } +} +jest.mock("jszip", () => { + return { + __esModule: true, + default: mockJszip, + } +}) + +const server = setupServer() +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() + window.sessionStorage.clear() +}) + +afterAll(() => server.close()) + +describe("listings", () => { + it("should not render Export to CSV when user is not admin", async () => { + jest.useFakeTimers() + const fakeToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ZTMxODNhOC0yMGFiLTRiMDYtYTg4MC0xMmE5NjYwNmYwOWMiLCJpYXQiOjE2Nzc2MDAxNDIsImV4cCI6MjM5NzkwMDc0Mn0.ve1U5tAardpFjNyJ_b85QZLtu12MoMTa2aM25E8D1BQ" + window.sessionStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY, fakeToken) + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost:3100/user", (_req, res, ctx) => { + return res( + ctx.json({ id: "user1", roles: { id: "user1", isAdmin: false, isPartner: true } }) + ) + }) + ) + + const { findByText, queryByText } = render( + + + + + + ) + const header = await findByText("Detroit Partner Portal") + expect(header).toBeInTheDocument() + const exportButton = queryByText("Export to CSV") + expect(exportButton).not.toBeInTheDocument() + }) + + it("should render the error text when listings csv api call fails", async () => { + jest.useFakeTimers() + const fakeToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ZTMxODNhOC0yMGFiLTRiMDYtYTg4MC0xMmE5NjYwNmYwOWMiLCJpYXQiOjE2Nzc2MDAxNDIsImV4cCI6MjM5NzkwMDc0Mn0.ve1U5tAardpFjNyJ_b85QZLtu12MoMTa2aM25E8D1BQ" + window.sessionStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY, fakeToken) + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost:3100/listings/csv", (_req, res, ctx) => { + return res(ctx.status(500), ctx.json("")) + }), + rest.get("http://localhost:3100/user", (_req, res, ctx) => { + return res(ctx.json({ id: "user1", roles: { id: "user1", isAdmin: true } })) + }) + ) + + const { findByText, getByText } = render( + + + + + + ) + const header = await findByText("Detroit Partner Portal") + expect(header).toBeInTheDocument() + const exportButton = getByText("Export to CSV") + expect(exportButton).toBeInTheDocument() + fireEvent.click(exportButton) + jest.clearAllTimers() + const error = await findByText( + "Export failed. Please try again later. If the problem persists, please email supportbloom@exygy.com", + { + exact: false, + } + ) + expect(error).toBeInTheDocument() + }) + + it("should render Export to CSV when user is admin and success message when clicked", async () => { + window.URL.createObjectURL = jest.fn() + //Prevent error from clicking anchor tag within test + HTMLAnchorElement.prototype.click = jest.fn() + jest.useFakeTimers() + const fakeToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ZTMxODNhOC0yMGFiLTRiMDYtYTg4MC0xMmE5NjYwNmYwOWMiLCJpYXQiOjE2Nzc2MDAxNDIsImV4cCI6MjM5NzkwMDc0Mn0.ve1U5tAardpFjNyJ_b85QZLtu12MoMTa2aM25E8D1BQ" + window.sessionStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY, fakeToken) + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + + rest.get("http://localhost:3100/listings/csv", (_req, res, ctx) => { + return res(ctx.json({ listingCSV: "", unitCSV: "" })) + }), + rest.get("http://localhost:3100/user", (_req, res, ctx) => { + return res(ctx.json({ id: "user1", roles: { id: "user1", isAdmin: true } })) + }) + ) + + const { findByText, getByText } = render( + + + + + + ) + const header = await findByText("Detroit Partner Portal") + expect(header).toBeInTheDocument() + const exportButton = getByText("Export to CSV") + expect(exportButton).toBeInTheDocument() + fireEvent.click(exportButton) + jest.clearAllTimers() + const success = await findByText("The file has been exported") + expect(success).toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/pages/users/index.test.tsx b/sites/partners/__tests__/pages/users/index.test.tsx new file mode 100644 index 0000000000..cd98411d22 --- /dev/null +++ b/sites/partners/__tests__/pages/users/index.test.tsx @@ -0,0 +1,158 @@ +import { + ACCESS_TOKEN_LOCAL_STORAGE_KEY, + AuthProvider, + ConfigProvider, +} from "@bloom-housing/shared-helpers" +import { fireEvent, render } from "@testing-library/react" +import { rest } from "msw" +import { setupServer } from "msw/node" +import React from "react" +import Users from "../../../src/pages/users" +import { user } from "../../testHelpers" + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() + window.sessionStorage.clear() +}) + +afterAll(() => server.close()) + +describe("users", () => { + it("should render the error text when api call fails", async () => { + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json([])) + }), + rest.get("http://localhost:3100/user/list", (_req, res, ctx) => { + return res(ctx.status(500), ctx.json("")) + }) + ) + const { findByText } = render( + + + + + + ) + + const error = await findByText("An error has occurred.") + expect(error).toBeInTheDocument() + }) + + it("should render user table when data is returned", async () => { + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json([])) + }), + rest.get("http://localhost:3100/user/list", (_req, res, ctx) => { + return res(ctx.json({ items: [user], meta: { totalItems: 1, totalPages: 1 } })) + }) + ) + const { findByText, getByText, queryAllByText } = render( + + + + + + ) + + const header = await findByText("Detroit Partner Portal") + expect(header).toBeInTheDocument() + expect(getByText("Users")).toBeInTheDocument() + expect(getByText("Filter")).toBeInTheDocument() + expect(getByText("Add User")).toBeInTheDocument() + expect(queryAllByText("Export to CSV")).toHaveLength(0) + + const name = await findByText("First Last") + expect(name).toBeInTheDocument() + expect(getByText("first.last@bloom.com")).toBeInTheDocument() + expect(getByText("Administrator")).toBeInTheDocument() + expect(getByText("09/04/2022")).toBeInTheDocument() + expect(getByText("Confirmed")).toBeInTheDocument() + }) + + it("should render Export to CSV when user is admin and success when clicked", async () => { + window.URL.createObjectURL = jest.fn() + // set a logged in token + jest.useFakeTimers() + const fakeToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ZTMxODNhOC0yMGFiLTRiMDYtYTg4MC0xMmE5NjYwNmYwOWMiLCJpYXQiOjE2Nzc2MDAxNDIsImV4cCI6MjM5NzkwMDc0Mn0.ve1U5tAardpFjNyJ_b85QZLtu12MoMTa2aM25E8D1BQ" + window.sessionStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY, fakeToken) + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json([])) + }), + rest.get("http://localhost:3100/user/list", (_req, res, ctx) => { + return res(ctx.json({ items: [user], meta: { totalItems: 1, totalPages: 1 } })) + }), + // set logged in user as admin + rest.get("http://localhost:3100/user", (_req, res, ctx) => { + return res(ctx.json({ id: "user1", roles: { id: "user1", isAdmin: true } })) + }), + rest.get("http://localhost:3100/user/csv", (_req, res, ctx) => { + return res(ctx.json("")) + }) + ) + const { findByText, getByText } = render( + + + + + + ) + + const header = await findByText("Detroit Partner Portal") + expect(header).toBeInTheDocument() + expect(getByText("Add User")).toBeInTheDocument() + expect(getByText("Export to CSV")).toBeInTheDocument() + fireEvent.click(getByText("Export to CSV")) + jest.clearAllTimers() + const successMessage = await findByText("The file has been exported") + expect(successMessage).toBeInTheDocument() + }) + + it("should render error message csv fails", async () => { + // set a logged in token + jest.useFakeTimers() + const fakeToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ZTMxODNhOC0yMGFiLTRiMDYtYTg4MC0xMmE5NjYwNmYwOWMiLCJpYXQiOjE2Nzc2MDAxNDIsImV4cCI6MjM5NzkwMDc0Mn0.ve1U5tAardpFjNyJ_b85QZLtu12MoMTa2aM25E8D1BQ" + window.sessionStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY, fakeToken) + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json([])) + }), + rest.get("http://localhost:3100/user/list", (_req, res, ctx) => { + return res(ctx.json({ items: [user], meta: { totalItems: 1, totalPages: 1 } })) + }), + // set logged in user as admin + rest.get("http://localhost:3100/user", (_req, res, ctx) => { + return res(ctx.json({ id: "user1", roles: { id: "user1", isAdmin: true } })) + }), + rest.get("http://localhost:3100/user/csv", (_req, res, ctx) => { + return res(ctx.status(500), ctx.json("")) + }) + ) + const { findByText, getByText } = render( + + + + + + ) + + const header = await findByText("Detroit Partner Portal") + expect(header).toBeInTheDocument() + fireEvent.click(getByText("Export to CSV")) + jest.clearAllTimers() + const errorMessage = await findByText("Export failed. Please try again later.", { + exact: false, + }) + expect(errorMessage).toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/testHelpers.ts b/sites/partners/__tests__/testHelpers.ts new file mode 100644 index 0000000000..4bf821347e --- /dev/null +++ b/sites/partners/__tests__/testHelpers.ts @@ -0,0 +1,190 @@ +import { + HomeTypeEnum, + Listing, + ListingMarketingTypeEnum, + ListingReviewOrder, + ListingStatus, + Unit, + UnitGroup, + UnitStatus, +} from "@bloom-housing/backend-core" + +export const user = { + agreedToTermsOfService: false, + confirmedAt: new Date(), + createdAt: new Date("2022-09-04T17:13:31.513Z"), + dob: new Date(), + email: "first.last@bloom.com", + failedLoginAttemptsCount: 0, + firstName: "First", + hitConfirmationURL: null, + id: "user_1", + jurisdictions: [ + { id: "e50e64bc-4bc8-4cef-a4d1-1812add9981b" }, + { id: "d6b652a0-9947-418a-b69b-cd72028ed913" }, + ], + language: null, + lastLoginAt: new Date(), + lastName: "Last", + leasingAgentInListings: [], + mfaEnabled: true, + middleName: "Middle", + passwordUpdatedAt: new Date(), + passwordValidForDays: 180, + phoneNumber: null, + phoneNumberVerified: false, + roles: { user: { id: "user_1" }, isAdmin: true, isJurisdictionalAdmin: false, isPartner: false }, + updatedAt: new Date(), +} + +export const unit: Unit = { + status: UnitStatus.available, + id: "sQ19KuyILEo0uuNqti2fl", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-07-09T21:20:05.783Z"), + updatedAt: new Date("2019-08-14T23:05:43.913Z"), + monthlyRentAsPercentOfIncome: null, +} + +const unitGroup: UnitGroup = { + unitType: [ + { + id: "unitType1", + createdAt: new Date(), + updatedAt: new Date(), + name: "oneBdrm", + numBedrooms: 1, + }, + ], + amiLevels: [], + id: "unitGroup1", + listingId: "listing1", + openWaitlist: true, + maxOccupancy: 3, + minOccupancy: 1, + bathroomMin: 1, + bathroomMax: 2, +} + +const address = { + id: "id", + createdAt: new Date(), + updatedAt: new Date(), + city: "San Francisco", + street: "548 Market St.", + zipCode: "94104", + state: "CA", + latitude: 37.36537, + longitude: -121.91071, +} + +export const listing: Listing = { + id: "Uvbk5qurpB2WI9V6WnNdH", + marketingType: ListingMarketingTypeEnum.marketing, + homeType: HomeTypeEnum.apartment, + applicationConfig: undefined, + applicationOpenDate: new Date("2019-12-31T15:22:57.000-07:00"), + applicationPickUpAddress: undefined, + applicationPickUpAddressOfficeHours: "", + applicationDropOffAddress: null, + applicationDropOffAddressOfficeHours: null, + applicationMailingAddress: null, + countyCode: "Detroit", + jurisdiction: { + id: "id", + name: "Detroit", + publicUrl: "", + }, + depositMax: "", + disableUnitsAccordion: false, + events: [], + showWaitlist: false, + reviewOrderType: ListingReviewOrder.firstComeFirstServe, + urlSlug: "listing-slug-abcdef", + whatToExpect: "Applicant will be contacted. All info will be verified. Be prepared if chosen.", + status: ListingStatus.active, + postmarkedApplicationsReceivedByDate: new Date("2019-12-05"), + applicationDueDate: new Date("2019-12-31T15:22:57.000-07:00"), + applicationMethods: [], + applicationOrganization: "98 Archer Street", + assets: [ + { + label: "building", + fileId: + "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/archer/archer-studios.jpg", + }, + ], + buildingSelectionCriteria: + "Tenant Selection Criteria will be available to all applicants upon request.", + costsNotIncluded: + "Resident responsible for PG&E, internet and phone. Owner pays for water, trash, and sewage. Residents encouraged to obtain renter's insurance but this is not a requirement. Rent is due by the 5th of each month. Late fee $35 and returned check fee is $35 additional.", + creditHistory: + "Applications will be rated on a score system for housing. An applicant's score may be impacted by negative tenant peformance information provided to the credit reporting agency. All applicants are expected have a passing acore of 70 points out of 100 to be considered for housing. Applicants with no credit history will receive a maximum of 80 points to fairly outweigh positive and/or negative trades as would an applicant with established credit history. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process. ", + depositMin: "1140.0", + programRules: + "Applicants must adhere to minimum & maximum income limits. Tenant Selection Criteria applies.", + waitlistMaxSize: 300, + name: "Archer Studios", + waitlistCurrentSize: 300, + waitlistOpenSpots: 0, + isWaitlistOpen: true, + displayWaitlistSize: false, + requiredDocuments: "Completed application and government issued IDs", + createdAt: new Date("2019-07-08T15:37:19.565-07:00"), + updatedAt: new Date("2019-07-09T14:35:11.142-07:00"), + applicationFee: "30.0", + criminalBackground: + "A criminal background investigation will be obtained on each applicant. As criminal background checks are done county by county and will be ran for all counties in which the applicant lived, Applicants will be disqualified for tenancy if they have been convicted of a felony or misdemeanor. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process. ", + leasingAgentAddress: address, + leasingAgentEmail: "admin@example.com", + leasingAgentName: "First Last", + leasingAgentOfficeHours: "Monday, Tuesday & Friday, 9:00AM - 5:00PM", + leasingAgentPhone: "(408) 217-8562", + leasingAgentTitle: "", + rentalAssistance: "Custom rental assistance", + rentalHistory: + "Two years of rental history will be verified with all applicable landlords. Household family members and/or personal friends are not acceptable landlord references. Two professional character references may be used in lieu of rental history for applicants with no prior rental history. An unlawful detainer report will be processed thourhg the U.D. Registry, Inc. Applicants will be disqualified if they have any evictions filing within the last 7 years. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process.", + householdSizeMin: 2, + householdSizeMax: 3, + smokingPolicy: "Non-smoking building", + unitsAvailable: 0, + unitAmenities: "Dishwasher", + developer: "Charities Housing ", + yearBuilt: 2012, + accessibility: + "There is a total of 5 ADA units in the complex, all others are adaptable. Exterior Wheelchair ramp (front entry)", + amenities: + "Community Room, Laundry Room, Assigned Parking, Bike Storage, Roof Top Garden, Part-time Resident Service Coordinator", + buildingTotalUnits: 35, + buildingAddress: address, + neighborhood: "Rosemary Gardens Park", + petPolicy: + "No pets allowed. Accommodation animals may be granted to persons with disabilities via a reasonable accommodation request.", + listingPreferences: [], + unitGroups: [unitGroup], + unitSummaries: { + unitGroupSummary: [], + householdMaxIncomeSummary: { columns: { householdSize: "3" }, rows: [] }, + }, + units: [unit], +} diff --git a/sites/partners/cypress.json b/sites/partners/cypress.json index 6db57c71b7..d5e75e52e4 100644 --- a/sites/partners/cypress.json +++ b/sites/partners/cypress.json @@ -3,6 +3,7 @@ "defaultCommandTimeout": 10000, "projectId": "bloom-partners-reference", "numTestsKeptInMemory": 0, + "trashAssetsBeforeRuns": true, "env": { "codeCoverage": { "url": "/api/__coverage__" diff --git a/sites/partners/cypress/integration/admin-user-management.spec.ts b/sites/partners/cypress/integration/admin-user-management.spec.ts new file mode 100644 index 0000000000..0a44fb0a52 --- /dev/null +++ b/sites/partners/cypress/integration/admin-user-management.spec.ts @@ -0,0 +1,38 @@ +describe("Admin User Mangement Tests", () => { + before(() => { + cy.login() + }) + + after(() => { + cy.signOut() + }) + + it("as admin user, should show all users", () => { + cy.visit("/") + cy.getByTestId("Users-1").click() + const rolesArray = ["Partner", "Administrator"] + cy.getByTestId("ag-page-size").select("100", { force: true }) + + const regex = new RegExp(`${rolesArray.join("|")}`, "g") + + cy.get(`.ag-center-cols-container [col-id="roles"]`).each((role) => { + cy.wrap(role).contains(regex) + }) + }) + + it("as admin user, should be able to download export", () => { + cy.visit("/") + cy.getByTestId("Users-1").click() + cy.getByTestId("export-users").click() + const convertToString = (value: number) => { + return value < 10 ? `0${value}` : `${value}` + } + const now = new Date() + const month = now.getMonth() + 1 + cy.readFile( + `cypress/downloads/users-${now.getFullYear()}-${convertToString(month)}-${convertToString( + now.getDate() + )}_${convertToString(now.getHours())}_${convertToString(now.getMinutes())}.csv` + ) + }) +}) diff --git a/sites/partners/cypress/integration/listing.spec.ts b/sites/partners/cypress/integration/listing.spec.ts index 369c5ef2a4..10f7720395 100644 --- a/sites/partners/cypress/integration/listing.spec.ts +++ b/sites/partners/cypress/integration/listing.spec.ts @@ -170,7 +170,6 @@ describe("Listing Management Tests", () => { cy.get(".p-4 > .is-primary").contains("Save").click() cy.get(".text-right > .button").contains("Application Process").click() cy.get("#reviewOrderFCFS").check() - cy.get("#dueDateQuestionNo").check() cy.get("#waitlistOpenNo").check() cy.get("#digitalApplicationChoiceYes").check() cy.get("#paperApplicationNo").check() @@ -265,4 +264,21 @@ describe("Listing Management Tests", () => { cy.getByTestId("page-header-text").should("have.text", `${listing["name"]} (Edited)`) }) }) + + it("as admin user, should be able to download listings export zip", () => { + const convertToString = (value: number) => { + return value < 10 ? `0${value}` : `${value}` + } + cy.visit("/") + cy.getByTestId("export-listings").click() + const now = new Date() + const dateString = `${now.getFullYear()}-${convertToString( + now.getMonth() + 1 + )}-${convertToString(now.getDate())}` + const timeString = `${convertToString(now.getHours())}-${convertToString(now.getMinutes())}` + const zipName = `${dateString}_${timeString}-complete-listing-data.zip` + const downloadFolder = Cypress.config("downloadsFolder") + const completeZipPath = `${downloadFolder}/${zipName}` + cy.readFile(completeZipPath) + }) }) diff --git a/sites/partners/package.json b/sites/partners/package.json index 2abbad2b32..e75c5ec61c 100644 --- a/sites/partners/package.json +++ b/sites/partners/package.json @@ -57,6 +57,8 @@ "@cypress/code-coverage": "^3.9.12", "@cypress/webpack-preprocessor": "^5.11.1", "@next/bundle-analyzer": "^10.1.0", + "@testing-library/react": "12.1.3", + "@testing-library/user-event": "^14.4.3", "@types/mapbox__mapbox-sdk": "^0.13.2", "@types/node": "^12.12.67", "@types/react": "^16.9.52", @@ -66,6 +68,7 @@ "cypress-file-upload": "^5.0.8", "jest": "^26.5.3", "js-levenshtein": "^1.1.6", + "msw": "^0.46.0", "next-transpile-modules": "^8.0.0", "nyc": "^15.1.0", "postcss": "^8.3.6", diff --git a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailRankingsAndResults.tsx b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailRankingsAndResults.tsx index 2cd4d0f1e0..82cf0a3a6a 100644 --- a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailRankingsAndResults.tsx +++ b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailRankingsAndResults.tsx @@ -54,13 +54,6 @@ const DetailRankingsAndResults = () => { )} - {getReviewOrderType() === ListingReviewOrder.firstComeFirstServe && ( - - - {listing.applicationDueDate ? t("t.yes") : t("t.no")} - - - )} {getDetailBoolean(listing.isWaitlistOpen)} diff --git a/sites/partners/src/components/listings/PaperListingForm/sections/RankingsAndResults.tsx b/sites/partners/src/components/listings/PaperListingForm/sections/RankingsAndResults.tsx index 7e242be275..0b08ab3749 100644 --- a/sites/partners/src/components/listings/PaperListingForm/sections/RankingsAndResults.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/sections/RankingsAndResults.tsx @@ -90,30 +90,6 @@ const RankingsAndResults = ({ listing }: RankingsAndResultsProps) => { /> - {reviewOrder === "reviewOrderFCFS" && ( - - -

{t("listings.dueDateQuestion")}

- -
-
- )} {reviewOrder === "reviewOrderLottery" && ( <> diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index 6d6b5078b1..ede297f6ef 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -1,7 +1,6 @@ -import { useContext } from "react" +import { useCallback, useContext, useState } from "react" import useSWR, { mutate } from "swr" import qs from "qs" - import { AuthContext } from "@bloom-housing/shared-helpers" import { EnumApplicationsApiExtraModelOrder, @@ -13,6 +12,9 @@ import { OrderByFieldsEnum, OrderDirEnum, } from "@bloom-housing/backend-core/types" +import dayjs from "dayjs" +import JSZip from "jszip" +import { setSiteAlertMessage, t } from "@bloom-housing/ui-components" interface PaginationProps { page?: number @@ -402,3 +404,93 @@ export function useUserList({ page, limit, search = "" }: UseUserListProps) { error, } } + +export const createDateStringFromNow = (format = "YYYY-MM-DD_HH:mm:ss"): string => { + const now = new Date() + return dayjs(now).format(format) +} + +export const useUsersExport = () => { + const { userService } = useContext(AuthContext) + + return useCsvExport( + () => userService.listAsCsv(), + `users-${createDateStringFromNow("YYYY-MM-DD_HH:mm")}.csv` + ) +} + +const useCsvExport = (endpoint: () => Promise, fileName: string) => { + const [csvExportLoading, setCsvExportLoading] = useState(false) + const [csvExportError, setCsvExportError] = useState(false) + const [csvExportSuccess, setCsvExportSuccess] = useState(false) + + const onExport = useCallback(async () => { + setCsvExportError(false) + setCsvExportSuccess(false) + setCsvExportLoading(true) + + try { + const content = await endpoint() + + const blob = new Blob([content], { type: "text/csv" }) + const fileLink = document.createElement("a") + fileLink.setAttribute("download", fileName) + fileLink.href = URL.createObjectURL(blob) + fileLink.click() + setCsvExportSuccess(true) + setSiteAlertMessage(t("t.exportSuccess"), "success") + } catch (err) { + setCsvExportError(true) + } + + setCsvExportLoading(false) + }, [endpoint, fileName]) + + return { + onExport, + csvExportLoading, + csvExportError, + csvExportSuccess, + } +} + +export const useListingZip = () => { + const { listingsService } = useContext(AuthContext) + + const [zipExportLoading, setZipExportLoading] = useState(false) + const [zipExportError, setZipExportError] = useState(false) + const [zipCompleted, setZipCompleted] = useState(false) + + const onExport = useCallback(async () => { + setZipExportError(false) + setZipCompleted(false) + setZipExportLoading(true) + + try { + const content = await listingsService.listAsCsv() + const now = new Date() + const dateString = dayjs(now).format("YYYY-MM-DD_HH-mm") + const zip = new JSZip() + zip.file(dateString + "_listing_data.csv", content?.listingCsv) + zip.file(dateString + "_unit_data.csv", content?.unitCsv) + await zip.generateAsync({ type: "blob" }).then(function (blob) { + const fileLink = document.createElement("a") + fileLink.setAttribute("download", `${dateString}-complete-listing-data.zip`) + fileLink.href = URL.createObjectURL(blob) + fileLink.click() + }) + setZipCompleted(true) + setSiteAlertMessage(t("t.exportSuccess"), "success") + } catch (err) { + setZipExportError(true) + } + setZipExportLoading(false) + }, [listingsService]) + + return { + onExport, + zipCompleted, + zipExportLoading, + zipExportError, + } +} diff --git a/sites/partners/src/page_content/locale_overrides/general.json b/sites/partners/src/page_content/locale_overrides/general.json index 305dfa5d47..3d259bd40f 100644 --- a/sites/partners/src/page_content/locale_overrides/general.json +++ b/sites/partners/src/page_content/locale_overrides/general.json @@ -91,6 +91,7 @@ "authentication.createAccount.firstName": "First Name", "authentication.createAccount.lastName": "Last Name", "errors.alert.emailConflict": "That email is already in use", + "errors.alert.exportFailed": "Export failed. Please try again later. If the problem persists, please email supportbloom@exygy.com", "errors.unauthorized.message": "Uh oh, you are not allowed to access this page.", "errors.maxLessThanMinBathroomError": "Max must be greater than or equal to Min Number of Bathrooms", "errors.maxLessThanMinFloorError": "Max must be greater than or equal to Min Floor", @@ -322,6 +323,8 @@ "t.endTime": "End Time", "t.enterAmount": "Enter amount", "t.export": "Export", + "t.exportSuccess": "The file has been exported", + "t.exportToCSV": "Export to CSV", "t.fileName": "File Name", "t.filter": "Filter", "t.invite": "Invite", diff --git a/sites/partners/src/pages/index.tsx b/sites/partners/src/pages/index.tsx index 9ca73cede2..e3d9000586 100644 --- a/sites/partners/src/pages/index.tsx +++ b/sites/partners/src/pages/index.tsx @@ -1,16 +1,18 @@ -import React, { useMemo, useContext } from "react" +import React, { useMemo, useContext, useState, useEffect } from "react" import Head from "next/head" import { ListingStatus } from "@bloom-housing/backend-core/types" -import { t, LocalizedLink } from "@bloom-housing/ui-components" +import { t, LocalizedLink, SiteAlert, AppearanceStyleType } from "@bloom-housing/ui-components" import { AuthContext } from "@bloom-housing/shared-helpers" import { Button } from "../../../../detroit-ui-components/src/actions/Button" import { PageHeader } from "../../../../detroit-ui-components/src/headers/PageHeader" import { AgTable, useAgTable } from "../../../../detroit-ui-components/src/tables/AgTable" import dayjs from "dayjs" import { ColDef, ColGroupDef } from "ag-grid-community" -import { useListingsData } from "../lib/hooks" +import { useListingsData, useListingZip } from "../lib/hooks" import Layout from "../layouts" -import { MetaTags } from "../components/shared/MetaTags" +import { MetaTags } from "../../src/components/shared/MetaTags" +import { faFileExport } from "@fortawesome/free-solid-svg-icons" +import { AlertBox } from "../../../../detroit-ui-components/src/notifications/AlertBox" class formatLinkCell { link: HTMLAnchorElement @@ -50,12 +52,17 @@ class ListingsLink extends formatLinkCell { export default function ListingsList() { const metaDescription = t("pageDescription.welcome", { regionName: t("region.name") }) - + const [errorAlert, setErrorAlert] = useState(false) const { profile } = useContext(AuthContext) const isAdmin = profile?.roles?.isAdmin || false const tableOptions = useAgTable() + const { onExport, zipCompleted, zipExportLoading, zipExportError } = useListingZip() + useEffect(() => { + setErrorAlert(zipExportError) + }, [zipExportError]) + const gridComponents = { formatLinkCell, formatWaitlistStatus, @@ -143,9 +150,26 @@ export default function ListingsList() { {t("nav.siteTitlePartners")} - + + {zipCompleted && ( +
+ +
+ )} +
+ {errorAlert && ( + setErrorAlert(false)} + closeable + type="alert" + inverted + > + {t("errors.alert.exportFailed")} + + )} {isAdmin && ( - - + + - + )} } diff --git a/sites/partners/src/pages/users/index.tsx b/sites/partners/src/pages/users/index.tsx index 33953f883e..65e2897a5c 100644 --- a/sites/partners/src/pages/users/index.tsx +++ b/sites/partners/src/pages/users/index.tsx @@ -1,14 +1,17 @@ -import React, { useMemo, useState } from "react" +import React, { useContext, useEffect, useMemo, useState } from "react" import Head from "next/head" import dayjs from "dayjs" -import { t, SiteAlert } from "@bloom-housing/ui-components" +import { t, SiteAlert, AlertBox } from "@bloom-housing/ui-components" import { Button } from "../../../../../detroit-ui-components/src/actions/Button" import { PageHeader } from "../../../../../detroit-ui-components/src/headers/PageHeader" import { Drawer } from "../../../../../detroit-ui-components/src/overlays/Drawer" import { AgTable, useAgTable } from "../../../../../detroit-ui-components/src/tables/AgTable" +import { AppearanceStyleType } from "../../../../../detroit-ui-components/src/global/AppearanceTypes" import { User } from "@bloom-housing/backend-core/types" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { faFileExport } from "@fortawesome/free-solid-svg-icons" import Layout from "../../layouts" -import { useUserList, useListingsData } from "../../lib/hooks" +import { useUserList, useListingsData, useUsersExport } from "../../lib/hooks" import { FormUserManage } from "../../components/users/FormUserManage" type UserDrawerValue = { @@ -34,10 +37,17 @@ const getRolesDisplay = ({ value }) => { const Users = () => { /* Add user drawer */ + const { profile } = useContext(AuthContext) const [userDrawer, setUserDrawer] = useState(null) + const [errorAlert, setErrorAlert] = useState(false) const tableOptions = useAgTable() + const { onExport, csvExportLoading, csvExportError, csvExportSuccess } = useUsersExport() + useEffect(() => { + setErrorAlert(csvExportError) + }, [csvExportError]) + const columns = useMemo(() => { return [ { @@ -138,23 +148,33 @@ const Users = () => { limit: "all", }) - if (error) return "An error has occurred." + if (error) return
An error has occurred.
return ( {t("nav.siteTitlePartners")} -
- - + {csvExportSuccess && ( + + )}
-
+ {errorAlert && ( + setErrorAlert(false)} + closeable + type="alert" + inverted + > + {t("errors.alert.exportFailed")} + + )} {
+ {profile?.roles?.isAdmin && ( + + )}
} /> diff --git a/sites/partners/styles/overrides.scss b/sites/partners/styles/overrides.scss index ca65268e5a..608c456fad 100644 --- a/sites/partners/styles/overrides.scss +++ b/sites/partners/styles/overrides.scss @@ -50,7 +50,7 @@ --bloom-font-sans: [ "Montserrat", "Open Sans", "Helvetica", "Arial", "Verdana", "sans-serif" ]; --bloom-font-alt-sans: [ "Montserrat", "Open Sans", "Helvetica", "Arial", "Verdana", "sans-serif" ]; - --bloom-color-accent-cool: "#297E73"; + --bloom-color-accent-cool: #297e73; --bloom-color-gray-700: "#000000"; --bloom-color-gray-800: "#18252A"; --bloom-color-gray-950: "#000000"; diff --git a/sites/public/src/components/filters/FilterForm.tsx b/sites/public/src/components/filters/FilterForm.tsx index 8917469173..021b7a26ae 100644 --- a/sites/public/src/components/filters/FilterForm.tsx +++ b/sites/public/src/components/filters/FilterForm.tsx @@ -118,7 +118,16 @@ const FilterForm = (props: FilterFormProps) => { // This is causing a linting issue with unbound-method, see issue: // https://github.com/react-hook-form/react-hook-form/issues/2887 // eslint-disable-next-line @typescript-eslint/unbound-method - const { handleSubmit, errors, register, reset, trigger, watch: formWatch } = useForm() + const { + handleSubmit, + errors, + register, + reset, + trigger, + watch: formWatch, + setValue, + getValues, + } = useForm() const minRent = formWatch("minRent") const maxRent = formWatch("maxRent") @@ -247,10 +256,12 @@ const FilterForm = (props: FilterFormProps) => { { { const dollarSign = useRouter().locale !== "ar" const numericFields = props.activeQuestion?.fields.filter((field) => field.type === "number") @@ -26,10 +28,13 @@ const FinderRentalCosts = (props: { { } } )} + description={listing.name} tags={getImageCardTag(listing)} moreImagesLabel={t("listings.moreImagesLabel")} moreImagesDescription={t("listings.moreImagesAltDescription", { @@ -539,6 +540,7 @@ export const ListingView = (props: ListingProps) => { headers={groupedUnitHeaders} data={[{ data: groupedUnitData }]} responsiveCollapse={true} + ariaLabel={t("t.unitInformation")} /> {listing?.section8Acceptance && (
@@ -605,7 +607,12 @@ export const ListingView = (props: ListingProps) => { )} - + )} {occupancyData.length > 0 && ( @@ -617,6 +624,7 @@ export const ListingView = (props: ListingProps) => { headers={occupancyHeaders} data={occupancyData} responsiveCollapse={false} + ariaLabel={t("t.occupancy")} /> )} diff --git a/sites/public/src/layouts/application.tsx b/sites/public/src/layouts/application.tsx index 139c4b706d..131f4ba1bd 100644 --- a/sites/public/src/layouts/application.tsx +++ b/sites/public/src/layouts/application.tsx @@ -172,6 +172,9 @@ const Layout = (props) => { {t("pageTitle.terms")} + + {t("pageTitle.accessibilityStatement")} +
diff --git a/sites/public/src/lib/helpers.tsx b/sites/public/src/lib/helpers.tsx index 0f9c48502e..05cee8eb5e 100644 --- a/sites/public/src/lib/helpers.tsx +++ b/sites/public/src/lib/helpers.tsx @@ -234,12 +234,15 @@ export const getListings = (listings) => { imageUrl: imageUrlFromListing(listing, parseInt(process.env.listingPhotoSize))[0], href: `/listing/${listing.id}/${listing.urlSlug}`, tags: getImageCardTag(listing), + description: listing.name, }} tableProps={{ headers: unitSummariesHeaders, data: getUnitGroupSummary(listing).data, responsiveCollapse: true, cellClassName: "px-5 py-3", + id: listing.name, + ariaLabel: `${listing.name} ${t("t.unitInformation")}`, }} contentProps={{ contentHeader: { text: listing.name, priority: 3 }, diff --git a/sites/public/src/md_content/accessibility.md b/sites/public/src/md_content/accessibility.md new file mode 100644 index 0000000000..82f0e5bd7c --- /dev/null +++ b/sites/public/src/md_content/accessibility.md @@ -0,0 +1,209 @@ + + +This is an accessibility statement from the City of Detroit Housing and Revitalization Department. + +### Conformance Status + +The [Web Content Accessibility Guidelines (WCAG)](https://www.w3.org/WAI/standards-guidelines/wcag/) defines best practices for designers and developers to improve accessibility of websites for people with disabilities. It defines three levels of conformance: Level A, Level AA, and Level AAA. Our goal is to deliver a web experience that achieves “Level AA” conformance according to the Web Content Accessibility Guidelines v2.1 (WCAG 2.1). We hope to continually iterate and improve Detroit Home Connect even beyond full WCAG conformance. + +### Feedback + +We welcome your feedback on the accessibility of Detroit Home Connect. Please let us know if you encounter accessibility barriers while using this website by contacting us at the email below: + +- E-mail: + +### Compatibility With Browsers and Assistive Technology + +Detroit Home Connect is compatible with NVDA and JAWS on PC and VoiceOver on Mac, and has been tested using those assistive technologies. + +In keeping with the most current industry best practice, Detroit Home Connect is not compatible with Internet Explorer, as this browser has been officially retired as of June 2022. + +### Technical Specifications +Accessibility of Detroit Home Connect relies on the following technologies to work with the particular combination of web browser and any assistive technologies or plugins installed on your computer: + +- HTML +- WAI-ARIA +- CSS +- JavaScript + +These technologies are relied upon for conformance with the accessibility standards used. + +### Limitations and Alternatives +Despite our best efforts to ensure the accessibility of Detroit Home Connect, there may be some limitations. Below is a description of some accessibility challenges that we are aware of on our site, and what we are doing to address those challenges. + +Known accessibility challenges for Detroit Home Connect: + +- **Alternative Image Text**: Alt text on listing images is automatically generated, which means that it might not exactly match the uploaded image. The images are uploaded by those that own the listing, so we cannot ensure they match the alternative text. We will remind property managers to upload images of the buildings to ensure consistency between the alternative text and image. Please reach out to the property manager, or the Detroit Home Connect support email if you encounter an issue with a listing image. +- **“Additional Housing Resources” Page**: Some of the linked pages may not be accessible, because the linked resources are third party sites, and we do not have control over their accessibility standards. Please reach out to the property manager or the Detroit Home Connect support email if you encounter an issue with the resources pages. +- **Heading elements**: Some heading elements are not consistent. + +### Assessment Approach + +The developer of Detroit Home Connect, Exygy, assessed the accessibility of Detroit Home Connect by the following approaches: + +- Self-evaluation. +- External evaluation, which included a review from accessibility expert testers with disabilities. + +--- + +### Date +This statement was updated on 21 March 2023. + + + + + +Esta es una declaración de accesibilidad del Departamento de Vivienda y Revitalización de la Ciudad de Detroit. + +### Estado de Conformidad + +Las [Pautas de Accesibilidad al Contenido Web (WCAG)](https://www.w3.org/WAI/standards-guidelines/wcag/) definen las mejores prácticas para que los diseñadores y desarrolladores mejoren la accesibilidad de los sitios web para las personas con discapacidades. Define tres niveles de conformidad: Nivel A, Nivel AA y Nivel AAA. Nuestro objetivo es ofrecer una experiencia web que logre el "Nivel AA" de conformidad con las Pautas de accesibilidad al contenido web v2.1 (WCAG 2.1). Esperamos iterar y mejorar continuamente Detroit Home Connect incluso más allá de la conformidad total con las WCAG. + +### Comentario + +Agradecemos sus comentarios sobre la accesibilidad de Detroit Home Connect. Háganos saber si encuentra barreras de accesibilidad mientras utiliza este sitio web comunicándose con nosotros al siguiente correo electrónico: + +- Correo electrónico: + +### Compatibilidad Con Navegadores y Tecnología de Asistencia + +Detroit Home Connect es compatible con NVDA y JAWS en PC y VoiceOver en Mac, y ha sido probado con esas tecnologías de asistencia. + +De acuerdo con las mejores prácticas más actuales de la industria, Detroit Home Connect no es compatible con Internet Explorer, ya que este navegador se retiró oficialmente en junio de 2022. + +### Especificaciones Técnicas +La accesibilidad de Detroit Home Connect se basa en las siguientes tecnologías para funcionar con la combinación particular de navegador web y cualquier tecnología de asistencia o complemento instalado en su computadora: + +- HTML +- WAI-ARIA +- CSS +- JavaScript + +Se confía en estas tecnologías para cumplir con los estándares de accesibilidad utilizados. + +### Limitaciones y Alternativas +A pesar de nuestros mejores esfuerzos para garantizar la accesibilidad de Detroit Home Connect, puede haber algunas limitaciones. A continuación se incluye una descripción de algunos desafíos de accesibilidad que conocemos en nuestro sitio y lo que estamos haciendo para abordar esos desafíos. + +Desafíos de accesibilidad conocidos para Detroit Home Connect: + +- **Texto de imagen alternativo**: el texto alternativo en las imágenes de la lista se genera automáticamente, lo que significa que es posible que no coincida exactamente con la imagen cargada. Las imágenes las cargan los propietarios de la lista, por lo que no podemos garantizar que coincidan con el texto alternativo. Recordaremos a los administradores de propiedades que carguen imágenes de los edificios para garantizar la coherencia entre el texto alternativo y la imagen. Comuníquese con el administrador de la propiedad o el correo electrónico de soporte de Detroit Home Connect si encuentra un problema con una imagen de listado. +- **Página “Recursos adicionales de vivienda”**: Es posible que no se pueda acceder a algunas de las páginas vinculadas, ya que los recursos vinculados son sitios de terceros y no tenemos control sobre sus estándares de accesibilidad. Comuníquese con el administrador de la propiedad o con el correo electrónico de soporte de Detroit Home Connect si tiene algún problema con las páginas de recursos. +- **Elementos de encabezado**: algunos elementos de encabezado no son consistentes. + +### Enfoque de Evaluación + +El desarrollador de Detroit Home Connect, Exygy, evaluó la accesibilidad de Detroit Home Connect mediante los siguientes enfoques: + +- Autoevaluación. +- Evaluación externa, que incluyó la revisión de testers expertos en accesibilidad con discapacidad. + +--- + +### Fecha +Esta declaración se actualizó el 21 de marzo de 2023. + + + + +هذا بيان إمكانية الوصول من دائرة الإسكان والتنشيط بمدينة ديترويت. + +### حالة التوافق + +[تحدد إرشادات الوصول إلى محتوى الويب (WCAG)](https://www.w3.org/WAI/standards-guidelines/wcag/) أفضل الممارسات للمصممين والمطورين لتحسين إمكانية الوصول إلى مواقع الويب للأشخاص ذوي الإعاقة. يحدد ثلاثة مستويات من المطابقة: المستوى A والمستوى AA والمستوى AAA. هدفنا هو تقديم تجربة ويب تحقق توافق "المستوى AA" وفقًا لإرشادات الوصول إلى محتوى الويب الإصدار 2.1 (WCAG 2.1). نأمل في تكرار وتحسين Detroit Home Connect باستمرار حتى بما يتجاوز توافق WCAG الكامل. + +### تعليق +نرحب بتعليقاتك حول إمكانية الوصول إلى Detroit Home Connect. يرجى إعلامنا إذا واجهت حواجز الوصول أثناء استخدام هذا الموقع عن طريق الاتصال بنا على البريد الإلكتروني أدناه: + +- البريد الإلكتروني: + +### التوافق مع المتصفحات والتكنولوجيا المساعدة + +يتوافق برنامج Detroit Home Connect مع NVDA و JAWS على الكمبيوتر الشخصي و VoiceOver على جهاز Mac ، وقد تم اختباره باستخدام تلك التقنيات المساعدة. + +تمشيا مع أفضل الممارسات الحالية في الصناعة ، لا يتوافق Detroit Home Connect مع Internet Explorer ، حيث تم إيقاف هذا المتصفح رسميًا اعتبارًا من يونيو 2022. + +### المواصفات الفنية +تعتمد إمكانية الوصول إلى Detroit Home Connect على التقنيات التالية للعمل مع مجموعة معينة من مستعرض الويب وأي تقنيات مساعدة أو مكونات إضافية مثبتة على جهاز الكمبيوتر الخاص بك: + +- لغة البرمجة +- WAI-ARIA +- CSS +- جافا سكريبت + +يتم الاعتماد على هذه التقنيات للتوافق مع معايير الوصول المستخدمة. + +### القيود والبدائل +على الرغم من بذلنا قصارى جهدنا لضمان إمكانية الوصول إلى Detroit Home Connect ، فقد تكون هناك بعض القيود. فيما يلي وصف لبعض تحديات إمكانية الوصول التي ندركها على موقعنا ، وما نقوم به لمواجهة هذه التحديات. + +تحديات الوصول المعروفة لـ Detroit Home Connect: + +- **نص الصورة البد**: يتم إنشاء النص البديل تلقائيًا في قائمة الصور ، مما يعني أنه قد لا يتطابق تمامًا مع الصورة التي تم تحميلها. يتم تحميل الصور من قبل أولئك الذين يملكون القائمة ، لذلك لا يمكننا التأكد من تطابقها مع النص البديل. سنذكر مديري العقارات بتحميل صور المباني لضمان الاتساق بين النص والصورة البديلين. يرجى التواصل مع مدير الممتلكات ، أو البريد الإلكتروني الخاص بدعم Detroit Home Connect إذا واجهت مشكلة في صورة قائمة. +- **صفحة "موارد الإسكان الإضافية"**: قد لا يمكن الوصول إلى بعض الصفحات المرتبطة ، لأن الموارد المرتبطة هي مواقع تابعة لجهات خارجية ، ولا نتحكم في معايير الوصول الخاصة بها. يرجى التواصل مع مدير الممتلكات أو البريد الإلكتروني الخاص بدعم Detroit Home Connect إذا واجهت مشكلة في صفحات الموارد. +- **عناصر العنوان**: بعض عناصر العنوان غير متسقة. + +### نهج التقييم + +قام مطور Detroit Home Connect ، Exygy ، بتقييم إمكانية الوصول إلى Detroit Home Connect من خلال الأساليب التالية: + +- التقييم الذاتي. +- التقييم الخارجي ، والذي تضمن مراجعة من مختبرين ذوي إعاقات ذوي خبرة في إمكانية الوصول. + +--- + +### تاريخ +تم تحديث هذا البيان في 21 مارس 2023. + + + +এটি সিটি অফ ডেট্রয়েট হাউজিং অ্যান্ড রিভাইটালাইজেশন ডিপার্টমেন্টের একটি অ্যাক্সেসিবিলিটি বিবৃতি। + +### সামঞ্জস্য অবস্থা + +[ওয়েব কনটেন্ট অ্যাক্সেসিবিলিটি নির্দেশিকা (WCAG)](https://www.w3.org/WAI/standards-guidelines/wcag/) প্রতিবন্ধী ব্যক্তিদের জন্য ওয়েবসাইটগুলির অ্যাক্সেসযোগ্যতা উন্নত করার জন্য ডিজাইনার এবং বিকাশকারীদের জন্য সর্বোত্তম অনুশীলনগুলি সংজ্ঞায়িত করে৷ এটি কনফারমেন্সের তিনটি স্তরকে সংজ্ঞায়িত করে: লেভেল A, লেভেল AA এবং লেভেল AAA। আমাদের লক্ষ্য হল একটি ওয়েব অভিজ্ঞতা প্রদান করা যা ওয়েব কন্টেন্ট অ্যাক্সেসিবিলিটি নির্দেশিকা v2.1 (WCAG 2.1) অনুযায়ী "লেভেল AA" সামঞ্জস্য অর্জন করে। আমরা সম্পূর্ণ WCAG সামঞ্জস্যের বাইরেও ডেট্রয়েট হোম কানেক্টকে ক্রমাগত পুনরাবৃত্তি এবং উন্নত করার আশা করি। + +### প্রতিক্রিয়া + +আমরা ডেট্রয়েট হোম কানেক্টের অ্যাক্সেসযোগ্যতার বিষয়ে আপনার মতামতকে স্বাগত জানাই। নীচের ইমেলে আমাদের সাথে যোগাযোগ করে এই ওয়েবসাইটটি ব্যবহার করার সময় আপনি অ্যাক্সেসযোগ্যতার বাধার সম্মুখীন হলে দয়া করে আমাদের জানান: + +ই-মেইল: + +### ব্রাউজার এবং সহায়ক প্রযুক্তির সাথে সামঞ্জস্য + +Detroit Home Connect PC-এ NVDA এবং JAWS এবং Mac-এ VoiceOver-এর সাথে সামঞ্জস্যপূর্ণ, এবং সেই সহায়ক প্রযুক্তিগুলি ব্যবহার করে পরীক্ষা করা হয়েছে। + +সবচেয়ে বর্তমান শিল্পের সর্বোত্তম অনুশীলনের সাথে তাল মিলিয়ে, Detroit Home Connect ইন্টারনেট এক্সপ্লোরারের সাথে সামঞ্জস্যপূর্ণ নয়, কারণ এই ব্রাউজারটি জুন 2022 থেকে আনুষ্ঠানিকভাবে অবসর নেওয়া হয়েছে। + +### প্রযুক্তিগত বিবরণ +ডেট্রয়েট হোম কানেক্টের অ্যাক্সেসিবিলিটি ওয়েব ব্রাউজার এবং আপনার কম্পিউটারে ইনস্টল করা যেকোনো সহায়ক প্রযুক্তি বা প্লাগইনগুলির বিশেষ সমন্বয়ের সাথে কাজ করার জন্য নিম্নলিখিত প্রযুক্তিগুলির উপর নির্ভর করে: + + +- এইচটিএমএল +- WAI-ARIA +- সিএসএস +- জাভাস্ক্রিপ্ট + +এই প্রযুক্তিগুলি ব্যবহার করা অ্যাক্সেসযোগ্যতার মানগুলির সাথে সামঞ্জস্যের জন্য নির্ভর করা হয়। + +### সীমাবদ্ধতা এবং বিকল্প +ডেট্রয়েট হোম কানেক্টের অ্যাক্সেসযোগ্যতা নিশ্চিত করার জন্য আমাদের সর্বোত্তম প্রচেষ্টা সত্ত্বেও, কিছু সীমাবদ্ধতা থাকতে পারে। নীচে কিছু অ্যাক্সেসিবিলিটি চ্যালেঞ্জগুলির একটি বিবরণ রয়েছে যা আমরা আমাদের সাইটে সচেতন এবং সেই চ্যালেঞ্জগুলি মোকাবেলায় আমরা কী করছি৷ + +ডেট্রয়েট হোম কানেক্টের জন্য পরিচিত অ্যাক্সেসিবিলিটি চ্যালেঞ্জ: + +- **বিকল্প চিত্র পাঠ্য**: তালিকাভুক্ত চিত্রগুলিতে Alt পাঠ্য স্বয়ংক্রিয়ভাবে তৈরি হয়, যার অর্থ এটি আপলোড করা চিত্রের সাথে ঠিক মেলে না। চিত্রগুলি তাদের দ্বারা আপলোড করা হয়েছে যারা তালিকার মালিক, তাই আমরা নিশ্চিত করতে পারি না যে সেগুলি বিকল্প পাঠ্যের সাথে মেলে৷ বিকল্প পাঠ্য এবং চিত্রের মধ্যে সামঞ্জস্যতা নিশ্চিত করতে আমরা সম্পত্তি পরিচালকদের বিল্ডিংয়ের ছবি আপলোড করার জন্য মনে করিয়ে দেব। যদি আপনি একটি তালিকা চিত্রের সাথে কোনো সমস্যার সম্মুখীন হন তাহলে অনুগ্রহ করে সম্পত্তি ব্যবস্থাপকের সাথে যোগাযোগ করুন বা ডেট্রয়েট হোম কানেক্ট সমর্থন ইমেলের সাথে যোগাযোগ করুন৷ +- **"অতিরিক্ত আবাসন সংস্থান" পৃষ্ঠা**: লিঙ্ক করা কিছু পৃষ্ঠা অ্যাক্সেসযোগ্য নাও হতে পারে, কারণ লিঙ্ক করা সংস্থানগুলি তৃতীয় পক্ষের সাইট, এবং তাদের অ্যাক্সেসযোগ্যতার মানগুলির উপর আমাদের নিয়ন্ত্রণ নেই৷ সম্পদের পৃষ্ঠাগুলিতে যদি আপনি কোনও সমস্যার সম্মুখীন হন তাহলে অনুগ্রহ করে সম্পত্তি ব্যবস্থাপক বা ডেট্রয়েট হোম কানেক্ট সমর্থন ইমেলের সাথে যোগাযোগ করুন। +- **শিরোনাম উপাদান**: কিছু শিরোনাম উপাদান সামঞ্জস্যপূর্ণ নয়। + +### মূল্যায়ন পদ্ধতি + +ডেট্রয়েট হোম কানেক্টের বিকাশকারী, এক্সিজি, নিম্নলিখিত পদ্ধতির মাধ্যমে ডেট্রয়েট হোম কানেক্টের অ্যাক্সেসযোগ্যতা মূল্যায়ন করেছেন: + +- স্ব মূল্যায়ন. +- বাহ্যিক মূল্যায়ন, যার মধ্যে অক্ষমতা সহ অ্যাক্সেসিবিলিটি বিশেষজ্ঞ পরীক্ষকদের কাছ থেকে একটি পর্যালোচনা অন্তর্ভুক্ত। + +--- + +### তারিখ +এই বিবৃতিটি 21 মার্চ 2023 তারিখে আপডেট করা হয়েছিল। + + + diff --git a/sites/public/src/pages/accessibility.tsx b/sites/public/src/pages/accessibility.tsx new file mode 100644 index 0000000000..9d19fdb23a --- /dev/null +++ b/sites/public/src/pages/accessibility.tsx @@ -0,0 +1,42 @@ +import React, { useEffect, useContext } from "react" +import { MarkdownSection, t } from "@bloom-housing/ui-components" +import { PageHeader } from "../../../../detroit-ui-components/src/headers/PageHeader" +import Markdown from "markdown-to-jsx" +import { PageView, pushGtmEvent, AuthContext } from "@bloom-housing/shared-helpers" +import { UserStatus } from "../lib/constants" +import Layout from "../layouts/application" +import pageContent from "../md_content/accessibility.md" +import RenderIf from "../RenderIf" + +const Accessibility = () => { + const { profile } = useContext(AuthContext) + + useEffect(() => { + pushGtmEvent({ + event: "pageView", + pageTitle: "Accessibility", + status: profile ? UserStatus.LoggedIn : UserStatus.NotLoggedIn, + }) + }, [profile]) + + const pageTitle = <>{t("pageTitle.accessibilityStatement")} + + return ( + + + + + {pageContent} + + + + ) +} + +export default Accessibility diff --git a/sites/public/src/pages/finder.tsx b/sites/public/src/pages/finder.tsx index adf22e8ab7..cdf449f008 100644 --- a/sites/public/src/pages/finder.tsx +++ b/sites/public/src/pages/finder.tsx @@ -81,7 +81,7 @@ const ProgressHeader = forwardRef( const Finder = () => { // eslint-disable-next-line @typescript-eslint/unbound-method - const { register, handleSubmit, trigger, errors, watch } = useForm() + const { register, handleSubmit, trigger, errors, watch, setValue, getValues } = useForm() const [questionIndex, setQuestionIndex] = useState(0) const [formData, setFormData] = useState([]) const [isDisclaimer, setIsDisclaimer] = useState(false) @@ -324,6 +324,8 @@ const Finder = () => { trigger={trigger} minRent={minRent} maxRent={maxRent} + setValue={setValue} + getValues={getValues} /> )} diff --git a/yarn.lock b/yarn.lock index 8c8db0bfe4..2ad2558bdf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5476,6 +5476,28 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@mswjs/cookies@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@mswjs/cookies/-/cookies-0.2.2.tgz#b4e207bf6989e5d5427539c2443380a33ebb922b" + integrity sha512-mlN83YSrcFgk7Dm1Mys40DLssI1KdJji2CMKN8eOlBqsTADYzj2+jWzsANsUTFbxDMWPD5e9bfA1RGqBpS3O1g== + dependencies: + "@types/set-cookie-parser" "^2.4.0" + set-cookie-parser "^2.4.6" + +"@mswjs/interceptors@^0.17.2": + version "0.17.8" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.17.8.tgz#2a4654b5076d0481b212d6c88f985bdbba09ce60" + integrity sha512-hjS5dy8u+Baa5r/SVAQyWZYQr8YZzpeGldNXJlZQBPjt7FQL7Acd1BnXJWmIhl62s5uh0WJNAcUMfAAcnqTchA== + dependencies: + "@open-draft/until" "^1.0.3" + "@types/debug" "^4.1.7" + "@xmldom/xmldom" "^0.8.3" + debug "^4.3.3" + headers-polyfill "^3.1.0" + outvariant "^1.2.1" + strict-event-emitter "^0.2.4" + web-encoding "^1.1.5" + "@napi-rs/triples@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@napi-rs/triples/-/triples-1.0.3.tgz#76d6d0c3f4d16013c61e45dfca5ff1e6c31ae53c" @@ -5929,6 +5951,10 @@ dependencies: "@octokit/openapi-types" "^12.11.0" +"@open-draft/until@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" + integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== "@pmmmwh/react-refresh-webpack-plugin@^0.4.3": version "0.4.3" @@ -6886,6 +6912,11 @@ dependencies: "@babel/runtime" "^7.12.5" +"@testing-library/user-event@^14.4.3": + version "14.4.3" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" + integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -7018,6 +7049,11 @@ dependencies: "@types/node" "*" +"@types/cookie@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" + integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== + "@types/cookiejar@*": version "2.1.1" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80" @@ -7031,6 +7067,13 @@ "@types/node" "*" moment ">=2.14.0" +"@types/debug@^4.1.7": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" + integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg== + dependencies: + "@types/ms" "*" + "@types/eslint-scope@^3.7.3": version "3.7.3" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224" @@ -7223,6 +7266,11 @@ jest-diff "^26.0.0" pretty-format "^26.0.0" +"@types/js-levenshtein@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5" + integrity sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g== + "@types/json-schema@*", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" @@ -7353,6 +7401,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= +"@types/ms@*": + version "0.7.31" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" + integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== + "@types/node-fetch@^2.5.7": version "2.5.10" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.10.tgz#9b4d4a0425562f9fcea70b12cb3fcdd946ca8132" @@ -7608,6 +7661,13 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" +"@types/set-cookie-parser@^2.4.0": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.2.tgz#b6a955219b54151bfebd4521170723df5e13caad" + integrity sha512-fBZgytwhYAUkj/jC/FAV4RQ5EerRup1YQsXQCh8rZfiHkc4UahC192oH0smGwsXol3cL3A5oETuAHeQHmhXM4w== + dependencies: + "@types/node" "*" + "@types/sinonjs__fake-timers@8.1.1": version "8.1.1" resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3" @@ -8097,6 +8157,11 @@ "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" +"@xmldom/xmldom@^0.8.3": + version "0.8.6" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.6.tgz#8a1524eb5bd5e965c1e3735476f0262469f71440" + integrity sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -8127,6 +8192,11 @@ "@zeit/next-css" "1.0.1" sass-loader "6.0.6" +"@zxing/text-encoding@0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b" + integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== + JSONStream@^1.0.3, JSONStream@^1.0.4: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -10151,6 +10221,14 @@ chalk@4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.1.tgz#c80b3fab28bf6371e6863325eee67e618b77e6ad" + integrity sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -10473,6 +10551,15 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + clone-deep@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.3.0.tgz#348c61ae9cdbe0edfe053d91ff4cc521d790ede8" @@ -11010,6 +11097,11 @@ cookie@0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== +cookie@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== + cookiejar@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" @@ -11632,14 +11724,7 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -debug@^4.3.4: +debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -12933,7 +13018,7 @@ events@^3.0.0: resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379" integrity sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg== -events@^3.2.0: +events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -14579,6 +14664,11 @@ graceful-fs@^4.2.9: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== +"graphql@^15.0.0 || ^16.0.0": + version "16.6.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb" + integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw== + grid-index@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/grid-index/-/grid-index-1.1.0.tgz#97f8221edec1026c8377b86446a7c71e79522ea7" @@ -14879,6 +14969,11 @@ he@1.2.0, he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +headers-polyfill@^3.0.4, headers-polyfill@^3.1.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.1.2.tgz#9a4dcb545c5b95d9569592ef7ec0708aab763fbe" + integrity sha512-tWCK4biJ6hcLqTviLXVR9DTRfYGQMXEIUj3gwJ2rZ5wO/at3XtkI4g8mCvFdUF9l1KMBNCfmNAdnahm1cgavQA== + highlight.js@^10.1.1, highlight.js@~10.4.0: version "10.4.1" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" @@ -15209,6 +15304,11 @@ image-size@1.0.0: dependencies: queue "6.0.2" +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immer@8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" @@ -15414,6 +15514,27 @@ inquirer@8.2.0: strip-ansi "^6.0.0" through "^2.3.6" +inquirer@^8.2.0: + version "8.2.5" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.5.tgz#d8654a7542c35a9b9e069d27e2df4858784d54f8" + integrity sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.21" + mute-stream "0.0.8" + ora "^5.4.1" + run-async "^2.4.0" + rxjs "^7.5.5" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + wrap-ansi "^7.0.0" + insert-module-globals@^7.0.0, insert-module-globals@^7.2.1: version "7.2.1" resolved "https://registry.yarnpkg.com/insert-module-globals/-/insert-module-globals-7.2.1.tgz#d5e33185181a4e1f33b15f7bf100ee91890d5cb3" @@ -15881,6 +16002,11 @@ is-negative-zero@^2.0.1: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== +is-node-process@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.0.1.tgz#4fc7ac3a91e8aac58175fe0578abbc56f2831b23" + integrity sha512-5IcdXuf++TTNt3oGl9EBdkvndXA8gmc4bz/Y+mdEpWh3Mcn/+kOw6hI7LD5CocqJWMzeb0I0ClndRVNdEPuJXQ== + is-number-object@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" @@ -17139,6 +17265,16 @@ jsprim@^2.0.2: array-includes "^3.1.1" object.assign "^4.1.1" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + junk@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" @@ -17357,6 +17493,13 @@ libnpmpublish@^4.0.0: semver "^7.1.3" ssri "^8.0.1" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.3.tgz#68f3005e921dafbd2a2afb48379986aa6d2579fd" @@ -18545,6 +18688,32 @@ ms@2.1.3, ms@^2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +msw@^0.46.0: + version "0.46.1" + resolved "https://registry.yarnpkg.com/msw/-/msw-0.46.1.tgz#4da4a679ada1ab25fc91dc11a210517204ce078d" + integrity sha512-yEKJcHjUbee6oeD/RDakFdr+RcdMtiREH4U4m/8eJnKpsH2kIA5DSU2wvpR1VKOBVVPyvmAIaU/OrPke8jNdTQ== + dependencies: + "@mswjs/cookies" "^0.2.2" + "@mswjs/interceptors" "^0.17.2" + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.1" + "@types/js-levenshtein" "^1.1.1" + chalk "4.1.1" + chokidar "^3.4.2" + cookie "^0.4.2" + graphql "^15.0.0 || ^16.0.0" + headers-polyfill "^3.0.4" + inquirer "^8.2.0" + is-node-process "^1.0.1" + js-levenshtein "^1.1.6" + node-fetch "^2.6.7" + outvariant "^1.3.0" + path-to-regexp "^6.2.0" + statuses "^2.0.0" + strict-event-emitter "^0.2.0" + type-fest "^2.19.0" + yargs "^17.3.1" + multer@1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4.tgz#e2bc6cac0df57a8832b858d7418ccaa8ebaf7d8c" @@ -18829,6 +18998,13 @@ node-fetch@2.6.1, node-fetch@^2.3.0, node-fetch@^2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== +node-fetch@^2.6.7: + version "2.6.9" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" + integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== + dependencies: + whatwg-url "^5.0.0" + node-forge@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" @@ -19613,6 +19789,11 @@ outpipe@^1.1.0: dependencies: shell-quote "^1.4.2" +outvariant@^1.2.1, outvariant@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.3.0.tgz#c39723b1d2cba729c930b74bf962317a81b9b1c9" + integrity sha512-yeWM9k6UPfG/nzxdaPlJkB2p08hCg4xP6Lx99F+vP8YF7xyZVfTmJjrrNalkmzudD4WFvNLVudQikqUmF8zhVQ== + overlayscrollbars@^1.13.1: version "1.13.1" resolved "https://registry.yarnpkg.com/overlayscrollbars/-/overlayscrollbars-1.13.1.tgz#0b840a88737f43a946b9d87875a2f9e421d0338a" @@ -19826,7 +20007,7 @@ pacote@^11.2.6: ssri "^8.0.1" tar "^6.1.0" -pako@~1.0.5: +pako@~1.0.2, pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== @@ -20065,6 +20246,11 @@ path-to-regexp@3.2.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.2.0.tgz#fa7877ecbc495c601907562222453c43cc204a5f" integrity sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA== +path-to-regexp@^6.2.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" + integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw== + path-type@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" @@ -22323,6 +22509,13 @@ rxjs@^7.5.1: dependencies: tslib "^2.1.0" +rxjs@^7.5.5: + version "7.8.0" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4" + integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg== + dependencies: + tslib "^2.1.0" + safe-buffer@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" @@ -22642,6 +22835,11 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= +set-cookie-parser@^2.4.6: + version "2.5.1" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz#ddd3e9a566b0e8e0862aca974a6ac0e01349430b" + integrity sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ== + set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" @@ -22652,7 +22850,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -23211,6 +23409,11 @@ static-extend@^0.1.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= +statuses@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + stdout-stream@^1.4.0: version "1.4.1" resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.1.tgz#5ac174cdd5cd726104aa0c0b2bd83815d8d535de" @@ -23330,6 +23533,13 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +strict-event-emitter@^0.2.0, strict-event-emitter@^0.2.4: + version "0.2.8" + resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.2.8.tgz#b4e768927c67273c14c13d20e19d5e6c934b47ca" + integrity sha512-KDf/ujU8Zud3YaLtMCcTI4xkZlZVIYxTLr+XIULexP+77EEVWixeXroLUXQXiVtH4XH2W7jr/3PT1v3zBuvc3A== + dependencies: + events "^3.3.0" + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" @@ -24296,6 +24506,11 @@ tr46@^2.1.0: dependencies: punycode "^2.1.1" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + traverse@~0.6.6: version "0.6.6" resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137" @@ -24581,6 +24796,11 @@ type-fest@^0.8.0, type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-fest@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -25039,6 +25259,17 @@ util@^0.11.0: dependencies: inherits "2.0.3" +util@^0.12.3: + version "0.12.5" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" + integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + which-typed-array "^1.1.2" + util@~0.10.1: version "0.10.4" resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" @@ -25277,11 +25508,25 @@ weak-map@^1.0.5: resolved "https://registry.yarnpkg.com/weak-map/-/weak-map-1.0.5.tgz#79691584d98607f5070bd3b70a40e6bb22e401eb" integrity sha1-eWkVhNmGB/UHC9O3CkDmuyLkAes= +web-encoding@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.1.5.tgz#fc810cf7667364a6335c939913f5051d3e0c4864" + integrity sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA== + dependencies: + util "^0.12.3" + optionalDependencies: + "@zxing/text-encoding" "0.9.0" + web-namespaces@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -25501,6 +25746,14 @@ whatwg-mimetype@^2.3.0: resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + whatwg-url@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" @@ -25878,6 +26131,11 @@ yargs-parser@^21.0.0: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.0.1.tgz#0267f286c877a4f0f728fceb6f8a3e4cb95c6e35" integrity sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg== +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + yargs@^13.3.0: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" @@ -25976,6 +26234,19 @@ yargs@^17.2.1: y18n "^5.0.5" yargs-parser "^21.0.0" +yargs@^17.3.1: + version "17.7.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.1.tgz#34a77645201d1a8fc5213ace787c220eabbd0967" + integrity sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"