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.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.",
+ },
+};