diff --git a/lib/lambda/search.test.ts b/lib/lambda/search.test.ts index 4c0624557d..7a52b30892 100644 --- a/lib/lambda/search.test.ts +++ b/lib/lambda/search.test.ts @@ -29,7 +29,7 @@ describe("getSearchData Handler", () => { const body = JSON.parse(res.body); expect(body).toBeTruthy(); expect(body?.hits?.hits).toBeTruthy(); - expect(body?.hits?.hits?.length).toEqual(17); + expect(body?.hits?.hits?.length).toEqual(19); }); it("should handle errors during processing", async () => { diff --git a/lib/lambda/sinkChangelog.ts b/lib/lambda/sinkChangelog.ts index 03f164b0db..c2e6652020 100644 --- a/lib/lambda/sinkChangelog.ts +++ b/lib/lambda/sinkChangelog.ts @@ -6,6 +6,8 @@ import { transformUpdateValuesSchema, transformDeleteSchema, transformedUpdateIdSchema, + transformedSplitSPASchema, + transformSubmitValuesSchema, } from "./update/adminChangeSchemas"; import { getPackageChangelog } from "libs/api/package"; @@ -64,26 +66,42 @@ const processAndIndex = async ({ // Parse the kafka record's value const record = JSON.parse(decodeBase64WithUtf8(value)); - // query all changelog entries for this ID and create copies of all entries with new ID if (record.isAdminChange) { - const schema = transformDeleteSchema(offset).or( - transformUpdateValuesSchema(offset).or(transformedUpdateIdSchema), - ); + const schema = transformDeleteSchema(offset) + .or(transformUpdateValuesSchema(offset)) + .or(transformedUpdateIdSchema) + .or(transformedSplitSPASchema) + .or(transformSubmitValuesSchema); const result = schema.safeParse(record); if (result.success) { if (result.data.adminChangeType === "update-id") { + // Push doc with package being soft deleted docs.forEach((log) => { const recordOffset = log.id.split("-").at(-1); - docs.push({ ...log, id: `${result.data.id}-${recordOffset}`, packageId: result.data.id, }); }); + // Get all changelog entries for this ID and create copies of all entries with new ID + const packageChangelogs = await getPackageChangelog(result.data.idToBeUpdated); + + packageChangelogs.hits.hits.forEach((log) => { + const recordOffset = log._id.split("-").at(-1); + docs.push({ + ...log._source, + id: `${result.data.id}-${recordOffset}`, + packageId: result.data.id, + }); + }); + } else if (result.data.adminChangeType === "split-spa") { + // Push doc with new split package + docs.push(result.data); + // Get all changelog entries for this ID and create copies of all entries with new ID const packageChangelogs = await getPackageChangelog(result.data.idToBeUpdated); packageChangelogs.hits.hits.forEach((log) => { diff --git a/lib/lambda/sinkMainProcessors.ts b/lib/lambda/sinkMainProcessors.ts index a3e5c25952..3b7c713e3e 100644 --- a/lib/lambda/sinkMainProcessors.ts +++ b/lib/lambda/sinkMainProcessors.ts @@ -7,12 +7,16 @@ import { deleteAdminChangeSchema, updateValuesAdminChangeSchema, updateIdAdminChangeSchema, + splitSPAAdminChangeSchema, + extendSubmitNOSOAdminSchema, } from "./update/adminChangeSchemas"; const removeDoubleQuotesSurroundingString = (str: string) => str.replace(/^"|"$/g, ""); const adminRecordSchema = deleteAdminChangeSchema .or(updateValuesAdminChangeSchema) - .or(updateIdAdminChangeSchema); + .or(updateIdAdminChangeSchema) + .or(splitSPAAdminChangeSchema) + .or(extendSubmitNOSOAdminSchema); type OneMacRecord = { id: string; diff --git a/lib/lambda/submit/getNextSplitSPAId.ts b/lib/lambda/submit/getNextSplitSPAId.ts new file mode 100644 index 0000000000..4d72e6ade1 --- /dev/null +++ b/lib/lambda/submit/getNextSplitSPAId.ts @@ -0,0 +1,33 @@ +import { search } from "libs/opensearch-lib"; +import { getDomainAndNamespace } from "libs/utils"; +import { cpocs } from "lib/packages/shared-types/opensearch"; + +export const getNextSplitSPAId = async (spaId: string) => { + const { domain, index } = getDomainAndNamespace("main"); + const query = { + size: 50, + query: { + regexp: { + "id.keyword": `${spaId}-[A-Z]`, + }, + }, + }; + // Get existing split SPAs for this package id + const { hits } = await search(domain, index, query); + // Extract suffixes from existing split SPA IDs + // If there are no split SPAs yet, start at the ASCII character before "A" ("@") + // Convert to ASCII char codes to get latest suffix + const latestSuffixCharCode = hits.hits.reduce((maxCharCode: number, hit: cpocs.ItemResult) => { + const suffix = hit._source.id.toString().split("-").at(-1) ?? "@"; + return Math.max(maxCharCode, suffix.charCodeAt(0)); + }, "@".charCodeAt(0)); + + // Increment letter but not past "Z" + // "A-Z" is 65-90 in ASCII + if (latestSuffixCharCode >= 90) { + throw new Error("This package can't be further split."); + } + const nextSuffix = String.fromCharCode(latestSuffixCharCode + 1); + + return `${spaId}-${nextSuffix}`; +}; diff --git a/lib/lambda/submit/submitSplitSPA.test.ts b/lib/lambda/submit/submitSplitSPA.test.ts new file mode 100644 index 0000000000..39474d9669 --- /dev/null +++ b/lib/lambda/submit/submitSplitSPA.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handler } from "./submitSplitSPA"; +import { APIGatewayEvent } from "node_modules/shared-types"; +import { + getRequestContext, + TEST_CHIP_SPA_ITEM, + TEST_MED_SPA_ITEM, + TEST_SPA_ITEM_TO_SPLIT, +} from "mocks"; + +describe("handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.topicName = "test-topic"; + }); + + it("should return 400 if event body is missing", async () => { + const event = {} as APIGatewayEvent; + + const result = await handler(event); + + expect(result?.statusCode).toEqual(400); + }); + + it("should return 404 if package ID is not found", async () => { + const invalidPackage = { + body: JSON.stringify({ packageId: "MD-25-9999" }), + } as unknown as APIGatewayEvent; + + const result = await handler(invalidPackage); + + expect(result?.statusCode).toEqual(404); + }); + + it("should throw an error if not Medicaid SPA", async () => { + const chipSPAPackage = { + body: JSON.stringify({ packageId: TEST_CHIP_SPA_ITEM._id }), + requestContext: getRequestContext(), + } as APIGatewayEvent; + + const result = await handler(chipSPAPackage); + + expect(result.body).toEqual(JSON.stringify({ message: "Record must be a Medicaid SPA" })); + }); + + it("should return 400 if package ID not provided", async () => { + const invalidPackage = { + body: JSON.stringify({}), + } as unknown as APIGatewayEvent; + + const result = await handler(invalidPackage); + + expect(result?.statusCode).toEqual(400); + }); + + it("should fail to split a package with no topic name", async () => { + delete process.env.topicName; + + const noActionevent = { + body: JSON.stringify({ + packageId: TEST_MED_SPA_ITEM._id, + }), + } as APIGatewayEvent; + + await expect(handler(noActionevent)).rejects.toThrow("Topic name is not defined"); + }); + + it("should create a split SPA", async () => { + const medSPAPackage = { + body: JSON.stringify({ packageId: TEST_MED_SPA_ITEM._id }), + } as unknown as APIGatewayEvent; + + const result = await handler(medSPAPackage); + expect(result?.statusCode).toEqual(200); + }); + + it("should fail if unable to get next split SPA suffix", async () => { + const medSPAPackage = { + body: JSON.stringify({ packageId: TEST_SPA_ITEM_TO_SPLIT }), + } as unknown as APIGatewayEvent; + + await expect(handler(medSPAPackage)).rejects.toThrow("This package can't be further split."); + }); +}); diff --git a/lib/lambda/submit/submitSplitSPA.ts b/lib/lambda/submit/submitSplitSPA.ts new file mode 100644 index 0000000000..831bbd7cb7 --- /dev/null +++ b/lib/lambda/submit/submitSplitSPA.ts @@ -0,0 +1,105 @@ +import { response } from "libs/handler-lib"; +import { APIGatewayEvent } from "aws-lambda"; +import { getPackage } from "libs/api/package"; +import { produceMessage } from "libs/api/kafka"; +import { ItemResult } from "shared-types/opensearch/main"; +import { events } from "shared-types/events"; +import { getNextSplitSPAId } from "./getNextSplitSPAId"; +import { z } from "zod"; + +/* +EXAMPLE EVENT JSON: +{ + "body": { + "packageId": "MD-25-9999", + } +} +*/ + +const sendSubmitSplitSPAMessage = async (currentPackage: ItemResult) => { + const topicName = process.env.topicName as string; + if (!topicName) { + throw new Error("Topic name is not defined"); + } + const newId = await getNextSplitSPAId(currentPackage._id); + if (!newId) { + throw new Error("Error getting next Split SPA Id"); + } + + // ID and changeMade are excluded; the rest of the object has to be spread into the new package + const { + id: _id, + changeMade: _changeMade, + origin: _origin, + ...remainingFields + } = currentPackage._source; + + await produceMessage( + topicName, + newId, + JSON.stringify({ + id: newId, + idToBeUpdated: currentPackage._id, + ...remainingFields, + makoChangedDate: Date.now(), + changedDate: Date.now(), + origin: "OneMAC", + changeMade: "OneMAC Admin has added a package to OneMAC.", + changeReason: "Per request from CMS, this package was added to OneMAC.", + mockEvent: "new-medicaid-submission", + isAdminChange: true, + adminChangeType: "split-spa", + }), + ); + + return response({ + statusCode: 200, + body: { message: `New Medicaid Split SPA ${newId} has been created.` }, + }); +}; + +const splitSPAEventBodySchema = z.object({ + packageId: events["new-medicaid-submission"].baseSchema.shape.id, +}); + +export const handler = async (event: APIGatewayEvent) => { + if (!event.body) { + return response({ + statusCode: 400, + body: { message: "Event body required" }, + }); + } + try { + const body = typeof event.body === "string" ? JSON.parse(event.body) : event.body; + const { packageId } = splitSPAEventBodySchema.parse(body); + + const currentPackage = await getPackage(packageId); + if (!currentPackage || currentPackage.found == false) { + return response({ + statusCode: 404, + body: { message: "No record found for the given id" }, + }); + } + + if (currentPackage._source.authority !== "Medicaid SPA") { + return response({ + statusCode: 400, + body: { message: "Record must be a Medicaid SPA" }, + }); + } + + return sendSubmitSplitSPAMessage(currentPackage); + } catch (err) { + console.error("Error has occured modifying package:", err); + if (err instanceof z.ZodError) { + return response({ + statusCode: 400, + body: { message: err.errors }, + }); + } + return response({ + statusCode: 500, + body: { message: err.message || "Internal Server Error" }, + }); + } +}; diff --git a/lib/lambda/update/adminChangeSchemas.ts b/lib/lambda/update/adminChangeSchemas.ts index 4404d97749..f13bc10fd2 100644 --- a/lib/lambda/update/adminChangeSchemas.ts +++ b/lib/lambda/update/adminChangeSchemas.ts @@ -23,6 +23,14 @@ export const updateIdAdminChangeSchema = z }) .and(z.record(z.string(), z.any())); +export const splitSPAAdminChangeSchema = z + .object({ + id: z.string(), + adminChangeType: z.literal("split-spa"), + idToBeUpdated: z.string(), + }) + .and(z.record(z.string(), z.any())); + export const transformDeleteSchema = (offset: number) => deleteAdminChangeSchema.transform((data) => ({ ...data, @@ -41,6 +49,8 @@ export const transformUpdateValuesSchema = (offset: number) => timestamp: Date.now(), })); +const currentTime = Date.now(); + export const transformedUpdateIdSchema = updateIdAdminChangeSchema.transform((data) => ({ ...data, event: "update-id", @@ -48,3 +58,47 @@ export const transformedUpdateIdSchema = updateIdAdminChangeSchema.transform((da id: `${data.id}`, timestamp: Date.now(), })); + +export const transformedSplitSPASchema = splitSPAAdminChangeSchema.transform((data) => ({ + ...data, + event: "split-spa", + packageId: data.id, + id: `${data.id}`, + timestamp: currentTime, + makoChangedDate: currentTime, + changedDate: currentTime, +})); + +export const submitNOSOAdminSchema = z.object({ + id: z.string(), + authority: z.string(), + status: z.string(), + submitterEmail: z.string(), + submitterName: z.string(), + adminChangeType: z.literal("NOSO"), + mockEvent: z.string(), + changeMade: z.string(), + changeReason: z.string(), +}); + +export const extendSubmitNOSOAdminSchema = submitNOSOAdminSchema.extend({ + packageId: z.string(), + origin: z.string(), + makoChangedDate: z.number(), + changedDate: z.number(), + statusDate: z.number(), + isAdminChange: z.boolean(), + state: z.string(), + event: z.string(), + stateStatus: z.string(), + cmsStatus: z.string(), +}); + +export const transformSubmitValuesSchema = extendSubmitNOSOAdminSchema.transform((data) => ({ + ...data, + adminChangeType: "NOSO", + event: "NOSO", + id: data.id, + packageId: data.id, + timestamp: Date.now(), +})); diff --git a/lib/lambda/update/submitNOSO.test.ts b/lib/lambda/update/submitNOSO.test.ts new file mode 100644 index 0000000000..9a46bcfa86 --- /dev/null +++ b/lib/lambda/update/submitNOSO.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handler } from "./submitNOSO"; +import { APIGatewayEvent } from "node_modules/shared-types"; + +import { NOT_EXISTING_ITEM_ID, TEST_ITEM_ID } from "mocks"; + +vi.mock("libs/handler-lib", () => ({ + response: vi.fn((data) => data), +})); + +describe("handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.topicName = "test-topic"; + }); + + it("should return 400 if event body is missing", async () => { + const event = {} as APIGatewayEvent; + const result = await handler(event); + const expectedResult = { statusCode: 400, body: { message: "Event body required" } }; + + expect(result).toStrictEqual(expectedResult); + }); + + it("should return 400 if package ID is not found", async () => { + const noActionevent = { + body: JSON.stringify({ packageId: "123", changeReason: "Nunya", authority: "test" }), + } as APIGatewayEvent; + + const resultPackage = await handler(noActionevent); + + expect(resultPackage?.statusCode).toBe(400); + }); + it("should return 400 if admingChangeType is not found", async () => { + const noApackageEvent = { + body: JSON.stringify({ action: "123", changeReason: "Nunya" }), + } as APIGatewayEvent; + + const resultAction = await handler(noApackageEvent); + + expect(resultAction?.statusCode).toBe(400); + }); + it("should return 400 if existing item is entered", async () => { + const noActionevent = { + body: JSON.stringify({ + id: TEST_ITEM_ID, + adminChangeType: "NOSO", + authority: "SPA", + submitterEmail: "test@email.com", + submitterName: "Name", + status: "submitted", + changeMade: "change", + mockEvent: "mock-event", + changeReason: "reason", + }), + } as APIGatewayEvent; + + const result = await handler(noActionevent); + + const expectedResult = { + statusCode: 400, + body: { message: `Package with id: ${TEST_ITEM_ID} already exists.` }, + }; + expect(result).toStrictEqual(expectedResult); + }); + + it("should submit a new item", async () => { + const validItem = { + body: JSON.stringify({ + id: NOT_EXISTING_ITEM_ID, + authority: "Medicaid SPA", + status: "submitted", + submitterEmail: "test@email.com", + submitterName: "Name", + adminChangeType: "NOSO", + changeMade: "change", + mockEvent: "mock-event", + changeReason: "reason", + }), + } as APIGatewayEvent; + + const result = await handler(validItem); + + const expectedResult = { + statusCode: 200, + body: { message: `${NOT_EXISTING_ITEM_ID} has been submitted.` }, + }; + expect(result).toStrictEqual(expectedResult); + }); + + it("should fail to create a package ID with no topic name", async () => { + process.env.topicName = ""; + const validItem = { + body: JSON.stringify({ + id: NOT_EXISTING_ITEM_ID, + authority: "Medicaid SPA", + status: "submitted", + submitterEmail: "test@email.com", + submitterName: "Name", + adminChangeType: "NOSO", + mockEvent: "mock-event", + changeMade: "change", + changeReason: "reason", + }), + } as APIGatewayEvent; + + const result = await handler(validItem); + const expectedResult = { + statusCode: 500, + body: { message: "Topic name is not defined" }, + }; + expect(result).toStrictEqual(expectedResult); + }); +}); diff --git a/lib/lambda/update/submitNOSO.ts b/lib/lambda/update/submitNOSO.ts new file mode 100644 index 0000000000..9eaf66762a --- /dev/null +++ b/lib/lambda/update/submitNOSO.ts @@ -0,0 +1,115 @@ +import { response } from "libs/handler-lib"; +import { APIGatewayEvent } from "aws-lambda"; +import { produceMessage } from "libs/api/kafka"; +import { getPackage } from "libs/api/package"; +import { ItemResult } from "shared-types/opensearch/main"; +import { submitNOSOAdminSchema } from "./adminChangeSchemas"; +import { z } from "zod"; + +import { getStatus } from "shared-types"; + +/* +EXAMPLE EVENT JSON: + +{ + "body": { + "id": "CO-34304.R00.01", + "authority": "1915(c)", + "status": "Submitted", + "submitterEmail": "george@example.com", + "submitterName": "George Harrison", + "adminChangeType": "NOSO", + "mockEvent": "app-k", //needed for future actions + "changeMade": "CO-34304.R00.01 added to OneMAC.Package not originally submitted in OneMAC. Contact your CPOC to verify the initial submission documents.", + "changeReason": "Per request from CMS, this package was added to OneMAC." + } +} + +*/ + +interface submitMessageType { + id: string; + authority: string; + status: string; + submitterEmail: string; + submitterName: string; + adminChangeType: string; + stateStatus: string; + cmsStatus: string; +} + +const sendSubmitMessage = async (item: submitMessageType) => { + const topicName = process.env.topicName as string; + if (!topicName) { + throw new Error("Topic name is not defined"); + } + + const currentTime = Date.now(); + + await produceMessage( + topicName, + item.id, + JSON.stringify({ + ...item, + packageId: item.id, + origin: "SEATool", + isAdminChange: true, + adminChangeType: "NOSO", + description: null, + event: "NOSO", + state: item.id.substring(0, 2), + makoChangedDate: currentTime, + changedDate: currentTime, + statusDate: currentTime, + }), + ); + + return response({ + statusCode: 200, + body: { message: `${item.id} has been submitted.` }, + }); +}; + +export const handler = async (event: APIGatewayEvent) => { + if (!event.body) { + return response({ + statusCode: 400, + body: { message: "Event body required" }, + }); + } + + try { + const item = submitNOSOAdminSchema.parse( + typeof event.body === "string" ? JSON.parse(event.body) : event.body, + ); + + const { stateStatus, cmsStatus } = getStatus(item.status); + // check if it already exsists + const currentPackage: ItemResult | undefined = await getPackage(item.id); + + if (currentPackage && currentPackage.found == true) { + // if it exists and has origin OneMAC we shouldn't override it + if (currentPackage._source.origin === "OneMAC") { + return response({ + statusCode: 400, + body: { message: `Package with id: ${item.id} already exists.` }, + }); + } + } + + return await sendSubmitMessage({ ...item, stateStatus, cmsStatus }); + } catch (err) { + console.error("Error has occured submitting package:", err); + if (err instanceof z.ZodError) { + return response({ + statusCode: 400, + body: { message: err.errors }, + }); + } + + return response({ + statusCode: 500, + body: { message: err.message || "Internal Server Error" }, + }); + } +}; diff --git a/lib/lambda/update/updatePackage.ts b/lib/lambda/update/updatePackage.ts index 62eba97ad4..038ba8760a 100644 --- a/lib/lambda/update/updatePackage.ts +++ b/lib/lambda/update/updatePackage.ts @@ -101,14 +101,12 @@ const sendUpdateIdMessage = async ({ origin: _origin, ...remainingFields } = currentPackage._source; - if (updatedId === currentPackage._id) { return response({ statusCode: 400, body: { message: "New ID required to update package" }, }); } - // check if a package with this new ID already exists const packageExists = await getPackage(updatedId); if (packageExists?.found) { @@ -132,6 +130,7 @@ const sendUpdateIdMessage = async ({ } await sendDeleteMessage(currentPackage._id); + await produceMessage( topicName, updatedId, diff --git a/lib/packages/shared-types/opensearch/changelog/index.ts b/lib/packages/shared-types/opensearch/changelog/index.ts index f6137b4981..94bcdf48f2 100644 --- a/lib/packages/shared-types/opensearch/changelog/index.ts +++ b/lib/packages/shared-types/opensearch/changelog/index.ts @@ -82,7 +82,9 @@ export type Document = Omit & | "withdraw-rai" | "update-values" | "update-id" - | "delete"; + | "delete" + | "split-spa" + | "NOSO"; }; export type Response = Res; diff --git a/lib/packages/shared-types/opensearch/main/index.ts b/lib/packages/shared-types/opensearch/main/index.ts index 131c807321..489efe900d 100644 --- a/lib/packages/shared-types/opensearch/main/index.ts +++ b/lib/packages/shared-types/opensearch/main/index.ts @@ -66,6 +66,7 @@ export type Document = AppkDocument & adminChangeType?: string; changeMade?: string; idToBeUpdated?: string; + mockEvent?: string; }; export type Response = Res; diff --git a/lib/stacks/api.ts b/lib/stacks/api.ts index 6f5221e018..cf43450d76 100644 --- a/lib/stacks/api.ts +++ b/lib/stacks/api.ts @@ -293,6 +293,28 @@ export class Api extends cdk.NestedStack { indexNamespace, }, }, + { + id: "submitSplitSPA", + entry: join(__dirname, "../lambda/submit/submitSplitSPA.ts"), + environment: { + topicName, + brokerString, + osDomain: `https://${openSearchDomainEndpoint}`, + indexNamespace, + }, + provisionedConcurrency: 2, + }, + { + id: "submitNOSO", + entry: join(__dirname, "../lambda/update/submitNOSO.ts"), + environment: { + dbInfoSecretName, + topicName, + brokerString, + osDomain: `https://${openSearchDomainEndpoint}`, + indexNamespace, + }, + }, { id: "getSystemNotifs", entry: join(__dirname, "../lambda/getSystemNotifs.ts"), diff --git a/mocks/data/items.ts b/mocks/data/items.ts index 4089efd6ea..aff5c6723a 100644 --- a/mocks/data/items.ts +++ b/mocks/data/items.ts @@ -13,6 +13,8 @@ export const NOT_FOUND_ITEM_ID = "MD-0004.R00.00"; export const NOT_EXISTING_ITEM_ID = "MD-11-0000"; export const TEST_ITEM_ID = "MD-0005.R01.00"; export const TEST_SPA_ITEM_ID = "MD-11-2020"; +export const TEST_SPA_ITEM_TO_SPLIT = "MD-12-2020"; +export const TEST_SPLIT_SPA_ITEM_ID = "MD-12-2020-Z"; export const EXISTING_ITEM_TEMPORARY_EXTENSION_ID = "MD-0005.R01.TE00"; export const HI_TEST_ITEM_ID = "HI-0000.R00.00"; export const CAPITATED_INITIAL_ITEM_ID = "SS-2234.R00.00"; @@ -181,6 +183,52 @@ const items: Record = { authority: "Medicaid SPA", }, }, + [TEST_SPA_ITEM_TO_SPLIT]: { + _id: TEST_SPA_ITEM_TO_SPLIT, + found: true, + _source: { + id: TEST_SPA_ITEM_TO_SPLIT, + seatoolStatus: SEATOOL_STATUS.APPROVED, + actionType: "New", + state: "MD", + origin: "OneMAC", + changedDate: "2024-11-26T18:17:21.557Z", + changelog: [ + { + _id: `${TEST_SPA_ITEM_TO_SPLIT}-001`, + _source: { + id: `${TEST_SPA_ITEM_TO_SPLIT}-0001`, + event: "new-medicaid-submission", + packageId: TEST_SPA_ITEM_TO_SPLIT, + }, + }, + ], + authority: "Medicaid SPA", + }, + }, + [TEST_SPLIT_SPA_ITEM_ID]: { + _id: TEST_SPLIT_SPA_ITEM_ID, + found: true, + _source: { + id: TEST_SPLIT_SPA_ITEM_ID, + seatoolStatus: SEATOOL_STATUS.APPROVED, + actionType: "New", + state: "MD", + origin: "OneMAC", + changedDate: "2024-11-26T18:17:21.557Z", + changelog: [ + { + _id: `${TEST_SPLIT_SPA_ITEM_ID}-001`, + _source: { + id: `${TEST_SPLIT_SPA_ITEM_ID}-0001`, + event: "new-medicaid-submission", + packageId: TEST_SPLIT_SPA_ITEM_ID, + }, + }, + ], + authority: "Medicaid SPA", + }, + }, [EXISTING_ITEM_TEMPORARY_EXTENSION_ID]: { _id: EXISTING_ITEM_TEMPORARY_EXTENSION_ID, found: true, @@ -547,16 +595,16 @@ export const TEST_TEMP_EXT_ITEM = items[ export const itemList = Object.values(items); -export const getFilteredItemList = (filters: string[]) => { - return itemList.filter((item) => filters.includes(item?._source?.authority || "")); -}; +export const getFilteredItemList = (filters: string[]): opensearch.main.ItemResult[] => + itemList + .filter((item) => filters.includes(item?._source?.authority || "")) + .map((item) => item as opensearch.main.ItemResult); export const docList = Object.values(items).map( (item) => (item?._source || {}) as opensearch.main.Document, ); -export const getFilteredDocList = (filters: string[]) => { - return docList.filter((item) => filters.includes(item?.authority || "")); -}; +export const getFilteredDocList = (filters: string[]): opensearch.main.Document[] => + docList.filter((item) => filters.includes(item?.authority || "")); export default items; diff --git a/mocks/handlers/api/search.ts b/mocks/handlers/api/search.ts index cbc80f5f72..e697f28402 100644 --- a/mocks/handlers/api/search.ts +++ b/mocks/handlers/api/search.ts @@ -1,13 +1,13 @@ import { http, HttpResponse, PathParams } from "msw"; import { getFilteredItemList } from "../../data/items"; -import { getFilterValueAsStringArray } from "../search.utils"; +import { getFilterValueAsStringArray, getAggregations } from "../search.utils"; import { SearchQueryBody } from "../../index.d"; const defaultApiSearchHandler = http.post( "https://test-domain.execute-api.us-east-1.amazonaws.com/mocked-tests/search/:index", async ({ params, request }) => { const { index } = params; - const { query } = await request.json(); + const { query, aggs } = await request.json(); const must = query?.bool?.must; @@ -18,6 +18,11 @@ const defaultApiSearchHandler = http.post( []; const itemList = getFilteredItemList(authorityValues); + const isSpa = + authorityValues.includes("CHIP SPA") || authorityValues.includes("Medicaid SPA"); + + const aggregations = getAggregations(aggs, isSpa); + return HttpResponse.json({ took: 3, timed_out: false, @@ -35,6 +40,7 @@ const defaultApiSearchHandler = http.post( max_score: 1, hits: itemList, }, + aggregations, }); } diff --git a/mocks/handlers/opensearch/main.ts b/mocks/handlers/opensearch/main.ts index 08e5295805..ec4dd7c1a3 100644 --- a/mocks/handlers/opensearch/main.ts +++ b/mocks/handlers/opensearch/main.ts @@ -58,6 +58,24 @@ const defaultOSMainSearchHandler = http.post( const must = query?.bool?.must; const mustTerms = getTermKeys(must); + const regexpQueries = query?.regexp; + + let itemHits: TestItemResult[] = Object.values(items) || []; + + if (regexpQueries) { + for (const fieldName in regexpQueries) { + const filteredFieldName = fieldName.replace(".keyword", "") as keyof TestItemResult; + const regexPattern = String(regexpQueries[fieldName]); + const regex = new RegExp(regexPattern); + + itemHits = itemHits.filter((item) => { + const fieldValue = + item[filteredFieldName] ?? + item._source?.[filteredFieldName as keyof typeof item._source]; + return fieldValue && regex.test(String(fieldValue)); + }); + } + } const appkParentIdValue = getTermValues(must, "appkParentId.keyword") || getTermValues(must, "appkParentId"); @@ -119,8 +137,6 @@ const defaultOSMainSearchHandler = http.post( } } - let itemHits: TestItemResult[] = Object.values(items) || []; - if (itemHits.length > 0) { mustTerms.forEach((term) => { const filterValue = getTermValues(must, term); diff --git a/mocks/handlers/search.utils.ts b/mocks/handlers/search.utils.ts index 9290c7ea03..57714e73b4 100644 --- a/mocks/handlers/search.utils.ts +++ b/mocks/handlers/search.utils.ts @@ -1,4 +1,4 @@ -import { QueryContainer, TermQuery, TermsQuery, TestHit } from ".."; +import { QueryContainer, TermQuery, TermsQuery, TestHit, QueryAggs, TestAggResult } from ".."; export const getFilterValue = ( query: QueryContainer | QueryContainer[] | undefined, @@ -210,3 +210,87 @@ export const filterItemsByTerm = ( (hit as TestHit)?._source && matchFilter(hit._source as D, filterTerm, filterValue), ); }; + +export const getAggregations = ( + aggs: Record, + isSpa: boolean = true, +): TestAggResult => { + const aggregations: TestAggResult = {}; + + if (aggs?.["stateStatus.keyword"]) { + aggregations["stateStatus.keyword"] = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: "Submitted", doc_count: 56 }, + { key: "Withdrawal Requested", doc_count: 3 }, + ], + }; + } + + if (aggs?.["origin.keyword"]) { + aggregations["origin.keyword"] = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: "OneMAC", doc_count: 59 }], + }; + } + + if (aggs?.["state.keyword"]) { + aggregations["state.keyword"] = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: "CO", doc_count: 5 }, + { key: "MD", doc_count: 41 }, + { key: "OH", doc_count: 12 }, + { key: "VA", doc_count: 1 }, + ], + }; + } + + if (aggs?.["leadAnalystName.keyword"]) { + aggregations["leadAnalystName.keyword"] = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: "George Harrison", doc_count: 1 }, + { key: "Padma Boggarapu", doc_count: 19 }, + { key: "Shante Abarabar", doc_count: 1 }, + ], + }; + } + + if (aggs?.["actionType.keyword"]) { + aggregations["actionType.keyword"] = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: isSpa + ? [{ key: "Amend", doc_count: 31 }] + : [ + { key: "Amend", doc_count: 20 }, + { key: "Extend", doc_count: 10 }, + { key: "Initial", doc_count: 7 }, + { key: "New", doc_count: 15 }, + { key: "Renew", doc_count: 7 }, + ], + }; + } + + if (aggs?.["authority.keyword"]) { + aggregations["authority.keyword"] = { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: isSpa + ? [ + { key: "CHIP SPA", doc_count: 31 }, + { key: "Medicaid SPA", doc_count: 90 }, + ] + : [ + { key: "1915(b)", doc_count: 48 }, + { key: "1915(c)", doc_count: 11 }, + ], + }; + } + return aggregations; +}; diff --git a/mocks/index.d.ts b/mocks/index.d.ts index f4ab69605e..8233fef1fa 100644 --- a/mocks/index.d.ts +++ b/mocks/index.d.ts @@ -16,6 +16,8 @@ export type TestItemResult = DeepPartial; export type TestMainDocument = TestItemResult["_source"]; +export type TestAggResult = opensearch.AggResult; + export type TestAppkItemResult = Omit; export type TestAppkDocument = TestAppkItemResult["_source"]; @@ -124,13 +126,17 @@ type BoolQuery = QueryBase & { should?: QueryContainer | QueryContainer[]; }; +export type QueryAggs = opensearch.main.Aggs; + export type SearchQueryBody = { from?: number; search?: string; query?: { bool: BoolQuery; match_all?: MatchAllQuery; + regexp?: Record; }; + aggs?: Record; size?: number; sortDirection?: string; sortField?: string; diff --git a/react-app/src/components/Opensearch/main/Filtering/Drawer/Filterable/DateRange.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Drawer/Filterable/DateRange.test.tsx index 92483c7597..5a00491a3a 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Drawer/Filterable/DateRange.test.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Drawer/Filterable/DateRange.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { screen, render, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { FilterableDateRange, DATE_FORMAT, DATE_DISPLAY_FORMAT } from "./DateRange"; -import { format, startOfQuarter, startOfMonth, sub, endOfDay, startOfDay, getDate } from "date-fns"; +import { format, startOfQuarter, startOfMonth, sub, endOfDay, startOfDay, setDate } from "date-fns"; import { UTCDate } from "@date-fns/utc"; describe("FilterableDateRange", () => { @@ -119,7 +119,7 @@ describe("FilterableDateRange", () => { const user = userEvent.setup(); render(); await user.click(screen.getByText("Pick a date")); - await user.click(screen.getByRole("button", { name: "Month To Date" })); + await user.click(screen.getByRole("button", { name: "Quarter To Date" })); expect(onChange).toHaveBeenCalledWith({ gte: (startOfDay(startOfQuarter(new UTCDate())) as UTCDate).toISOString(), lte: (endOfDay(new UTCDate()) as UTCDate).toISOString(), @@ -130,8 +130,13 @@ describe("FilterableDateRange", () => { const user = userEvent.setup(); render(); await user.click(screen.getByText("Pick a date")); - const pickers = screen.getAllByRole("grid"); - const firstDay = within(pickers[0]) + + const picker = screen.getAllByRole("grid", { + name: format(new Date(), "MMMM yyyy"), + })[0]; + // there will be two date pickers for the month but we only + // want the first one because we want the beginning of the month + const firstDay = within(picker) .getAllByRole("gridcell", { name: "1" }) .find((day) => !day.getAttribute("disabled")); await user.click(firstDay); @@ -142,10 +147,12 @@ describe("FilterableDateRange", () => { }); }); - it("should handle the first day set to the month and clicking today", async () => { + it("should handle the first day set to the month and clicking the 15th", async () => { const user = userEvent.setup(); - const firstDay = startOfMonth(new UTCDate()) as UTCDate; - console.log({ firstDay, formatted: format(firstDay, DATE_FORMAT) }); + const today = new UTCDate(); + const firstDay = startOfMonth(today) as UTCDate; + const fifteenthDay = setDate(today, 15) as UTCDate; + render( { />, ); await user.click(screen.getByText(format(firstDay, DATE_DISPLAY_FORMAT))); - const pickers = screen.getAllByRole("grid"); - const todayDate = startOfDay(new UTCDate()); - const todayDay = within(pickers[0]) - .getAllByRole("gridcell", { name: `${getDate(todayDate)}` }) + + const picker = screen.getAllByRole("grid", { + name: format(new Date(), "MMMM yyyy"), + })[0]; + // there will be two date pickers for the month but we only + // want the first one because we want the middle of the month + const fifteenthDayButton = within(picker) + .getAllByRole("gridcell", { name: "15" }) .find((day) => !day.getAttribute("disabled")); - expect(todayDay).not.toBeNull(); + expect(fifteenthDayButton).not.toBeNull(); - if (todayDay) { - await user.click(todayDay); + if (fifteenthDayButton) { + await user.click(fifteenthDayButton); expect(onChange).toHaveBeenLastCalledWith({ gte: (startOfDay(firstDay) as UTCDate).toISOString(), - lte: (endOfDay(todayDate) as UTCDate).toISOString(), + lte: (endOfDay(fifteenthDay) as UTCDate).toISOString(), }); } }); + it("should handle deselecting", async () => { + const user = userEvent.setup(); + const firstDay = startOfMonth(new UTCDate()) as UTCDate; + render( + , + ); + await user.click( + screen.getByText( + `${format(firstDay, DATE_DISPLAY_FORMAT)} - ${format(firstDay, DATE_DISPLAY_FORMAT)}`, + ), + ); + + const pickers = screen.getAllByRole("grid"); + const firstDayBtn = within(pickers[0]) + .getAllByRole("gridcell", { name: "1" }) + .find((day) => !day.getAttribute("disabled")); + await user.click(firstDayBtn); + + expect(onChange).toHaveBeenCalledWith({ gte: undefined, lte: undefined }); + }); }); diff --git a/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx new file mode 100644 index 0000000000..e441e2a514 --- /dev/null +++ b/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx @@ -0,0 +1,454 @@ +import { describe, expect, it } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; +import LZ from "lz-string"; +import { OsFilterDrawer } from "./index"; +import { FilterDrawerProvider } from "../FilterProvider"; +import { opensearch } from "shared-types"; + +const routes = [ + { + path: "/dashboard", + element: ( + + + + ), + }, +]; +const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; + +const setup = ( + filters: opensearch.Filterable[], + tab: "spas" | "waivers", +) => { + const user = userEvent.setup(); + const queryString = LZ.compressToEncodedURIComponent( + JSON.stringify({ + filters, + search: "", + tab, + pagination: { + number: 0, + size: 25, + }, + sort: { + field: "submissionDate", + order: "desc", + }, + code, + }), + ); + const rendered = renderWithQueryClientAndMemoryRouter(, routes, { + initialEntries: [ + { + pathname: "/dashboard", + search: `code=${code}&os=${queryString}`, + }, + ], + }); + return { + user, + ...rendered, + }; +}; + +describe("OsFilterDrawer", () => { + describe("SPA Filters", () => { + it("should display the drawer closed initially", () => { + setup([], "spas"); + expect(screen.getByRole("button", { name: "Filters" }).getAttribute("data-state")).toEqual( + "closed", + ); + }); + it("should handle clicking the Filter button and opening the drawer", async () => { + const { user } = setup([], "spas"); + await user.click(screen.getByRole("button", { name: "Filters" })); + expect(screen.getByRole("heading", { name: "Filters", level: 4 })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reset" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Close" })).toBeInTheDocument(); + + [ + "State", + "Authority", + "Status", + "RAI Withdraw Enabled", + "Initial Submission", + "Final Disposition", + "Latest Package Activity", + "Formal RAI Response", + "CPOC Name", + "Submission Source", + ].forEach((label) => { + const heading = screen.queryByRole("heading", { name: label, level: 3 }); + expect(heading).toBeInTheDocument(); + expect(heading.nextElementSibling.getAttribute("data-state")).toEqual("closed"); + }); + }); + it("should handle clicking the Reset button", async () => { + const { user } = setup( + [ + { + label: "State", + field: "state.keyword", + component: "multiSelect", + prefix: "must", + type: "terms", + value: ["MD"], + }, + { + label: "Authority", + field: "authority.keyword", + component: "multiCheck", + prefix: "must", + type: "terms", + value: ["CHIP SPA"], + }, + { + label: "RAI Withdraw Enabled", + field: "raiWithdrawEnabled", + component: "boolean", + prefix: "must", + type: "match", + value: true, + }, + { + label: "Final Disposition", + field: "finalDispositionDate", + component: "dateRange", + prefix: "must", + type: "range", + value: { + gte: "2025-01-01T00:00:00.000Z", + lte: "2025-01-01T23:59:59.999Z", + }, + }, + ], + "spas", + ); + await user.click(screen.getByRole("button", { name: "Filters" })); + const state = screen.getByRole("heading", { + name: "State", + level: 3, + }).parentElement; + expect(state.getAttribute("data-state")).toEqual("open"); + expect(within(state).queryByLabelText("Remove MD")).toBeInTheDocument(); + + const authority = screen.getByRole("heading", { + name: "Authority", + level: 3, + }).parentElement; + expect(authority.getAttribute("data-state")).toEqual("open"); + expect(within(authority).queryByLabelText("CHIP SPA").getAttribute("data-state")).toEqual( + "checked", + ); + + const raiWithdraw = screen.getByRole("heading", { + name: "RAI Withdraw Enabled", + level: 3, + }).parentElement; + expect(raiWithdraw.getAttribute("data-state")).toEqual("open"); + expect(within(raiWithdraw).queryByLabelText("Yes").getAttribute("data-state")).toEqual( + "checked", + ); + + const finalDisposition = screen.getByRole("heading", { + name: "Final Disposition", + level: 3, + }).parentElement; + expect(finalDisposition.getAttribute("data-state")).toEqual("open"); + expect( + within(finalDisposition).queryByText("Jan 01, 2025 - Jan 01, 2025"), + ).toBeInTheDocument(); + + await user.click(screen.queryByRole("button", { name: "Reset" })); + + expect(state.getAttribute("data-state")).toEqual("open"); + expect(within(state).queryByLabelText("Remove MD")).toBeNull(); + + expect(authority.getAttribute("data-state")).toEqual("open"); + expect(within(authority).queryByLabelText("CHIP SPA").getAttribute("data-state")).toEqual( + "unchecked", + ); + + expect(raiWithdraw.getAttribute("data-state")).toEqual("open"); + expect(within(raiWithdraw).queryByLabelText("Yes").getAttribute("data-state")).toEqual( + "unchecked", + ); + + expect(finalDisposition.getAttribute("data-state")).toEqual("open"); + expect(within(finalDisposition).queryByText("Pick a date")).toBeInTheDocument(); + }); + it("should handle clicking the Close button", async () => { + const { user } = setup([], "spas"); + await user.click(screen.getByRole("button", { name: "Filters" })); + expect(screen.getByRole("heading", { name: "Filters", level: 4 })).toBeInTheDocument(); + await user.click(screen.queryByRole("button", { name: "Close" })); + expect(screen.getByRole("button", { name: "Filters" }).getAttribute("data-state")).toEqual( + "closed", + ); + }); + describe("State filter", () => { + it("should handle expanding the State filter", async () => { + const { user } = setup([], "spas"); + await user.click(screen.getByRole("button", { name: "Filters" })); + + const state = screen.getByRole("heading", { + name: "State", + level: 3, + }).parentElement; + expect(state.getAttribute("data-state")).toEqual("closed"); + await user.click(screen.getByRole("button", { name: "State" })); + expect(state.getAttribute("data-state")).toEqual("open"); + const combo = screen.getByRole("combobox"); + expect(combo).toBeInTheDocument(); + }); + it("should display a state filter if one is selected", async () => { + const { user } = setup( + [ + { + label: "State", + field: "state.keyword", + component: "multiSelect", + prefix: "must", + type: "terms", + value: ["MD"], + }, + ], + "spas", + ); + await user.click(screen.getByRole("button", { name: "Filters" })); + + const state = screen.getByRole("heading", { + name: "State", + level: 3, + }).parentElement; + expect(state.getAttribute("data-state")).toEqual("open"); + + const combo = screen.getByRole("combobox"); + expect(combo).toBeInTheDocument(); + expect(screen.queryByLabelText("Remove MD")).toBeInTheDocument(); + }); + }); + describe("Authority filter", () => { + it("should handle expanding the Authority filter", async () => { + const { user } = setup([], "spas"); + await user.click(screen.getByRole("button", { name: "Filters" })); + + const authority = screen.getByRole("heading", { + name: "Authority", + level: 3, + }).parentElement; + expect(authority.getAttribute("data-state")).toEqual("closed"); + await user.click(screen.getByRole("button", { name: "Authority" })); + expect(authority.getAttribute("data-state")).toEqual("open"); + expect(screen.queryByRole("button", { name: "Select All" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Clear" })).toBeInTheDocument(); + + const chip = screen.queryByLabelText("CHIP SPA"); + expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("data-state")).toEqual("unchecked"); + + const med = screen.queryByLabelText("Medicaid SPA"); + expect(med).toBeInTheDocument(); + expect(med.getAttribute("data-state")).toEqual("unchecked"); + }); + it("should display spa filter authorities if one filter is selected", async () => { + const { user } = setup( + [ + { + label: "Authority", + field: "authority.keyword", + component: "multiCheck", + prefix: "must", + type: "terms", + value: ["CHIP SPA"], + }, + ], + "spas", + ); + await user.click(screen.getByRole("button", { name: "Filters" })); + + // it should already be expanded if there is a filter already set + expect( + screen + .getByRole("heading", { + name: "Authority", + level: 3, + }) + .parentElement.getAttribute("data-state"), + ).toEqual("open"); + + const chip = screen.queryByLabelText("CHIP SPA"); + expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("data-state")).toEqual("checked"); + + const med = screen.queryByLabelText("Medicaid SPA"); + expect(med).toBeInTheDocument(); + expect(med.getAttribute("data-state")).toEqual("unchecked"); + }); + it("should handle selecting a filter", async () => { + const { user } = setup([], "spas"); + await user.click(screen.getByRole("button", { name: "Filters" })); + + await user.click(screen.getByRole("button", { name: "Authority" })); + + const chip = screen.queryByLabelText("CHIP SPA"); + expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("data-state")).toEqual("unchecked"); + + const med = screen.queryByLabelText("Medicaid SPA"); + expect(med).toBeInTheDocument(); + expect(med.getAttribute("data-state")).toEqual("unchecked"); + + await user.click(chip); + expect(chip.getAttribute("data-state")).toEqual("checked"); + }); + it("should handle clicking Select All", async () => { + const { user } = setup([], "spas"); + await user.click(screen.getByRole("button", { name: "Filters" })); + + await user.click(screen.getByRole("button", { name: "Authority" })); + + const chip = screen.queryByLabelText("CHIP SPA"); + expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("data-state")).toEqual("unchecked"); + + const med = screen.queryByLabelText("Medicaid SPA"); + expect(med).toBeInTheDocument(); + expect(med.getAttribute("data-state")).toEqual("unchecked"); + + await user.click(screen.queryByRole("button", { name: "Select All" })); + expect(chip.getAttribute("data-state")).toEqual("checked"); + expect(med.getAttribute("data-state")).toEqual("checked"); + }); + it("should handle clicking Clear", async () => { + const { user } = setup( + [ + { + label: "Authority", + field: "authority.keyword", + component: "multiCheck", + prefix: "must", + type: "terms", + value: ["CHIP SPA", "Medicaid SPA"], + }, + ], + "spas", + ); + await user.click(screen.getByRole("button", { name: "Filters" })); + + const chip = screen.queryByLabelText("CHIP SPA"); + expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("data-state")).toEqual("checked"); + + const med = screen.queryByLabelText("Medicaid SPA"); + expect(med).toBeInTheDocument(); + expect(med.getAttribute("data-state")).toEqual("checked"); + + await user.click(screen.queryByRole("button", { name: "Clear" })); + expect(chip.getAttribute("data-state")).toEqual("unchecked"); + expect(med.getAttribute("data-state")).toEqual("unchecked"); + }); + }); + }); + describe("Waiver Filters", () => { + it("should display the drawer closed initially", () => { + setup([], "waivers"); + expect(screen.getByRole("button", { name: "Filters" }).getAttribute("data-state")).toEqual( + "closed", + ); + }); + it("should open the drawer and show all the filters, if you click the Filter button", async () => { + const { user } = setup([], "waivers"); + await user.click(screen.getByRole("button", { name: "Filters" })); + expect(screen.getByRole("heading", { name: "Filters", level: 4 })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reset" })).toBeInTheDocument(); + + [ + "State", + "Authority", + "Action Type", + "Status", + "RAI Withdraw Enabled", + "Initial Submission", + "Final Disposition", + "Latest Package Activity", + "Formal RAI Response", + "CPOC Name", + "Submission Source", + ].forEach((label) => { + const heading = screen.queryByRole("heading", { name: label, level: 3 }); + expect(heading).toBeInTheDocument(); + expect(heading.nextElementSibling.getAttribute("data-state")).toEqual("closed"); + }); + }); + describe("Authority filter", () => { + it("should handle clicking the Authority filter", async () => { + const { user } = setup([], "waivers"); + await user.click(screen.getByRole("button", { name: "Filters" })); + + const authority = screen.getByRole("heading", { + name: "Authority", + level: 3, + }).parentElement; + expect(authority.getAttribute("data-state")).toEqual("closed"); + await user.click(screen.getByRole("button", { name: "Authority" })); + expect(authority.getAttribute("data-state")).toEqual("open"); + expect(screen.queryByRole("button", { name: "Select All" })).toBeVisible(); + expect(screen.queryByRole("button", { name: "Clear" })); + + const chip = screen.queryByLabelText("1915(b)"); + expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("data-state")).toEqual("unchecked"); + + const med = screen.queryByLabelText("1915(c)"); + expect(med).toBeInTheDocument(); + expect(med.getAttribute("data-state")).toEqual("unchecked"); + }); + it("should display waivers filter authorities if one filter is selected", async () => { + const { user } = setup( + [ + { + label: "Authority", + field: "authority.keyword", + component: "multiCheck", + prefix: "must", + type: "terms", + value: ["1915(b)"], + }, + ], + "waivers", + ); + await user.click(screen.getByRole("button", { name: "Filters" })); + + // it should already be expanded if there is a filter already set + expect( + screen + .getByRole("heading", { + name: "Authority", + level: 3, + }) + .parentElement.getAttribute("data-state"), + ).toEqual("open"); + + const chip = screen.queryByLabelText("1915(b)"); + expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("data-state")).toEqual("checked"); + + const med = screen.queryByLabelText("1915(c)"); + expect(med).toBeInTheDocument(); + expect(med.getAttribute("data-state")).toEqual("unchecked"); + }); + }); + }); + it("should show for filters for an invalid tab", async () => { + // @ts-expect-error + const { user } = setup([], "invalid"); + await user.click(screen.getByRole("button", { name: "Filters" })); + expect(screen.getAllByRole("button").length).toEqual(2); + expect(screen.getByRole("button", { name: "Reset" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); + }); +}); diff --git a/react-app/src/components/Opensearch/main/Filtering/Export/Export.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Export/Export.test.tsx index d0e3af4193..b9133bce89 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Export/Export.test.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Export/Export.test.tsx @@ -1,18 +1,98 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, test, vi, beforeEach } from "vitest"; -import { OsExportData } from "@/components"; +import { describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; +import { getFilteredDocList } from "mocks"; +import { BLANK_VALUE } from "@/consts"; +import { OsExportData, OsTableColumn, FilterDrawerProvider } from "@/components"; +import LZ from "lz-string"; +import { ExportToCsv } from "export-to-csv"; -vi.mock("@/components/Opensearch/main/useOpensearch.ts", () => ({ - useOsUrl: vi.fn(), -})); +const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; +const columns: OsTableColumn[] = [ + { + props: { className: "w-[150px]" }, + field: "id.keyword", + label: "SPA ID", + locked: true, + transform: (data) => data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, + { + field: "raiReceivedDate", + label: "Formal RAI Response", + cell: (data) => data.raiReceivedDate ?? BLANK_VALUE, + }, + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, +]; + +const setup = (disabled?: boolean) => { + const user = userEvent.setup(); + const queryString = LZ.compressToEncodedURIComponent( + JSON.stringify({ + filters: [], + search: "", + tab: "spas", + pagination: { + number: 0, + size: 25, + }, + sort: { + field: "submissionDate", + order: "desc", + }, + code, + }), + ); + const rendered = renderWithQueryClientAndMemoryRouter( + , + [ + { + path: "/dashboard", + element: ( + + + + ), + }, + ], + { + initialEntries: [ + { + pathname: "/dashboard", + search: `code=${code}&os=${queryString}`, + }, + ], + }, + ); + return { + user, + ...rendered, + }; +}; describe("Tooltip component within export button", () => { - beforeEach(() => { - render(); - }); + it("Tooltip content hidden when not hovering", async () => { + setup(true); - test("Tooltip content hidden when not hovering", async () => { const tooltipTrigger = screen.queryByTestId("tooltip-trigger"); expect(tooltipTrigger).toBeInTheDocument(); @@ -20,15 +100,31 @@ describe("Tooltip component within export button", () => { expect(tooltipContent).not.toBeInTheDocument(); }); - test("Tooltip content shown on hover", async () => { + it("Tooltip content shown on hover", async () => { + const { user } = setup(true); + const tooltipTrigger = screen.queryByTestId("tooltip-trigger"); expect(tooltipTrigger).toBeTruthy(); - expect(tooltipTrigger).toBeDisabled(); - if (tooltipTrigger) userEvent.hover(tooltipTrigger); + if (tooltipTrigger) user.hover(tooltipTrigger); await waitFor(() => screen.getByTestId("tooltip-content")); expect(screen.queryAllByText("No records available")[0]).toBeVisible(); }); + + it("should export on click if button is enabled", async () => { + const spy = vi.spyOn(ExportToCsv.prototype, "generateCsv").mockImplementation(() => {}); + const expected = getFilteredDocList(["CHIP SPA", "Medicaid SPA"]).map((doc) => ({ + Authority: doc.authority, + "SPA ID": doc.id, + State: doc.state, + })); + + const { user } = setup(false); + + await user.click(screen.queryByTestId("tooltip-trigger")); + + expect(spy).toHaveBeenCalledWith(expected); + }); }); diff --git a/react-app/src/components/Opensearch/main/Filtering/Filtering.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Filtering.test.tsx index 89e3601ccd..3f3511668a 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Filtering.test.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Filtering.test.tsx @@ -1,69 +1,233 @@ -import { useState } from "react"; -import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { screen, within } from "@testing-library/react"; +import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; import userEvent from "@testing-library/user-event"; -import { describe, expect, test, beforeEach } from "vitest"; -import { type OsTableColumn, VisibilityPopover } from "../index"; +import { BLANK_VALUE } from "@/consts"; +import LZ from "lz-string"; +import { OsFiltering, OsTableColumn, OsProvider, FilterDrawerProvider } from "@/components"; +import { getFilteredItemList, getFilteredDocList } from "mocks"; +import { opensearch } from "shared-types"; +import { ExportToCsv } from "export-to-csv"; -const MockOsFilteringWrapper = () => { - const [columns, setColumns] = useState([ - { - field: "state.keyword", - label: "State", - cell: () => "", - }, - { - field: "authority.keyword", - label: "Authority", - cell: () => "", - }, +const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; +const items: opensearch.Hit[] = getFilteredItemList([ + "CHIP SPA", + "Medicaid SPA", +]).map((item) => ({ ...item, found: undefined }) as opensearch.Hit); +const defaultHits: opensearch.Hits = { + hits: items, + max_score: 5, + total: { value: items.length, relation: "eq" }, +}; +const defaultColumns: OsTableColumn[] = [ + { + props: { className: "w-[150px]" }, + field: "id.keyword", + label: "SPA ID", + locked: true, + transform: (data) => data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, +]; + +const setup = ( + columns: OsTableColumn[], + onToggle: (field: string) => void, + disabled?: boolean, + hits: opensearch.Hits = defaultHits, +) => { + const user = userEvent.setup(); + const queryString = LZ.compressToEncodedURIComponent( + JSON.stringify({ + filters: [], + search: "", + tab: "spas", + pagination: { + number: 0, + size: 25, + }, + sort: { + field: "submissionDate", + order: "desc", + }, + code, + }), + ); + const rendered = renderWithQueryClientAndMemoryRouter( + , + [ + { + path: "/dashboard", + element: ( + + + + + + ), + }, + ], { - field: "stateStatus.keyword", - label: "Status", - hidden: true, - cell: () => "", + initialEntries: [ + { + pathname: "/dashboard", + search: `code=${code}&os=${queryString}`, + }, + ], }, - ]); - - const onToggle = (field: string) => { - setColumns((state) => { - return state?.map((S) => { - if (S.field !== field) return S; - return { ...S, hidden: !S.hidden }; - }); - }); - }; - - return ( - !COL.locked || COL.field)} - onItemClick={onToggle} - hiddenColumns={columns.filter((COL) => COL.hidden === true)} - /> ); + return { + user, + ...rendered, + }; }; describe("Visibility button", () => { - beforeEach(() => { - render(); + it("should display the filtering buttons", async () => { + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, false); + + const search = screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"); + expect(search).toBeInTheDocument(); + expect(search).toBeEnabled(); + + expect(screen.queryByRole("button", { name: "Columns" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Filters" })).toBeInTheDocument(); + + const exportBtn = screen.queryByRole("button", { name: "Export" }); + expect(exportBtn).toBeInTheDocument(); + expect(exportBtn).toBeEnabled(); }); - test("Visibility button should show number of hidden columns if any", async () => { - expect(screen.getByText("Columns (1 hidden)")).toBeInTheDocument(); - await userEvent.click(screen.getByText("Columns (1 hidden)")); + it("should display filtering button with hidden columns", async () => { + const onToggle = vi.fn(); + setup( + [ + ...defaultColumns, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ], + onToggle, + false, + ); - const stateColumnMenuItem = screen.getByText("State"); - await userEvent.click(stateColumnMenuItem); + const search = screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"); + expect(search).toBeInTheDocument(); + expect(search).toBeEnabled(); - expect(screen.getByText("Columns (2 hidden)")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Columns (1 hidden)" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Filters" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Export" })).toBeInTheDocument(); }); - test("Visibility button text should not show number if no hidden columns", async () => { - expect(screen.getByText("Columns (1 hidden)")).toBeInTheDocument(); - await userEvent.click(screen.getByText("Columns (1 hidden)")); + it("should display the filtering buttons with disabled search", async () => { + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, true); + + const search = screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"); + expect(search).toBeInTheDocument(); + expect(search).toBeDisabled(); + + expect(screen.queryByRole("button", { name: "Columns" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Filters" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Export" })).toBeInTheDocument(); + }); + + it("should display the filtering buttons with Export disabled", async () => { + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, false, { + hits: [], + max_score: 5, + total: { value: 0, relation: "eq" }, + }); + + expect( + screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Columns" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Filters" })).toBeInTheDocument(); + + const exportBtn = screen.queryByRole("button", { name: "Export" }); + expect(exportBtn).toBeInTheDocument(); + expect(exportBtn).toBeDisabled(); + }); + + it("should handle searching", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, false); + + const search = screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"); + await user.type(search, "testing[Enter]"); + expect(search).toHaveValue("testing"); + }); + + it("should handle clicking the Columns button", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, false); + + expect(screen.queryByRole("dialog")).toBeNull(); + await user.click(screen.queryByRole("button", { name: "Columns" })); + const columns = screen.queryByRole("dialog"); + expect(columns).toBeInTheDocument(); + expect(within(columns).getByText("SPA ID")).toBeInTheDocument(); + expect(within(columns).getByText("State")).toBeInTheDocument(); + expect(within(columns).getByText("Authority")).toBeInTheDocument(); + }); + + it("should handle clicking the Filters button", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, false); + + const filters = screen.getByRole("button", { name: "Filters" }); + expect(filters.getAttribute("data-state")).toEqual("closed"); + await user.click(filters); + expect(screen.getByRole("heading", { name: "Filters", level: 4 })).toBeInTheDocument(); + }); - const statusColumnMenuItem = screen.getByText("Status"); - await userEvent.click(statusColumnMenuItem); + it("should handle clicking the Export button", async () => { + const spy = vi.spyOn(ExportToCsv.prototype, "generateCsv").mockImplementation(() => {}); + const expected = getFilteredDocList(["CHIP SPA", "Medicaid SPA"]).map((doc) => ({ + Authority: doc.authority, + "SPA ID": doc.id, + State: doc.state, + })); + const user = userEvent.setup(); + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, false); - expect(screen.getByText("Columns")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Export" })); + expect(spy).toHaveBeenCalledWith(expected); }); }); diff --git a/react-app/src/components/Opensearch/main/Settings/Visibility.test.tsx b/react-app/src/components/Opensearch/main/Settings/Visibility.test.tsx new file mode 100644 index 0000000000..1ec3240036 --- /dev/null +++ b/react-app/src/components/Opensearch/main/Settings/Visibility.test.tsx @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { VisibilityPopover } from "@/components"; +import { BLANK_VALUE } from "@/consts"; + +describe("VisibilityPopover", () => { + it("should display the number of hidden columns", () => { + render( + data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ]} + onItemClick={vi.fn()} + hiddenColumns={[ + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ]} + />, + ); + expect(screen.getByRole("button", { name: "Columns (1 hidden)" })).toBeInTheDocument(); + }); + + it("should display no number if no columns are hidden", () => { + render( + data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, + ]} + onItemClick={vi.fn()} + hiddenColumns={[]} + />, + ); + expect(screen.getByRole("button", { name: "Columns" })).toBeInTheDocument(); + }); + + it("should should skip columns without field", async () => { + const user = userEvent.setup(); + const onItemClick = vi.fn(); + render( + data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ]} + onItemClick={onItemClick} + hiddenColumns={[ + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ]} + />, + ); + await user.click(screen.getByRole("button", { name: "Columns (1 hidden)" })); + + expect(within(screen.getByRole("dialog")).queryByText("Authority")).toBeNull(); + }); + + it("should handle hiding a column", async () => { + const user = userEvent.setup(); + const onItemClick = vi.fn(); + render( + data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ]} + onItemClick={onItemClick} + hiddenColumns={[ + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ]} + />, + ); + await user.click(screen.getByRole("button", { name: "Columns (1 hidden)" })); + + const stateColumnMenuItem = within(screen.getByRole("dialog")).getByText("State"); + await user.click(stateColumnMenuItem.parentElement); + + expect(onItemClick).toHaveBeenCalledWith("state.keyword"); + }); + + it("should handle unhiding a column", async () => { + const user = userEvent.setup(); + const onItemClick = vi.fn(); + render( + data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ]} + onItemClick={onItemClick} + hiddenColumns={[ + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, + ]} + />, + ); + await user.click(screen.getByRole("button", { name: "Columns (1 hidden)" })); + + const stateColumnMenuItem = within(screen.getByRole("dialog")).getByText("Submission Source"); + await user.click(stateColumnMenuItem.parentElement); + + expect(onItemClick).toHaveBeenCalledWith("origin.keyword"); + }); +}); diff --git a/react-app/src/components/Opensearch/main/Table/index.test.tsx b/react-app/src/components/Opensearch/main/Table/index.test.tsx new file mode 100644 index 0000000000..0cc935085e --- /dev/null +++ b/react-app/src/components/Opensearch/main/Table/index.test.tsx @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; +import userEvent from "@testing-library/user-event"; +import { BLANK_VALUE } from "@/consts"; +import LZ from "lz-string"; +import { opensearch } from "shared-types"; +import { OsTable, OsTableColumn, OsProvider, FilterDrawerProvider } from "@/components"; +import { getFilteredItemList } from "mocks"; + +const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; +const items: opensearch.Hit[] = getFilteredItemList([ + "CHIP SPA", + "Medicaid SPA", +]).map((item) => ({ ...item, found: undefined }) as opensearch.Hit); +const defaultHits: opensearch.Hits = { + hits: items, + max_score: 5, + total: { value: items.length, relation: "eq" }, +}; +const defaultColumns: OsTableColumn[] = [ + { + props: { className: "w-[150px]" }, + field: "id.keyword", + label: "SPA ID", + locked: true, + transform: (data) => data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, + { + field: "raiReceivedDate", + label: "Formal RAI Response", + cell: (data) => data.raiReceivedDate ?? BLANK_VALUE, + }, + { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, + }, +]; + +const setup = ( + columns: OsTableColumn[], + onToggle: (field: string) => void, + hits: opensearch.Hits, +) => { + const user = userEvent.setup(); + const queryString = LZ.compressToEncodedURIComponent( + JSON.stringify({ + filters: [], + search: "", + tab: "spas", + pagination: { + number: 0, + size: 25, + }, + sort: { + field: "submissionDate", + order: "desc", + }, + code, + }), + ); + const rendered = renderWithQueryClientAndMemoryRouter( + , + [ + { + path: "/dashboard", + element: ( + + + + + + ), + }, + ], + { + initialEntries: [ + { + pathname: "/dashboard", + search: `code=${code}&os=${queryString}`, + }, + ], + }, + ); + return { + user, + ...rendered, + }; +}; + +describe("", () => { + it("should display the table with values", () => { + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, defaultHits); + + // Check that the correct column headers appear + expect(screen.getAllByRole("columnheader").length).toEqual(4); + expect(screen.getByText("SPA ID", { selector: "th>div" })); + expect(screen.getByText("State", { selector: "th>div" })); + expect(screen.getByText("Authority", { selector: "th>div" })); + expect(screen.getByText("Formal RAI Response", { selector: "th>div" })); + + // Check that the correct amount rows appear + expect(screen.getAllByRole("row").length).toEqual(items.length + 1); // add 1 for header + }); + + it("should display the table with no values", () => { + const onToggle = vi.fn(); + setup(defaultColumns, onToggle, { + hits: [], + max_score: 5, + total: { value: 0, relation: "eq" }, + }); + + // Check that the correct column headers appear + expect(screen.getAllByRole("columnheader").length).toEqual(4); + expect(screen.getByText("SPA ID", { selector: "th>div" })); + expect(screen.getByText("State", { selector: "th>div" })); + expect(screen.getByText("Authority", { selector: "th>div" })); + expect(screen.getByText("Formal RAI Response", { selector: "th>div" })); + + expect(screen.getByText("No Results Found")).toBeInTheDocument(); + expect( + screen.getByText("Adjust your search and filter to find what you are looking for."), + ).toBeInTheDocument(); + + // Check that the correct amount rows appear + expect(screen.getAllByRole("row").length).toEqual(2); + // one row for the header and one for the no results text + }); +}); diff --git a/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx b/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx index cf7360d428..4e6ec7da9c 100644 --- a/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx +++ b/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx @@ -101,10 +101,17 @@ export const UploadSubsequentDocuments = () => { return ; } - const originalSubmissionEvent = (submission._source.changelog ?? []).reduce( + let originalSubmissionEvent = (submission._source.changelog ?? []).reduce( (acc, { _source }) => (_source?.event ? _source?.event : acc), null, ); + if (originalSubmissionEvent === "NOSO") { + originalSubmissionEvent = submission._source.mockEvent; + } + + if (originalSubmissionEvent === "split-spa") { + originalSubmissionEvent = submission._source.mockEvent; + } const schema: SchemaWithEnforcableProps | undefined = formSchemas[originalSubmissionEvent]; diff --git a/react-app/src/features/package/admin-changes/index.tsx b/react-app/src/features/package/admin-changes/index.tsx index 798ae11832..777dae2395 100644 --- a/react-app/src/features/package/admin-changes/index.tsx +++ b/react-app/src/features/package/admin-changes/index.tsx @@ -49,7 +49,6 @@ export const AC_LegacyAdminChange: FC = (props) = export const AC_Update: FC = () => { return

Coming Soon

; }; - export const AdminChange: FC = (props) => { const [label, Content] = useMemo(() => { switch (props.event) { @@ -59,12 +58,16 @@ export const AdminChange: FC = (props) => { } return ["Disable Formal RAI Response Withdraw", AC_WithdrawDisabled]; } + case "NOSO": + return [props.changeType || "Package Added", AC_LegacyAdminChange]; case "legacy-admin-change": return [props.changeType || "Manual Update", AC_LegacyAdminChange]; + case "split-spa": + return ["Package Added", AC_LegacyAdminChange]; default: return [BLANK_VALUE, AC_Update]; } - }, [props.actionType, props.changeType]); + }, [props.event, props.changeType, props.raiWithdrawEnabled]); return (