From f0484bd504b3b2a470260b5680741be18c9f881d Mon Sep 17 00:00:00 2001 From: ailZhou <127151429+ailZhou@users.noreply.github.com> Date: Tue, 19 Nov 2024 12:11:33 -0500 Subject: [PATCH] Admin Banner (#58) --- serverless-compose.yml | 1 + .../app-api/handlers/banners/create.test.ts | 77 ++++++++ services/app-api/handlers/banners/create.ts | 51 +++++ .../app-api/handlers/banners/delete.test.ts | 70 +++++++ services/app-api/handlers/banners/delete.ts | 17 ++ .../app-api/handlers/banners/fetch.test.ts | 71 +++++++ services/app-api/handlers/banners/fetch.ts | 10 + services/app-api/libs/param-lib.ts | 10 + services/app-api/serverless.yml | 38 ++++ services/app-api/storage/banners.test.ts | 71 +++++++ services/app-api/storage/banners.ts | 38 ++++ .../app-api/storage/dynamo/dynamodb-lib.ts | 2 - services/app-api/testing/setupJest.ts | 4 + services/app-api/types/banner.ts | 9 + services/app-api/utils/authorization.ts | 4 + services/app-api/utils/constants.ts | 1 + services/app-api/utils/tests/mockBanner.ts | 9 + services/database/serverless.yml | 24 ++- .../ui-src/src/components/app/AppRoutes.tsx | 50 +++-- .../banners/AdminBannerProvider.tsx | 127 +++++++++++++ .../src/components/banners/Banner.test.tsx | 19 ++ .../ui-src/src/components/banners/Banner.tsx | 17 ++ .../src/components/banners/PreviewBanner.tsx | 18 ++ .../components/forms/AdminBannerForm.test.tsx | 97 ++++++++++ .../src/components/forms/AdminBannerForm.tsx | 119 ++++++++++++ services/ui-src/src/components/index.ts | 10 + .../components/pages/Admin/AdminPage.test.tsx | 159 ++++++++++++++++ .../src/components/pages/Admin/AdminPage.tsx | 174 ++++++++++++++++++ .../ui-src/src/components/report/Elements.tsx | 2 +- services/ui-src/src/constants.ts | 3 + services/ui-src/src/types/banners.ts | 19 ++ services/ui-src/src/types/index.ts | 1 + services/ui-src/src/types/states.ts | 19 +- .../utils/api/requestMethods/banner.test.ts | 30 +++ .../src/utils/api/requestMethods/banner.ts | 36 ++++ services/ui-src/src/utils/index.ts | 1 + services/ui-src/src/utils/state/useStore.ts | 43 ++++- .../ui-src/src/utils/testing/mockBanner.tsx | 9 + .../ui-src/src/utils/testing/setupJest.tsx | 32 +++- services/ui-src/src/verbiage/errors.ts | 19 ++ services/ui-src/src/verbiage/pages/admin.ts | 6 + 41 files changed, 1487 insertions(+), 30 deletions(-) create mode 100644 services/app-api/handlers/banners/create.test.ts create mode 100644 services/app-api/handlers/banners/create.ts create mode 100644 services/app-api/handlers/banners/delete.test.ts create mode 100644 services/app-api/handlers/banners/delete.ts create mode 100644 services/app-api/handlers/banners/fetch.test.ts create mode 100644 services/app-api/handlers/banners/fetch.ts create mode 100644 services/app-api/storage/banners.test.ts create mode 100644 services/app-api/storage/banners.ts create mode 100644 services/app-api/types/banner.ts create mode 100644 services/app-api/utils/tests/mockBanner.ts create mode 100644 services/ui-src/src/components/banners/AdminBannerProvider.tsx create mode 100644 services/ui-src/src/components/banners/Banner.test.tsx create mode 100644 services/ui-src/src/components/banners/Banner.tsx create mode 100644 services/ui-src/src/components/banners/PreviewBanner.tsx create mode 100644 services/ui-src/src/components/forms/AdminBannerForm.test.tsx create mode 100644 services/ui-src/src/components/forms/AdminBannerForm.tsx create mode 100644 services/ui-src/src/components/pages/Admin/AdminPage.test.tsx create mode 100644 services/ui-src/src/components/pages/Admin/AdminPage.tsx create mode 100644 services/ui-src/src/types/banners.ts create mode 100644 services/ui-src/src/utils/api/requestMethods/banner.test.ts create mode 100644 services/ui-src/src/utils/api/requestMethods/banner.ts create mode 100644 services/ui-src/src/utils/testing/mockBanner.tsx create mode 100644 services/ui-src/src/verbiage/pages/admin.ts diff --git a/serverless-compose.yml b/serverless-compose.yml index 78e34e7b..6230bcb5 100644 --- a/serverless-compose.yml +++ b/serverless-compose.yml @@ -13,6 +13,7 @@ services: app-api: path: services/app-api params: + BannerTableName: ${database.BannerTableName} QmReportTableName: ${database.QmReportTableName} # wave 3: depends on many diff --git a/services/app-api/handlers/banners/create.test.ts b/services/app-api/handlers/banners/create.test.ts new file mode 100644 index 00000000..7989eb48 --- /dev/null +++ b/services/app-api/handlers/banners/create.test.ts @@ -0,0 +1,77 @@ +import { StatusCodes } from "../../libs/response-lib"; +import { proxyEvent } from "../../testing/proxyEvent"; +import { APIGatewayProxyEvent, UserRoles } from "../../types/types"; +import { canWriteBanner } from "../../utils/authorization"; +import { createBanner } from "./create"; +import { error } from "../../utils/constants"; + +jest.mock("../../utils/authentication", () => ({ + authenticatedUser: jest.fn().mockResolvedValue({ + role: UserRoles.ADMIN, + state: "PA", + }), +})); + +jest.mock("../../utils/authorization", () => ({ + canWriteBanner: jest.fn().mockReturnValue(true), +})); + +jest.mock("../../storage/banners", () => ({ + putBanner: jest.fn().mockReturnValue({}), +})); + +const testEvent: APIGatewayProxyEvent = { + ...proxyEvent, + body: `{"key":"mock-id","title":"test banner","description":"test description","link":"https://www.mocklink.com","startDate":1000,"endDate":2000}`, + pathParameters: { bannerId: "testKey" }, + headers: { "cognito-identity-id": "test" }, +}; + +describe("Test createBanner API method", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Test missing path params", async () => { + const badTestEvent = { + ...proxyEvent, + pathParameters: {}, + } as APIGatewayProxyEvent; + const res = await createBanner(badTestEvent); + expect(res.statusCode).toBe(StatusCodes.BadRequest); + }); + + test("Test unauthorized banner creation throws 403 error", async () => { + (canWriteBanner as jest.Mock).mockReturnValueOnce(false); + const res = await createBanner(testEvent); + expect(res.statusCode).toBe(StatusCodes.Forbidden); + expect(res.body).toContain(error.UNAUTHORIZED); + }); + + test("Test Successful Run of Banner Creation", async () => { + const res = await createBanner(testEvent); + expect(res.statusCode).toBe(StatusCodes.Created); + expect(res.body).toContain("test banner"); + expect(res.body).toContain("test description"); + }); + + test("Test bannerKey not provided throws 500 error", async () => { + const noKeyEvent: APIGatewayProxyEvent = { + ...testEvent, + pathParameters: {}, + }; + const res = await createBanner(noKeyEvent); + expect(res.statusCode).toBe(StatusCodes.BadRequest); + expect(res.body).toContain(error.MISSING_DATA); + }); + + test("Test bannerKey empty throws 500 error", async () => { + const noKeyEvent: APIGatewayProxyEvent = { + ...testEvent, + pathParameters: { bannerId: "" }, + }; + const res = await createBanner(noKeyEvent); + expect(res.statusCode).toBe(StatusCodes.BadRequest); + expect(res.body).toContain(error.MISSING_DATA); + }); +}); diff --git a/services/app-api/handlers/banners/create.ts b/services/app-api/handlers/banners/create.ts new file mode 100644 index 00000000..abc4d6ce --- /dev/null +++ b/services/app-api/handlers/banners/create.ts @@ -0,0 +1,51 @@ +import { handler } from "../../libs/handler-lib"; +import { putBanner } from "../../storage/banners"; +import { error } from "../../utils/constants"; +import { + badRequest, + created, + forbidden, + internalServerError, +} from "../../libs/response-lib"; +import { canWriteBanner } from "../../utils/authorization"; +import { parseBannerId } from "../../libs/param-lib"; +import { BannerData } from "../../types/banner"; + +export const createBanner = handler(parseBannerId, async (request) => { + const { bannerId } = request.parameters; + const user = request.user; + + if (!canWriteBanner(user)) { + return forbidden(error.UNAUTHORIZED); + } + + if (!request?.body) { + return badRequest("Invalid request"); + } + + const unvalidatedPayload = request.body; + + //TO DO: add validation & validation test back + const { title, description, link, startDate, endDate } = + unvalidatedPayload as BannerData; + + const currentTime = Date.now(); + + const newBanner = { + key: bannerId, + createdAt: currentTime, + lastAltered: currentTime, + lastAlteredBy: user.fullName, + title, + description, + link, + startDate, + endDate, + }; + try { + await putBanner(newBanner); + } catch { + return internalServerError(error.CREATION_ERROR); + } + return created(newBanner); +}); diff --git a/services/app-api/handlers/banners/delete.test.ts b/services/app-api/handlers/banners/delete.test.ts new file mode 100644 index 00000000..bc8e36d5 --- /dev/null +++ b/services/app-api/handlers/banners/delete.test.ts @@ -0,0 +1,70 @@ +import { StatusCodes } from "../../libs/response-lib"; +import { proxyEvent } from "../../testing/proxyEvent"; +import { APIGatewayProxyEvent, UserRoles } from "../../types/types"; +import { canWriteBanner } from "../../utils/authorization"; +import { deleteBanner } from "./delete"; +import { error } from "../../utils/constants"; +import { DeleteCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { mockClient } from "aws-sdk-client-mock"; + +const dynamoClientMock = mockClient(DynamoDBDocumentClient); + +jest.mock("../../utils/authentication", () => ({ + authenticatedUser: jest.fn().mockResolvedValue({ + role: UserRoles.ADMIN, + state: "PA", + }), +})); + +jest.mock("../../utils/authorization", () => ({ + canWriteBanner: jest.fn().mockReturnValue(true), +})); + +const testEvent: APIGatewayProxyEvent = { + ...proxyEvent, + headers: { "cognito-identity-id": "test" }, + pathParameters: { bannerId: "testKey" }, +}; + +describe("Test deleteBanner API method", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Test not authorized to delete banner throws 403 error", async () => { + (canWriteBanner as jest.Mock).mockReturnValueOnce(false); + const res = await deleteBanner(testEvent); + expect(res.statusCode).toBe(StatusCodes.Forbidden); + expect(res.body).toContain(error.UNAUTHORIZED); + }); + + test("Test Successful Banner Deletion", async () => { + const mockDelete = jest.fn(); + dynamoClientMock.on(DeleteCommand).callsFake(mockDelete); + const res = await deleteBanner(testEvent); + expect(res.statusCode).toBe(StatusCodes.Ok); + expect(mockDelete).toHaveBeenCalled(); + }); + + test("Test bannerKey not provided throws 500 error", async () => { + const noKeyEvent: APIGatewayProxyEvent = { + ...testEvent, + pathParameters: {}, + }; + const res = await deleteBanner(noKeyEvent); + + expect(res.statusCode).toBe(StatusCodes.BadRequest); + expect(res.body).toContain(error.MISSING_DATA); + }); + + test("Test bannerKey empty throws 500 error", async () => { + const noKeyEvent: APIGatewayProxyEvent = { + ...testEvent, + pathParameters: { bannerId: "" }, + }; + const res = await deleteBanner(noKeyEvent); + + expect(res.statusCode).toBe(StatusCodes.BadRequest); + expect(res.body).toContain(error.MISSING_DATA); + }); +}); diff --git a/services/app-api/handlers/banners/delete.ts b/services/app-api/handlers/banners/delete.ts new file mode 100644 index 00000000..44d85ab8 --- /dev/null +++ b/services/app-api/handlers/banners/delete.ts @@ -0,0 +1,17 @@ +import { handler } from "../../libs/handler-lib"; +import { canWriteBanner } from "../../utils/authorization"; +import { error } from "../../utils/constants"; +import { deleteBanner as deleteBannerById } from "../../storage/banners"; +import { forbidden, ok } from "../../libs/response-lib"; +import { parseBannerId } from "../../libs/param-lib"; + +export const deleteBanner = handler(parseBannerId, async (request) => { + const { bannerId } = request.parameters; + const user = request.user; + + if (!canWriteBanner(user)) { + return forbidden(error.UNAUTHORIZED); + } + await deleteBannerById(bannerId); + return ok(); +}); diff --git a/services/app-api/handlers/banners/fetch.test.ts b/services/app-api/handlers/banners/fetch.test.ts new file mode 100644 index 00000000..154d38f9 --- /dev/null +++ b/services/app-api/handlers/banners/fetch.test.ts @@ -0,0 +1,71 @@ +import { StatusCodes } from "../../libs/response-lib"; +import { proxyEvent } from "../../testing/proxyEvent"; +import { APIGatewayProxyEvent, UserRoles } from "../../types/types"; +import { fetchBanner } from "./fetch"; +import { error } from "../../utils/constants"; +import { getBanner } from "../../storage/banners"; +import { mockBannerResponse } from "../../testing/setupJest"; + +jest.mock("../../utils/authentication", () => ({ + authenticatedUser: jest.fn().mockResolvedValue({ + role: UserRoles.ADMIN, + state: "PA", + }), +})); + +jest.mock("../../utils/authorization", () => ({ + isAuthenticated: jest.fn().mockReturnValue(true), +})); + +jest.mock("../../storage/banners", () => ({ + getBanner: jest.fn(), +})); + +const testEvent: APIGatewayProxyEvent = { + ...proxyEvent, + headers: { "cognito-identity-id": "test" }, + pathParameters: { bannerId: "admin-banner-id" }, +}; + +describe("Test fetchBanner API method", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Test Successful Banner Fetch", async () => { + (getBanner as jest.Mock).mockResolvedValueOnce(mockBannerResponse); + const res = await fetchBanner(testEvent); + expect(res.statusCode).toBe(StatusCodes.Ok); + expect(res.body).toContain("testDesc"); + expect(res.body).toContain("testTitle"); + }); + + test("Test successful empty banner found fetch", async () => { + (getBanner as jest.Mock).mockResolvedValueOnce(undefined); + const res = await fetchBanner(testEvent); + expect(res.body).not.toBeDefined(); + expect(res.statusCode).toBe(StatusCodes.Ok); + }); + + test("Test bannerKey not provided throws 500 error", async () => { + const noKeyEvent: APIGatewayProxyEvent = { + ...testEvent, + pathParameters: {}, + }; + const res = await fetchBanner(noKeyEvent); + + expect(res.statusCode).toBe(StatusCodes.BadRequest); + expect(res.body).toContain(error.MISSING_DATA); + }); + + test("Test bannerKey empty throws 500 error", async () => { + const noKeyEvent: APIGatewayProxyEvent = { + ...testEvent, + pathParameters: { bannerId: "" }, + }; + const res = await fetchBanner(noKeyEvent); + + expect(res.statusCode).toBe(StatusCodes.BadRequest); + expect(res.body).toContain(error.MISSING_DATA); + }); +}); diff --git a/services/app-api/handlers/banners/fetch.ts b/services/app-api/handlers/banners/fetch.ts new file mode 100644 index 00000000..f4334fa6 --- /dev/null +++ b/services/app-api/handlers/banners/fetch.ts @@ -0,0 +1,10 @@ +import { handler } from "../../libs/handler-lib"; +import { getBanner } from "../../storage/banners"; +import { ok } from "../../libs/response-lib"; +import { parseBannerId } from "../../libs/param-lib"; + +export const fetchBanner = handler(parseBannerId, async (request) => { + const { bannerId } = request.parameters; + const banner = await getBanner(bannerId); + return ok(banner); +}); diff --git a/services/app-api/libs/param-lib.ts b/services/app-api/libs/param-lib.ts index 8fbf6734..221eb662 100644 --- a/services/app-api/libs/param-lib.ts +++ b/services/app-api/libs/param-lib.ts @@ -36,3 +36,13 @@ export const parseReportParameters = (event: APIGatewayProxyEvent) => { return { reportType, state, id }; }; + +export const parseBannerId = (event: APIGatewayProxyEvent) => { + const { bannerId } = event.pathParameters ?? {}; + if (!bannerId) { + logger.warn("Invalid banner id in path"); + return undefined; + } + + return { bannerId }; +}; diff --git a/services/app-api/serverless.yml b/services/app-api/serverless.yml index b10ae017..2830de73 100644 --- a/services/app-api/serverless.yml +++ b/services/app-api/serverless.yml @@ -33,6 +33,7 @@ custom: - production dotenv: path: ../../.env + bannerTableName: ${env:BANNER_TABLE_NAME, param:BannerTableName} qmReportTableName: ${env:QM_REPORT_TABLE_NAME, param:QmReportTableName} webAclName: ${self:service}-${self:custom.stage}-webacl-waf associateWaf: @@ -79,9 +80,46 @@ provider: Resource: "*" environment: STAGE: ${self:custom.stage} + BANNER_TABLE_NAME: ${self:custom.bannerTableName} QM_REPORT_TABLE_NAME: ${self:custom.qmReportTableName} functions: + createBanner: + handler: handlers/banners/create.createBanner + events: + - http: + path: banners/{bannerId} + method: post + cors: true + authorizer: aws_iam + request: + parameters: + paths: + bannerId: true + deleteBanner: + handler: handlers/banners/delete.deleteBanner + events: + - http: + path: banners/{bannerId} + method: delete + cors: true + authorizer: aws_iam + request: + parameters: + paths: + bannerId: true + fetchBanner: + handler: handlers/banners/fetch.fetchBanner + events: + - http: + path: banners/{bannerId} + method: get + cors: true + authorizer: aws_iam + request: + parameters: + paths: + bannerId: true createReport: handler: handlers/reports/create.createReport events: diff --git a/services/app-api/storage/banners.test.ts b/services/app-api/storage/banners.test.ts new file mode 100644 index 00000000..df1ef0df --- /dev/null +++ b/services/app-api/storage/banners.test.ts @@ -0,0 +1,71 @@ +import { putBanner, getBanner, deleteBanner } from "./banners"; +import { mockClient } from "aws-sdk-client-mock"; +import { + DeleteCommand, + DynamoDBDocumentClient, + GetCommand, + PutCommand, +} from "@aws-sdk/lib-dynamodb"; + +const mockDynamo = mockClient(DynamoDBDocumentClient); + +const mockBanner = { + key: "mock-key", + title: "Mock Title", + description: "Mock description", + startDate: new Date(2024, 8, 27).getDate(), + endDate: new Date(2024, 8, 28).getDate(), + isActive: true, +}; + +describe("Banner storage methods", () => { + beforeEach(() => { + mockDynamo.reset(); + }); + + it("should call Dynamo to create a new or updated banner", async () => { + const mockPut = jest.fn(); + mockDynamo.on(PutCommand).callsFakeOnce(mockPut); + + await putBanner(mockBanner); + + expect(mockPut).toHaveBeenCalledWith( + { + TableName: "local-banners", + Item: mockBanner, + }, + expect.any(Function) + ); + }); + + it("should call Dynamo to fetch a banner", async () => { + const mockFetch = jest.fn().mockResolvedValue({ Item: mockBanner }); + mockDynamo.on(GetCommand).callsFakeOnce(mockFetch); + + const banner = await getBanner("mock-key"); + + expect(banner).toBe(mockBanner); + expect(mockFetch).toHaveBeenCalledWith( + { + TableName: "local-banners", + Key: { key: "mock-key" }, + }, + expect.any(Function) + ); + }); + + it("should call Dynamo to delete a banner", async () => { + const mockDelete = jest.fn(); + mockDynamo.on(DeleteCommand).callsFakeOnce(mockDelete); + + await deleteBanner("mock-key"); + + expect(mockDelete).toHaveBeenCalledWith( + { + TableName: "local-banners", + Key: { key: "mock-key" }, + }, + expect.any(Function) + ); + }); +}); diff --git a/services/app-api/storage/banners.ts b/services/app-api/storage/banners.ts new file mode 100644 index 00000000..32fd169a --- /dev/null +++ b/services/app-api/storage/banners.ts @@ -0,0 +1,38 @@ +import { DeleteCommand, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { createClient } from "./dynamo/dynamodb-lib"; +import { BannerData } from "../types/banner"; + +const bannerTableName = process.env.BANNER_TABLE_NAME!; +const client = createClient(); + +export const putBanner = async (banner: BannerData) => { + await client.send( + new PutCommand({ + TableName: bannerTableName, + Item: banner, + }) + ); +}; + +export const getBanner = async (bannerId: string) => { + const response = await client.send( + new GetCommand({ + TableName: bannerTableName, + Key: { + key: bannerId, + }, + }) + ); + return response.Item as BannerData | undefined; +}; + +export const deleteBanner = async (bannerId: string) => { + await client.send( + new DeleteCommand({ + TableName: bannerTableName, + Key: { + key: bannerId, + }, + }) + ); +}; diff --git a/services/app-api/storage/dynamo/dynamodb-lib.ts b/services/app-api/storage/dynamo/dynamodb-lib.ts index 2fd9089b..dc9abe81 100644 --- a/services/app-api/storage/dynamo/dynamodb-lib.ts +++ b/services/app-api/storage/dynamo/dynamodb-lib.ts @@ -4,9 +4,7 @@ import { ScanCommandOutput, } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, Paginator } from "@aws-sdk/lib-dynamodb"; -// utils import { logger } from "../../libs/debug-lib"; -// types const localConfig = { endpoint: process.env.DYNAMODB_URL, diff --git a/services/app-api/testing/setupJest.ts b/services/app-api/testing/setupJest.ts index 891f23cc..7bd55e83 100644 --- a/services/app-api/testing/setupJest.ts +++ b/services/app-api/testing/setupJest.ts @@ -1,5 +1,6 @@ process.env.STAGE = "local"; process.env.QM_REPORT_TABLE_NAME = "local-qm-reports"; +process.env.BANNER_TABLE_NAME = "local-banners"; /* * This mock mutes all logger output during tests! Including console errors! @@ -28,3 +29,6 @@ jest.mock("../libs/debug-lib", () => { flush: jest.fn(), }; }); + +// BANNER +export * from "../utils/tests/mockBanner"; diff --git a/services/app-api/types/banner.ts b/services/app-api/types/banner.ts new file mode 100644 index 00000000..966832fb --- /dev/null +++ b/services/app-api/types/banner.ts @@ -0,0 +1,9 @@ +export interface BannerData { + title: string; + description: string; + link?: string; + key: string; + startDate?: number; + endDate?: number; + isActive?: boolean; +} diff --git a/services/app-api/utils/authorization.ts b/services/app-api/utils/authorization.ts index 9de8b7d5..5e686757 100644 --- a/services/app-api/utils/authorization.ts +++ b/services/app-api/utils/authorization.ts @@ -25,3 +25,7 @@ export const canWriteState = (user: User, state: StateAbbr) => { } return false; }; + +export const canWriteBanner = (user: User) => { + return user.role == UserRoles.ADMIN; +}; diff --git a/services/app-api/utils/constants.ts b/services/app-api/utils/constants.ts index 366ef95b..ed872056 100644 --- a/services/app-api/utils/constants.ts +++ b/services/app-api/utils/constants.ts @@ -4,6 +4,7 @@ export const error = { MISSING_DATA: "Missing required data.", INVALID_DATA: "Provided data is not valid.", SERVER_ERROR: "An unspecified server error occured.", + CREATION_ERROR: "Could not be created due to a database error.", }; export enum DeliverySystem { diff --git a/services/app-api/utils/tests/mockBanner.ts b/services/app-api/utils/tests/mockBanner.ts new file mode 100644 index 00000000..0049c52a --- /dev/null +++ b/services/app-api/utils/tests/mockBanner.ts @@ -0,0 +1,9 @@ +export const mockBannerResponse = { + createdAt: 1654198665696, + endDate: 1657252799000, + lastAltered: 1654198665696, + description: "testDesc", + title: "testTitle", + key: "admin-banner-id", + startDate: 1641013200000, +}; diff --git a/services/database/serverless.yml b/services/database/serverless.yml index cceefdbb..ff368e52 100644 --- a/services/database/serverless.yml +++ b/services/database/serverless.yml @@ -25,6 +25,7 @@ custom: - production dotenv: path: ../../.env + bannerTableName: ${self:custom.stage}-banners qmReportTableName: ${self:custom.stage}-qm-reports dynamodb: stages: @@ -38,9 +39,9 @@ provider: name: aws runtime: nodejs20.x region: us-east-1 - stackTags: + stackTags: PROJECT: ${self:custom.project} - SERVICE: ${self:service} + SERVICE: ${self:service} iam: role: path: ${env:IAM_PATH, ssm:/configuration/${self:custom.stage}/iam/path, ssm:/configuration/default/iam/path, "/"} @@ -62,7 +63,22 @@ provider: - "s3:ListBucket" - "s3:PutObject" resources: - Resources: + Resources: + BannerTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ${self:custom.bannerTableName} + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: true + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES + AttributeDefinitions: + - AttributeName: key + AttributeType: S + KeySchema: + - AttributeName: key + KeyType: HASH + BillingMode: PAY_PER_REQUEST # Set the capacity to auto-scale QmReportTable: Type: AWS::DynamoDB::Table Properties: @@ -83,6 +99,8 @@ resources: KeyType: RANGE BillingMode: PAY_PER_REQUEST # Set the capacity to auto-scale Outputs: + BannerTableName: + Value: !Ref BannerTable QmReportTableName: Value: !Ref QmReportTable QmReportTableArn: diff --git a/services/ui-src/src/components/app/AppRoutes.tsx b/services/ui-src/src/components/app/AppRoutes.tsx index 8e7f1d0d..f2aeaef3 100644 --- a/services/ui-src/src/components/app/AppRoutes.tsx +++ b/services/ui-src/src/components/app/AppRoutes.tsx @@ -1,35 +1,49 @@ -import { Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes } from "react-router-dom"; import { + AdminPage, HelpPage, HomePage, ProfilePage, DashboardPage, NotFoundPage, + AdminBannerProvider, ExportedReportPage, } from "components"; import { CreateReportOptions } from "components/pages/CreateReportOptions/CreateReportOptions"; import { ReportPageWrapper } from "components/report/ReportPageWrapper"; +import { useStore } from "utils"; export const AppRoutes = () => { + const { userIsAdmin } = useStore().user ?? {}; + return (
- - {/* General Routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } - /> - + + + {/* General Routes */} + } /> + : } + /> + } /> + } /> + } /> + } + /> + } /> + } + /> + } + /> + +
); }; diff --git a/services/ui-src/src/components/banners/AdminBannerProvider.tsx b/services/ui-src/src/components/banners/AdminBannerProvider.tsx new file mode 100644 index 00000000..48f9c569 --- /dev/null +++ b/services/ui-src/src/components/banners/AdminBannerProvider.tsx @@ -0,0 +1,127 @@ +import { createContext, ReactNode, useMemo, useEffect } from "react"; +import { BannerData, AdminBannerShape } from "types/banners"; +import { bannerId } from "../../constants"; +import { bannerErrors } from "verbiage/errors"; +import { + checkDateRangeStatus, + deleteBanner, + getBanner, + useStore, + writeBanner, +} from "utils"; + +const ADMIN_BANNER_ID = bannerId; + +export const AdminBannerContext = createContext({ + fetchAdminBanner: Function, + writeAdminBanner: Function, + deleteAdminBanner: Function, +}); + +export const AdminBannerProvider = ({ children }: Props) => { + // state management + const { + bannerData, + setBannerData, + bannerActive, + setBannerActive, + bannerLoading, + setBannerLoading, + bannerErrorMessage, + setBannerErrorMessage, + bannerDeleting, + setBannerDeleting, + } = useStore(); + + const fetchAdminBanner = async () => { + setBannerLoading(true); + try { + const currentBanner = await getBanner(ADMIN_BANNER_ID); + const newBannerData = currentBanner as BannerData | undefined; + setBannerData(newBannerData); + setBannerErrorMessage(undefined); + } catch (e: any) { + // 404 expected when no current banner exists + if (!e.toString().includes("404")) { + setBannerErrorMessage(bannerErrors.GET_BANNER_FAILED); + } + } + setBannerLoading(false); + }; + + const deleteAdminBanner = async () => { + setBannerDeleting(true); + try { + await deleteBanner(ADMIN_BANNER_ID); + await fetchAdminBanner(); + } catch { + setBannerErrorMessage(bannerErrors.DELETE_BANNER_FAILED); + } + setBannerDeleting(false); + }; + + const writeAdminBanner = async (newBannerData: BannerData) => { + try { + await writeBanner(newBannerData); + } catch { + setBannerErrorMessage(bannerErrors.CREATE_BANNER_FAILED); + } + await fetchAdminBanner(); + }; + + useEffect(() => { + let bannerActivity = false; + if (bannerData) { + bannerActivity = checkDateRangeStatus( + bannerData.startDate!, + bannerData.endDate! + ); + } + setBannerActive(bannerActivity); + }, [bannerData]); + + useEffect(() => { + fetchAdminBanner(); + }, []); + + const providerValue = useMemo( + () => ({ + // Banner Data + bannerData, + setBannerData, + // Banner showing + bannerActive, + setBannerActive, + // Banner Loading + bannerLoading, + setBannerLoading, + // Banner Error State + bannerErrorMessage, + setBannerErrorMessage, + // Banner Deleting State + bannerDeleting, + setBannerDeleting, + // Banner API calls + fetchAdminBanner, + writeAdminBanner, + deleteAdminBanner, + }), + [ + bannerData, + bannerActive, + bannerLoading, + bannerErrorMessage, + bannerDeleting, + ] + ); + + return ( + + {children} + + ); +}; + +interface Props { + children?: ReactNode; +} diff --git a/services/ui-src/src/components/banners/Banner.test.tsx b/services/ui-src/src/components/banners/Banner.test.tsx new file mode 100644 index 00000000..0d55b2d2 --- /dev/null +++ b/services/ui-src/src/components/banners/Banner.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { Banner } from "./Banner"; + +const bannerData = { + title: "mock-title", + description: "mock-description", +}; + +describe("Test Banner", () => { + it("Test Banner with no data", () => { + const { container } = render(); + expect(container.childElementCount).toEqual(0); + }); + it("Test Banner with data", () => { + render(); + expect(screen.getByText(bannerData.title)).toBeInTheDocument(); + expect(screen.getByText(bannerData.description)).toBeInTheDocument(); + }); +}); diff --git a/services/ui-src/src/components/banners/Banner.tsx b/services/ui-src/src/components/banners/Banner.tsx new file mode 100644 index 00000000..e900424e --- /dev/null +++ b/services/ui-src/src/components/banners/Banner.tsx @@ -0,0 +1,17 @@ +import { Alert } from "components"; +import { BannerData } from "types"; + +export const Banner = ({ bannerData, ...props }: Props) => { + if (bannerData) { + const { title, description, link } = bannerData; + return ( + bannerData && ( + + ) + ); + } else return <>; +}; + +interface Props { + bannerData: BannerData | undefined; +} diff --git a/services/ui-src/src/components/banners/PreviewBanner.tsx b/services/ui-src/src/components/banners/PreviewBanner.tsx new file mode 100644 index 00000000..f4e68e07 --- /dev/null +++ b/services/ui-src/src/components/banners/PreviewBanner.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; +import { Banner } from "components"; + +export const PreviewBanner = () => { + // get the form context + const form = useFormContext(); + + // set banner preview data + const formData = form.getValues(); + const bannerData = { + title: formData?.["bannerTitle"]?.["answer"] || "New banner title", + description: + formData?.["bannerDescription"]?.["answer"] || "New banner description", + link: formData?.["bannerLink"]?.["answer"] || "", + }; + + return ; +}; diff --git a/services/ui-src/src/components/forms/AdminBannerForm.test.tsx b/services/ui-src/src/components/forms/AdminBannerForm.test.tsx new file mode 100644 index 00000000..c60f002a --- /dev/null +++ b/services/ui-src/src/components/forms/AdminBannerForm.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from "@testing-library/react"; +import { AdminBannerForm } from "components"; +import { RouterWrappedComponent } from "utils/testing/mockRouter"; +import userEvent from "@testing-library/user-event"; +import { testA11y } from "utils/testing/commonTests"; + +const mockWriteAdminBanner = jest.fn(); +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +const adminBannerFormComponent = (writeAdminBanner: Function) => ( + + + +); + +describe("", () => { + test("AdminBannerForm is visible", () => { + render(adminBannerFormComponent(mockWriteAdminBanner)); + expect(screen.getByRole("textbox", { name: "Title text" })).toBeVisible(); + expect( + screen.getByRole("textbox", { name: "Description text" }) + ).toBeVisible(); + expect(screen.getByRole("textbox", { name: "Link" })).toBeVisible(); + expect(screen.getByRole("textbox", { name: "Start date" })).toBeVisible(); + expect(screen.getByRole("textbox", { name: "End date" })).toBeVisible(); + expect( + screen.getByRole("button", { name: "Replace Current Banner" }) + ).toBeVisible(); + }); + + test("AdminBannerForm can be filled and submitted without error", async () => { + render(adminBannerFormComponent(mockWriteAdminBanner)); + + const titleInput = screen.getByLabelText("Title text"); + await userEvent.type(titleInput, "mock title"); + + const descriptionInput = screen.getByLabelText("Description text"); + await userEvent.type(descriptionInput, "mock description"); + + const linkInput = screen.getByLabelText("Link", { exact: false }); + await userEvent.type(linkInput, "http://example.com"); + + const startDateInput = screen.getByLabelText("Start date"); + await userEvent.type(startDateInput, "01/01/1970"); + + const endDateInput = screen.getByLabelText("End date"); + await userEvent.type(endDateInput, "01/01/1970"); + + const submitButton = screen.getByText("Replace Current Banner"); + await userEvent.click(submitButton); + + const HOURS = 60 * 60 * 1000; + + expect(mockWriteAdminBanner).toHaveBeenCalledWith({ + key: "admin-banner-id", + title: "mock title", + description: "mock description", + link: "http://example.com", + startDate: 5 * HOURS, // midnight UTC, in New York + endDate: 29 * HOURS - 1000, // 1 second before midnight of the next day + }); + }); + + test("AdminBannerForm shows an error when submit fails", async () => { + mockWriteAdminBanner.mockImplementationOnce(() => { + throw new Error("FAILURE"); + }); + + render(adminBannerFormComponent(mockWriteAdminBanner)); + + const titleInput = screen.getByLabelText("Title text"); + await userEvent.type(titleInput, "mock title"); + + const descriptionInput = screen.getByLabelText("Description text"); + await userEvent.type(descriptionInput, "mock description"); + + const linkInput = screen.getByLabelText("Link", { exact: false }); + await userEvent.type(linkInput, "http://example.com"); + + const startDateInput = screen.getByLabelText("Start date"); + await userEvent.type(startDateInput, "01/01/1970"); + + const endDateInput = screen.getByLabelText("End date"); + await userEvent.type(endDateInput, "01/01/1970"); + + const submitButton = screen.getByText("Replace Current Banner"); + await userEvent.click(submitButton); + + const errorMessage = screen.getByText( + "Current banner could not be replaced", + { exact: false } + ); + expect(errorMessage).toBeVisible(); + }); + + testA11y(adminBannerFormComponent(mockWriteAdminBanner)); +}); diff --git a/services/ui-src/src/components/forms/AdminBannerForm.tsx b/services/ui-src/src/components/forms/AdminBannerForm.tsx new file mode 100644 index 00000000..3d3d5ff3 --- /dev/null +++ b/services/ui-src/src/components/forms/AdminBannerForm.tsx @@ -0,0 +1,119 @@ +import { FormProvider, useForm } from "react-hook-form"; +import { useState } from "react"; +import { Button, Flex, Spinner } from "@chakra-ui/react"; +import { ErrorAlert, PreviewBanner, TextField, DateField } from "components"; +import { bannerId } from "../../constants"; +import { bannerErrors } from "verbiage/errors"; +import { convertDatetimeStringToNumber } from "utils"; +import { ElementType, ErrorVerbiage } from "types"; + +export const AdminBannerForm = ({ writeAdminBanner, ...props }: Props) => { + const [error, setError] = useState(); + const [submitting, setSubmitting] = useState(false); + + // add validation to formJson + const form = useForm(); + + const onSubmit = async (formData: any) => { + setSubmitting(true); + const newBannerData = { + key: bannerId, + title: formData["bannerTitle"]?.answer, + description: formData["bannerDescription"]?.answer, + link: formData["bannerLink"]?.answer || undefined, + startDate: convertDatetimeStringToNumber( + formData["bannerStartDate"]?.answer, + "startDate" + ), + endDate: convertDatetimeStringToNumber( + formData["bannerEndDate"]?.answer, + "endDate" + ), + }; + + try { + await writeAdminBanner(newBannerData); + window.scrollTo(0, 0); + } catch { + setError(bannerErrors.REPLACE_BANNER_FAILED); + } + setSubmitting(false); + }; + + return ( + <> + + +
+ + + + + + + +
+ +
+ + + + + ); +}; + +interface Props { + writeAdminBanner: Function; +} + +const sx = { + errorAlert: { + maxWidth: "40rem", + }, + previewFlex: { + flexDirection: "column", + }, + replaceBannerButton: { + width: "14rem", + marginTop: "1rem !important", + alignSelf: "end", + }, +}; diff --git a/services/ui-src/src/components/index.ts b/services/ui-src/src/components/index.ts index abc4cb1a..922b50e2 100644 --- a/services/ui-src/src/components/index.ts +++ b/services/ui-src/src/components/index.ts @@ -9,6 +9,13 @@ export { ErrorAlert } from "./alerts/ErrorAlert"; // app export { App } from "./app/App"; export { Error } from "./app/Error"; +// banners +export { + AdminBannerContext, + AdminBannerProvider, +} from "./banners/AdminBannerProvider"; +export { Banner } from "./banners/Banner"; +export { PreviewBanner } from "./banners/PreviewBanner"; // layout export { HomePage } from "./layout/HomePage"; export { Header } from "./layout/Header"; @@ -25,12 +32,15 @@ export { ExportedReportBanner } from "./export/ExportedReportBanner"; export { ExportedReportWrapper } from "./export/ExportedReportWrapper"; // fields export { TextField } from "./fields/TextField"; +export { DateField } from "./fields/DateField"; // forms +export { AdminBannerForm } from "./forms/AdminBannerForm"; export { AdminDashSelector } from "./forms/AdminDashSelector"; // logins export { LoginCognito } from "./logins/LoginCognito"; export { LoginIDM } from "./logins/LoginIDM"; // pages +export { AdminPage } from "./pages/Admin/AdminPage"; export { HelpPage } from "./pages/HelpPage/HelpPage"; export { ProfilePage } from "./pages/Profile/ProfilePage"; export { NotFoundPage } from "./pages/NotFound/NotFoundPage"; diff --git a/services/ui-src/src/components/pages/Admin/AdminPage.test.tsx b/services/ui-src/src/components/pages/Admin/AdminPage.test.tsx new file mode 100644 index 00000000..ee2aec03 --- /dev/null +++ b/services/ui-src/src/components/pages/Admin/AdminPage.test.tsx @@ -0,0 +1,159 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { act } from "react-dom/test-utils"; +import { AdminPage, AdminBannerContext } from "components"; +import { useStore } from "utils"; +import { + mockBannerStore, + RouterWrappedComponent, +} from "utils/testing/setupJest"; +import { mockBannerData } from "utils/testing/mockBanner"; +import { bannerErrors } from "verbiage/errors"; +import { testA11yAct } from "utils/testing/commonTests"; + +const mockBannerMethods = { + fetchAdminBanner: jest.fn(() => {}), + writeAdminBanner: jest.fn(() => {}), + deleteAdminBanner: jest.fn(() => {}), +}; + +jest.mock("utils/state/useStore"); +const mockedUseStore = useStore as jest.MockedFunction; + +const adminView = (context: any) => ( + + + + + +); + +describe("", () => { + describe("Test AdminPage banner manipulation functionality", () => { + test("Deletes current banner on delete button click", async () => { + await act(async () => { + mockedUseStore.mockReturnValue(mockBannerStore); + await render(adminView(mockBannerMethods)); + }); + const deleteButton = screen.getByText("Delete Current Banner"); + await userEvent.click(deleteButton); + await waitFor(() => + expect(mockBannerMethods.deleteAdminBanner).toHaveBeenCalled() + ); + }); + }); + + describe("Test AdminPage without banner", () => { + beforeEach(async () => { + await act(async () => { + mockedUseStore.mockReturnValue({ + ...mockBannerStore, + bannerData: undefined, + }); + await render(adminView(mockBannerMethods)); + }); + }); + + test("Check that AdminPage renders", () => { + expect(screen.getByTestId("admin-view")).toBeVisible(); + }); + + test("Check that current banner info does not render", () => { + const currentBannerStatus = screen.queryByText("Status:"); + expect(currentBannerStatus).not.toBeInTheDocument(); + }); + + test("Check that 'no current banner' text shows", async () => { + expect(screen.getByText("There is no current banner")).toBeVisible(); + }); + }); + + describe("Test AdminPage with banner", () => { + beforeEach(async () => { + await act(async () => { + mockedUseStore.mockReturnValue(mockBannerStore); + await render(adminView(mockBannerMethods)); + }); + }); + + test("Check that AdminPage renders", () => { + expect(screen.getByTestId("admin-view")).toBeVisible(); + }); + + test("Check that current banner info renders", () => { + const currentBannerStatus = screen.queryByText("Status:"); + expect(currentBannerStatus).toBeVisible(); + + const deleteButton = screen.getByText("Delete Current Banner"); + expect(deleteButton).toBeVisible(); + }); + + test("Check that 'no current banner' text does not show", () => { + expect( + screen.queryByText("There is no current banner") + ).not.toBeInTheDocument(); + }); + }); + + describe("Test AdminPage with active/inactive banner", () => { + const currentTime = Date.now(); // 'current' time in ms since unix epoch + const oneDay = 1000 * 60 * 60 * 24; // 1000ms * 60s * 60m * 24h = 86,400,000ms + const context = mockBannerMethods; + mockedUseStore.mockReturnValue(mockBannerStore); + + test("Active banner shows 'active' status", async () => { + // TODO: actually toggle active status + const activeBannerData = { + ...mockBannerData, + startDate: currentTime - oneDay, + endDate: currentTime + oneDay, + }; + await act(async () => { + mockedUseStore.mockReturnValue({ + ...mockBannerStore, + bannerData: activeBannerData, + bannerActive: true, + }); + await render(adminView(context)); + }); + const currentBannerStatus = screen.getByText("Status:"); + expect(currentBannerStatus.textContent).toEqual("Status: Active"); + }); + + test("Inactive banner shows 'inactive' status", async () => { + const inactiveBannerData = { + ...mockBannerData, + startDate: currentTime + oneDay, + endDate: currentTime + oneDay + oneDay, + }; + await act(async () => { + mockedUseStore.mockReturnValue({ + ...mockBannerStore, + bannerData: inactiveBannerData, + }); + await render(adminView(context)); + }); + const currentBannerStatus = screen.getByText("Status:"); + expect(currentBannerStatus.textContent).toEqual("Status: Inactive"); + }); + }); + + describe("Test AdminPage displays banner error when state has set an error", () => { + test("Displays error if deleteBanner throws error", async () => { + mockedUseStore.mockReturnValue({ + ...mockBannerStore, + bannerErrorMessage: bannerErrors.DELETE_BANNER_FAILED, + }); + + await act(async () => { + await render(adminView(mockBannerMethods)); + }); + + expect( + screen.getByText("Current banner could not be deleted") + ).toBeVisible(); + }); + }); + + testA11yAct(adminView(mockBannerMethods)); +}); diff --git a/services/ui-src/src/components/pages/Admin/AdminPage.tsx b/services/ui-src/src/components/pages/Admin/AdminPage.tsx new file mode 100644 index 00000000..c0b819d2 --- /dev/null +++ b/services/ui-src/src/components/pages/Admin/AdminPage.tsx @@ -0,0 +1,174 @@ +import { useContext, MouseEventHandler } from "react"; +import { + Box, + Button, + Collapse, + Flex, + Heading, + Text, + Spinner, +} from "@chakra-ui/react"; +import { + AdminBannerContext, + AdminBannerForm, + Banner, + ErrorAlert, + PageTemplate, +} from "components"; +import { convertDateUtcToEt, useStore } from "utils"; +import verbiage from "verbiage/pages/admin"; + +export const AdminPage = () => { + const { deleteAdminBanner, writeAdminBanner } = + useContext(AdminBannerContext); + + const { + bannerData, + bannerActive, + bannerLoading, + bannerErrorMessage, + bannerDeleting, + } = useStore(); + + return ( + + + + + {verbiage.intro.header} + + {verbiage.intro.body} + + + Current Banner + {bannerLoading ? ( + + + + ) : ( + <> + + {bannerData && ( + <> + + + Status:{" "} + + {bannerActive ? "Active" : "Inactive"} + + + + Start Date:{" "} + {bannerData.startDate && ( + {convertDateUtcToEt(bannerData.startDate)} + )} + + + End Date:{" "} + {bannerData.endDate && ( + {convertDateUtcToEt(bannerData.endDate)} + )} + + + + + + + + )} + + {!bannerData && There is no current banner} + + )} + + + Create a New Banner + + + + ); +}; + +const sx = { + layout: { + ".contentFlex": { + marginTop: "3.5rem", + }, + }, + errorAlert: { + width: "100% !important", + marginTop: "-4rem", + marginBottom: "2rem", + }, + introTextBox: { + width: "100%", + marginBottom: "2.25rem", + }, + headerText: { + marginBottom: "1rem", + fontSize: "2rem", + fontWeight: "normal", + }, + currentBannerSectionBox: { + width: "100%", + marginBottom: "2.25rem", + }, + sectionHeader: { + fontSize: "1.5rem", + fontWeight: "bold", + }, + currentBannerInfo: { + flexDirection: "column", + marginBottom: "0.5rem !important", + }, + currentBannerStatus: { + span: { + marginLeft: "0.5rem", + "&.active": { + color: "palette.success", + }, + "&.inactive": { + color: "palette.error", + }, + }, + }, + currentBannerDate: { + span: { + marginLeft: "0.5rem", + }, + }, + currentBannerFlex: { + flexDirection: "column", + }, + spinnerContainer: { + marginTop: "0.5rem", + ".ds-c-spinner": { + "&:before": { + borderColor: "palette.black", + }, + "&:after": { + borderLeftColor: "palette.black", + }, + }, + }, + deleteBannerButton: { + width: "13.3rem", + alignSelf: "end", + marginTop: "1rem !important", + }, + newBannerBox: { + width: "100%", + flexDirection: "column", + marginBottom: "2.25rem", + }, +}; diff --git a/services/ui-src/src/components/report/Elements.tsx b/services/ui-src/src/components/report/Elements.tsx index fc17b6f1..11dbc7fa 100644 --- a/services/ui-src/src/components/report/Elements.tsx +++ b/services/ui-src/src/components/report/Elements.tsx @@ -23,7 +23,7 @@ import arrowLeftIcon from "assets/icons/arrows/icon_arrow_left_blue.png"; export interface PageElementProps { element: PageElement; - index: number; + index?: number; formkey: string; } diff --git a/services/ui-src/src/constants.ts b/services/ui-src/src/constants.ts index f7af3499..8aba192d 100644 --- a/services/ui-src/src/constants.ts +++ b/services/ui-src/src/constants.ts @@ -1,3 +1,6 @@ +// BANNERS +export const bannerId = "admin-banner-id"; + // HOST DOMAIN export const PRODUCTION_HOST_DOMAIN = "mdcthcbs.cms.gov"; diff --git a/services/ui-src/src/types/banners.ts b/services/ui-src/src/types/banners.ts new file mode 100644 index 00000000..b9dea4c0 --- /dev/null +++ b/services/ui-src/src/types/banners.ts @@ -0,0 +1,19 @@ +// BANNER + +export interface BannerData { + title: string; + description: string; + link?: string; + key?: string; + startDate?: number; + endDate?: number; + isActive?: boolean; +} + +export interface AdminBannerMethods { + fetchAdminBanner: Function; + writeAdminBanner: Function; + deleteAdminBanner: Function; +} + +export interface AdminBannerShape extends AdminBannerMethods {} diff --git a/services/ui-src/src/types/index.ts b/services/ui-src/src/types/index.ts index 4bf35d91..1bcc9b06 100644 --- a/services/ui-src/src/types/index.ts +++ b/services/ui-src/src/types/index.ts @@ -1,3 +1,4 @@ +export * from "./banners"; export * from "./users"; export * from "./states"; export * from "./other"; diff --git a/services/ui-src/src/types/states.ts b/services/ui-src/src/types/states.ts index 05da5485..d7cfafb2 100644 --- a/services/ui-src/src/types/states.ts +++ b/services/ui-src/src/types/states.ts @@ -1,6 +1,23 @@ import { ParentPageTemplate, PageData, Report } from "types/report"; import React from "react"; -import { HcbsUser } from "types"; +import { BannerData, ErrorVerbiage, HcbsUser } from "types"; + +export interface AdminBannerState { + bannerData: BannerData | undefined; + bannerActive: boolean; + bannerLoading: boolean; + bannerErrorMessage: ErrorVerbiage | undefined; + bannerDeleting: boolean; + // ACTIONS + setBannerData: (newBannerData: BannerData | undefined) => void; + clearAdminBanner: () => void; + setBannerActive: (bannerStatus: boolean) => void; + setBannerLoading: (bannerLoading: boolean) => void; + setBannerErrorMessage: ( + bannerErrorMessage: ErrorVerbiage | undefined + ) => void; + setBannerDeleting: (bannerDeleting: boolean) => void; +} // initial user state export interface HcbsUserState { diff --git a/services/ui-src/src/utils/api/requestMethods/banner.test.ts b/services/ui-src/src/utils/api/requestMethods/banner.test.ts new file mode 100644 index 00000000..74151c50 --- /dev/null +++ b/services/ui-src/src/utils/api/requestMethods/banner.test.ts @@ -0,0 +1,30 @@ +import { getBanner, writeBanner, deleteBanner } from "./banner"; +import { bannerId } from "../../../constants"; +import { mockBannerData } from "utils/testing/setupJest"; +import { initAuthManager } from "utils/auth/authLifecycle"; + +describe("utils/banner", () => { + beforeEach(async () => { + jest.useFakeTimers(); + initAuthManager(); + jest.runAllTimers(); + }); + + describe("getBanner()", () => { + test("executes", () => { + expect(getBanner(bannerId)).toBeTruthy(); + }); + }); + + describe("writeBanner()", () => { + test("executes", () => { + expect(writeBanner(mockBannerData)).toBeTruthy(); + }); + }); + + describe("deleteBanner()", () => { + test("executes", () => { + expect(deleteBanner(bannerId)).toBeTruthy(); + }); + }); +}); diff --git a/services/ui-src/src/utils/api/requestMethods/banner.ts b/services/ui-src/src/utils/api/requestMethods/banner.ts new file mode 100644 index 00000000..2926d4be --- /dev/null +++ b/services/ui-src/src/utils/api/requestMethods/banner.ts @@ -0,0 +1,36 @@ +import { apiLib, updateTimeout } from "utils"; +import { getRequestHeaders } from "./getRequestHeaders"; +import { BannerData } from "types/banners"; + +async function getBanner(bannerKey: string) { + const requestHeaders = await getRequestHeaders(); + const options = { + headers: { ...requestHeaders }, + }; + + updateTimeout(); + return await apiLib.get(`/banners/${bannerKey}`, options); +} + +async function writeBanner(bannerData: BannerData) { + const requestHeaders = await getRequestHeaders(); + const options = { + headers: { ...requestHeaders }, + body: { ...bannerData }, + }; + + updateTimeout(); + return await apiLib.post(`/banners/${bannerData.key}`, options); +} + +async function deleteBanner(bannerKey: string) { + const requestHeaders = await getRequestHeaders(); + const request = { + headers: { ...requestHeaders }, + }; + + updateTimeout(); + return await apiLib.del(`/banners/${bannerKey}`, request); +} + +export { getBanner, writeBanner, deleteBanner }; diff --git a/services/ui-src/src/utils/index.ts b/services/ui-src/src/utils/index.ts index 43a4b822..4f29f9cf 100644 --- a/services/ui-src/src/utils/index.ts +++ b/services/ui-src/src/utils/index.ts @@ -2,6 +2,7 @@ export * from "./api/apiLib"; export * from "./api/providers/ApiProvider"; export * from "./api/requestMethods/getTemplateUrl"; +export * from "./api/requestMethods/banner"; // auth export * from "./auth/UserProvider"; export * from "./auth/authLifecycle"; diff --git a/services/ui-src/src/utils/state/useStore.ts b/services/ui-src/src/utils/state/useStore.ts index 25addbd6..cca13a9a 100644 --- a/services/ui-src/src/utils/state/useStore.ts +++ b/services/ui-src/src/utils/state/useStore.ts @@ -1,6 +1,13 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; -import { HcbsUserState, HcbsUser, HcbsReportState } from "types"; +import { + HcbsUserState, + HcbsUser, + HcbsReportState, + BannerData, + ErrorVerbiage, + AdminBannerState, +} from "types"; import { Report } from "types/report"; import React from "react"; import { buildState, mergeAnswers, setPage } from "./management/reportState"; @@ -19,6 +26,37 @@ const userStore = (set: Function) => ({ set(() => ({ showLocalLogins: true }), false, { type: "showLocalLogins" }), }); +// BANNER STORE +const bannerStore = (set: Function) => ({ + // initial state + bannerData: undefined, + bannerActive: false, + bannerLoading: false, + bannerErrorMessage: undefined, + bannerDeleting: false, + // actions + setBannerData: (newBanner: BannerData | undefined) => + set(() => ({ bannerData: newBanner }), false, { type: "setBannerData" }), + clearAdminBanner: () => + set(() => ({ bannerData: undefined }), false, { type: "clearAdminBanner" }), + setBannerActive: (bannerStatus: boolean) => + set(() => ({ bannerActive: bannerStatus }), false, { + type: "setBannerActive", + }), + setBannerLoading: (loading: boolean) => + set(() => ({ bannerLoading: loading }), false, { + type: "setBannerLoading", + }), + setBannerErrorMessage: (errorMessage: ErrorVerbiage | undefined) => + set(() => ({ bannerErrorMessage: errorMessage }), false, { + type: "setBannerErrorMessage", + }), + setBannerDeleting: (deleting: boolean) => + set(() => ({ bannerDeleting: deleting }), false, { + type: "setBannerDeleting", + }), +}); + // REPORT STORE const reportStore = (set: Function): HcbsReportState => ({ // initial state @@ -55,8 +93,9 @@ const reportStore = (set: Function): HcbsReportState => ({ export const useStore = create( // devtools is being used for debugging state persist( - devtools((set) => ({ + devtools((set) => ({ ...userStore(set), + ...bannerStore(set), ...reportStore(set), })), { diff --git a/services/ui-src/src/utils/testing/mockBanner.tsx b/services/ui-src/src/utils/testing/mockBanner.tsx new file mode 100644 index 00000000..41705350 --- /dev/null +++ b/services/ui-src/src/utils/testing/mockBanner.tsx @@ -0,0 +1,9 @@ +import { bannerId } from "../../constants"; + +export const mockBannerData = { + key: bannerId, + title: "Yes here I am, a banner", + description: "I have a description too thank you very much", + startDate: 1640995200000, // 1/1/2022 00:00:00 UTC + endDate: 1672531199000, // 12/31/2022 23:59:59 UTC +}; diff --git a/services/ui-src/src/utils/testing/setupJest.tsx b/services/ui-src/src/utils/testing/setupJest.tsx index 81ee20fd..95620f37 100644 --- a/services/ui-src/src/utils/testing/setupJest.tsx +++ b/services/ui-src/src/utils/testing/setupJest.tsx @@ -3,7 +3,13 @@ import { BrowserRouter as Router } from "react-router-dom"; import "@testing-library/jest-dom"; import "jest-axe/extend-expect"; import { mockFlags, resetLDMocks } from "jest-launchdarkly-mock"; -import { UserRoles, HcbsUserState, UserContextShape } from "types"; +import { + UserRoles, + HcbsUserState, + UserContextShape, + AdminBannerState, +} from "types"; +import { mockBannerData } from "./mockBanner"; // GLOBALS global.React = React; @@ -81,6 +87,22 @@ jest.mock("aws-amplify/auth", () => ({ signInWithRedirect: () => {}, })); +// BANNER STATES / STORE + +export const mockBannerStore: AdminBannerState = { + bannerData: mockBannerData, + bannerActive: false, + bannerLoading: false, + bannerErrorMessage: { title: "", description: "" }, + bannerDeleting: false, + setBannerData: () => {}, + clearAdminBanner: () => {}, + setBannerActive: () => {}, + setBannerLoading: () => {}, + setBannerErrorMessage: () => {}, + setBannerDeleting: () => {}, +}; + // USER CONTEXT export const mockUserContext: UserContextShape = { @@ -162,12 +184,14 @@ export const mockAdminUserStore: HcbsUserState = { // BOUND STORE -export const mockUseStore: HcbsUserState = { +export const mockUseStore: HcbsUserState & AdminBannerState = { ...mockStateUserStore, + ...mockBannerStore, }; -export const mockUseAdminStore: HcbsUserState = { +export const mockUseAdminStore: HcbsUserState & AdminBannerState = { ...mockAdminUserStore, + ...mockBannerStore, }; // ROUTER @@ -184,6 +208,8 @@ export const mockLDClient = { // ASSET export * from "./mockAsset"; +// BANNER +export * from "./mockBanner"; // FORM export * from "./mockForm"; // ROUTER diff --git a/services/ui-src/src/verbiage/errors.ts b/services/ui-src/src/verbiage/errors.ts index 37ffe776..c16de18b 100644 --- a/services/ui-src/src/verbiage/errors.ts +++ b/services/ui-src/src/verbiage/errors.ts @@ -19,3 +19,22 @@ export const genericErrorContent = [ content: ".", }, ]; + +export const bannerErrors = { + GET_BANNER_FAILED: { + title: "Banner could not be fetched", + description: genericErrorContent, + }, + REPLACE_BANNER_FAILED: { + title: "Current banner could not be replaced.", + description: genericErrorContent, + }, + DELETE_BANNER_FAILED: { + title: "Current banner could not be deleted", + description: genericErrorContent, + }, + CREATE_BANNER_FAILED: { + title: "Could not create a banner.", + description: genericErrorContent, + }, +}; diff --git a/services/ui-src/src/verbiage/pages/admin.ts b/services/ui-src/src/verbiage/pages/admin.ts new file mode 100644 index 00000000..7261469d --- /dev/null +++ b/services/ui-src/src/verbiage/pages/admin.ts @@ -0,0 +1,6 @@ +export default { + intro: { + header: "Banner Admin", + body: "Manage the announcement banner below.", + }, +};