diff --git a/src/components/CancelApplicationButton/index.test.tsx b/src/components/CancelApplicationButton/index.test.tsx
new file mode 100644
index 000000000..4366d3804
--- /dev/null
+++ b/src/components/CancelApplicationButton/index.test.tsx
@@ -0,0 +1,407 @@
+import { render, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { axe } from "jest-axe";
+import { useMemo } from "react";
+import { MockedProvider, MockedResponse } from "@apollo/client/testing";
+import { GraphQLError } from "graphql";
+import {
+ Context as AuthContext,
+ ContextState as AuthContextState,
+ Status as AuthContextStatus,
+} from "../Contexts/AuthContext";
+import { CANCEL_APP, CancelAppInput, CancelAppResp } from "../../graphql";
+import Button from "./index";
+
+const baseAuthCtx: AuthContextState = {
+ status: AuthContextStatus.LOADED,
+ isLoggedIn: false,
+ user: null,
+};
+
+const baseUser: User = {
+ _id: "",
+ firstName: "",
+ lastName: "",
+ userStatus: "Active",
+ role: "Submitter", // NOTE: This role has access to the delete button by default
+ IDP: "nih",
+ email: "",
+ studies: null,
+ dataCommons: [],
+ createdAt: "",
+ updateAt: "",
+ permissions: ["data_submission:view", "data_submission:create"],
+ notifications: [],
+};
+
+type TestParentProps = {
+ user?: Partial;
+ mocks?: MockedResponse[];
+ children: React.ReactNode;
+};
+
+const TestParent: React.FC = ({ mocks = [], user = {}, children }) => {
+ const authCtxValue = useMemo(
+ () => ({
+ ...baseAuthCtx,
+ user: { ...baseUser, ...user },
+ }),
+ [user]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+describe("Accessibility", () => {
+ it("should have no violations for the component", async () => {
+ const { container, getByTestId } = render(
+ ,
+ { wrapper: TestParent }
+ );
+
+ expect(getByTestId("cancel-restore-application-button")).not.toBeDisabled(); // Sanity check to ensure the button is active
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it("should have no violations for the component when disabled", async () => {
+ const { container, getByTestId } = render(, {
+ wrapper: TestParent,
+ });
+
+ expect(getByTestId("cancel-restore-application-button")).toBeDisabled(); // Sanity check to ensure the button is disabled
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
+
+describe("Basic Functionality", () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it("should render without crashing", () => {
+ expect(() =>
+ render(, { wrapper: TestParent })
+ ).not.toThrow();
+ });
+
+ it("should show a snackbar when the delete operation fails (GraphQL Error)", async () => {
+ const mocks: MockedResponse[] = [
+ {
+ request: {
+ query: CANCEL_APP,
+ },
+ variableMatcher: () => true,
+ result: {
+ errors: [new GraphQLError("Simulated GraphQL error")],
+ },
+ },
+ ];
+
+ const { getByTestId, getByRole } = render(
+ ,
+ { wrapper: (props) => }
+ );
+
+ // Open confirmation dialog
+ userEvent.click(getByTestId("cancel-restore-application-button"));
+
+ // Click dialog confirm button
+ const button = await within(getByRole("dialog")).findByRole("button", { name: /confirm/i });
+ userEvent.click(button);
+
+ await waitFor(() => {
+ expect(global.mockEnqueue).toHaveBeenCalledWith(
+ "An error occurred while deleting the selected rows.",
+ {
+ variant: "error",
+ }
+ );
+ });
+ });
+
+ it("should show a snackbar when the delete operation fails (Network Error)", async () => {
+ const mocks: MockedResponse[] = [
+ {
+ request: {
+ query: CANCEL_APP,
+ },
+ variableMatcher: () => true,
+ error: new Error("Simulated network error"),
+ },
+ ];
+
+ const { getByTestId, getByRole } = render(
+ ,
+ { wrapper: (props) => }
+ );
+
+ // Open confirmation dialog
+ userEvent.click(getByTestId("cancel-restore-application-button"));
+
+ // Click dialog confirm button
+ const button = await within(getByRole("dialog")).findByRole("button", { name: /confirm/i });
+ userEvent.click(button);
+
+ await waitFor(() => {
+ expect(global.mockEnqueue).toHaveBeenCalledWith(
+ "An error occurred while deleting the selected rows.",
+ {
+ variant: "error",
+ }
+ );
+ });
+ });
+
+ it("should show a snackbar when the delete operation fails (API Error)", async () => {
+ const mocks: MockedResponse[] = [
+ {
+ request: {
+ query: CANCEL_APP,
+ },
+ variableMatcher: () => true,
+ result: {
+ data: {
+ deleteDataRecords: {
+ success: false,
+ message: "Simulated API rejection message",
+ },
+ },
+ },
+ },
+ ];
+
+ const { getByTestId, getByRole } = render(
+ ,
+ { wrapper: (props) => }
+ );
+
+ // Open confirmation dialog
+ userEvent.click(getByTestId("cancel-restore-application-button"));
+
+ // Click dialog confirm button
+ const button = await within(getByRole("dialog")).findByRole("button", { name: /confirm/i });
+ userEvent.click(button);
+
+ await waitFor(() => {
+ expect(global.mockEnqueue).toHaveBeenCalledWith(
+ "An error occurred while deleting the selected rows.",
+ {
+ variant: "error",
+ }
+ );
+ });
+ });
+
+ it("should call the onCancel callback when the cancel/restore operation is successful", async () => {
+ const onDelete = jest.fn();
+ const mocks: MockedResponse[] = [
+ {
+ request: {
+ query: CANCEL_APP,
+ },
+ variableMatcher: () => true,
+ result: {
+ data: {
+ deleteDataRecords: {
+ success: true,
+ message: "",
+ },
+ },
+ },
+ },
+ ];
+
+ const { getByTestId, getByRole } = render(
+ ,
+ { wrapper: (props) => }
+ );
+
+ // Open confirmation dialog
+ userEvent.click(getByTestId("cancel-restore-application-button"));
+
+ // Click dialog confirm button
+ const button = await within(getByRole("dialog")).findByRole("button", { name: /confirm/i });
+ userEvent.click(button);
+
+ await waitFor(() => {
+ expect(onDelete).toHaveBeenCalled();
+ });
+ });
+});
+
+describe("Implementation Requirements", () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it("should be disabled when a cancel/restore operation is in progress", () => {
+ const { getByTestId } = render(, {
+ wrapper: (props) => ,
+ });
+
+ expect(getByTestId("cancel-restore-application-button")).toBeDisabled();
+ });
+
+ it("should restore the Submission Request only when the 'Confirm' button is clicked in the dialog", async () => {
+ const mockMatcher = jest.fn().mockImplementation(() => true);
+ const mocks: MockedResponse[] = [
+ {
+ request: {
+ query: CANCEL_APP,
+ },
+ variableMatcher: mockMatcher,
+ result: {
+ data: {
+ deleteDataRecords: {
+ success: false,
+ message: "Simulated API rejection message",
+ },
+ },
+ },
+ },
+ ];
+
+ const { getByTestId, getByRole } = render(
+ ,
+ {
+ wrapper: (props) => (
+
+ ),
+ }
+ );
+
+ expect(mockMatcher).not.toHaveBeenCalled();
+
+ userEvent.click(getByTestId("cancel-restore-application-button"));
+
+ const button = await within(getByRole("dialog")).findByRole("button", { name: /confirm/i });
+ userEvent.click(button);
+
+ await waitFor(() => {
+ expect(mockMatcher).toHaveBeenCalledWith(
+ expect.objectContaining({
+ _id: "mock-submission-id",
+ nodeType: "test-node-type",
+ nodeIds: ["ID_1", "ID_2", "ID_3"],
+ })
+ );
+ });
+ });
+
+ it("should cancel the Submission Request only when the 'Confirm' button is clicked in the dialog", async () => {
+ const mockMatcher = jest.fn().mockImplementation(() => true);
+ const mocks: MockedResponse[] = [
+ {
+ request: {
+ query: CANCEL_APP,
+ },
+ variableMatcher: mockMatcher,
+ result: {
+ data: {
+ deleteDataRecords: {
+ success: false,
+ message: "Simulated API rejection message",
+ },
+ },
+ },
+ },
+ ];
+
+ const { getByTestId, getByRole } = render(
+ ,
+ {
+ wrapper: (props) => (
+
+ ),
+ }
+ );
+
+ expect(mockMatcher).not.toHaveBeenCalled();
+
+ userEvent.click(getByTestId("cancel-restore-application-button"));
+
+ const button = await within(getByRole("dialog")).findByRole("button", { name: /confirm/i });
+ userEvent.click(button);
+
+ await waitFor(() => {
+ expect(mockMatcher).toHaveBeenCalledWith(
+ expect.objectContaining({
+ _id: "mock-submission-id",
+ nodeType: "test-node-type",
+ nodeIds: ["ID_1", "ID_2", "ID_3"],
+ })
+ );
+ });
+ });
+
+ it("should dismiss the dialog when the 'Cancel' button is clicked", async () => {
+ const { getByTestId, getByRole } = render(
+ ,
+ {
+ wrapper: TestParent,
+ }
+ );
+
+ userEvent.click(getByTestId("cancel-restore-application-button"));
+
+ const dialog = getByRole("dialog");
+ expect(dialog).toBeInTheDocument();
+
+ const button = await within(dialog).findByRole("button", { name: /cancel/i });
+ userEvent.click(button);
+
+ await waitFor(() => {
+ expect(() => getByRole("dialog")).toThrow();
+ });
+ });
+
+ it("should be visible and interactive when the user has the required permissions", async () => {
+ const { getByTestId } = render(, {
+ wrapper: (props) => (
+
+ ),
+ });
+
+ expect(getByTestId("cancel-restore-application-button")).toBeVisible();
+ });
+
+ it("should not be rendered when the user is missing the required permissions", async () => {
+ const { queryByTestId } = render(, {
+ wrapper: (props) => ,
+ });
+
+ expect(queryByTestId("cancel-restore-application-button")).not.toBeInTheDocument();
+ });
+
+ it.each(["New", "In Progress", "Rejected", "Withdrawn"])(
+ "should render as the 'Cancel' variant for the Submission Request status '%s'",
+ (status) => {
+ const { getByTestId } = render(, {
+ wrapper: (props) => ,
+ });
+
+ expect(getByTestId("cancel-restore-application-button")).toBeEnabled();
+ }
+ );
+
+ it.each(["Submitted", "Released", "Completed", "Canceled", "Deleted"])(
+ "should render as the 'Restore' variant for the Submission Request status '%s'",
+ (status) => {
+ const { getByTestId } = render(, {
+ wrapper: (props) => ,
+ });
+
+ expect(getByTestId("cancel-restore-application-button")).toBeDisabled();
+ }
+ );
+});
diff --git a/src/components/CancelApplicationButton/index.tsx b/src/components/CancelApplicationButton/index.tsx
new file mode 100644
index 000000000..80473716d
--- /dev/null
+++ b/src/components/CancelApplicationButton/index.tsx
@@ -0,0 +1,124 @@
+import { memo, useMemo, useState } from "react";
+import { isEqual } from "lodash";
+import { IconButton, IconButtonProps, styled } from "@mui/material";
+import { useSnackbar } from "notistack";
+import { useMutation } from "@apollo/client";
+import { ReactComponent as DeleteAllFilesIcon } from "../../assets/icons/delete_all_files_icon.svg";
+import DeleteDialog from "../DeleteDialog";
+import { useAuthContext } from "../Contexts/AuthContext";
+import { CANCEL_APP, CancelAppInput, CancelAppResp } from "../../graphql";
+import { Logger } from "../../utils";
+import { hasPermission } from "../../config/AuthPermissions";
+
+const StyledIconButton = styled(IconButton)(({ disabled }) => ({
+ opacity: disabled ? 0.26 : 1,
+}));
+
+/**
+ * The statuses of the application that can be restored from.
+ */
+const RESTORE_STATUSES: ApplicationStatus[] = ["Canceled", "Deleted"];
+
+type Props = {
+ /**
+ * The the application to be canceled/restored
+ */
+ application: Application | Omit;
+ /**
+ * Optional callback function for when successful cancellation/restoration occurs
+ */
+ onCancel?: () => void;
+} & Omit;
+
+const CancelApplicationButton = ({ application, onCancel, disabled, ...rest }: Props) => {
+ const { enqueueSnackbar } = useSnackbar();
+ const { user } = useAuthContext();
+ const { _id, status } = application;
+
+ const [cancelApp] = useMutation(CANCEL_APP, {
+ context: { clientName: "backend" },
+ fetchPolicy: "no-cache",
+ });
+
+ const [loading, setLoading] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ const isRestoreAction = useMemo(() => RESTORE_STATUSES.includes(status), [status]);
+
+ const textValues = useMemo(
+ () => ({
+ // TODO: Icon from the design
+ icon: isRestoreAction ? : ,
+ dialogTitle: `${isRestoreAction ? "Restore" : "Cancel"} Submission Request`,
+ dialogDescription: isRestoreAction
+ ? `Are you sure you want to restore the previously canceled submission request for the study listed below?`
+ : `Are you sure you want to cancel the submission request for the study listed below?`,
+ }),
+ [status, isRestoreAction]
+ );
+
+ const onClickIcon = async () => {
+ setConfirmOpen(true);
+ };
+
+ const onCloseDialog = async () => {
+ setConfirmOpen(false);
+ };
+
+ const onConfirmDialog = async () => {
+ try {
+ const { data: d, errors } = await cancelApp({
+ variables: { _id },
+ });
+
+ if (errors || !d?.cancelApplication?._id) {
+ Logger.error("Failed to cancel the application", errors);
+ throw new Error("Oops! Unable to cancel that Submission Request.");
+ }
+
+ setConfirmOpen(false);
+ onCancel();
+ } catch (err) {
+ enqueueSnackbar(err, { variant: "error" });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!hasPermission(user, "submission_request", "cancel", application)) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {textValues.icon}
+
+
+ {textValues.dialogDescription}
+
+
+ {/* TODO: technically this needs to be study name */}
+ Study: {application.studyAbbreviation || "N/A"}
+
+ }
+ confirmText="Confirm"
+ closeText="Cancel"
+ onConfirm={onConfirmDialog}
+ onClose={onCloseDialog}
+ />
+ >
+ );
+};
+
+export default memo(CancelApplicationButton, isEqual);
diff --git a/src/components/DeleteDialog/index.tsx b/src/components/DeleteDialog/index.tsx
index adc80d72b..b7984ae3f 100644
--- a/src/components/DeleteDialog/index.tsx
+++ b/src/components/DeleteDialog/index.tsx
@@ -76,7 +76,7 @@ const StyledDescription = styled(Typography)({
type Props = {
header?: string;
- description?: string;
+ description?: string | JSX.Element;
closeText?: string;
confirmText?: string;
onClose: () => void;
diff --git a/src/content/questionnaire/Contexts/QuestionnaireContext.tsx b/src/content/questionnaire/Contexts/QuestionnaireContext.tsx
index f6e9f33cc..ca58e688c 100644
--- a/src/content/questionnaire/Contexts/QuestionnaireContext.tsx
+++ b/src/content/questionnaire/Contexts/QuestionnaireContext.tsx
@@ -13,11 +13,9 @@ const QuestionnaireContext = React.createContext<{
application: ListApplicationsResp["listApplications"]["applications"][number]
) => void;
/**
- * Action performed when 'Cancel' or 'Restore' button is clicked
+ * A reference to the table's actions
*/
- handleOnCancelClick?: (
- application: ListApplicationsResp["listApplications"]["applications"][number]
- ) => void;
+ tableRef?: React.RefObject;
}>({});
export default QuestionnaireContext;
diff --git a/src/content/questionnaire/ListView.tsx b/src/content/questionnaire/ListView.tsx
index c340c6dfa..27516f187 100644
--- a/src/content/questionnaire/ListView.tsx
+++ b/src/content/questionnaire/ListView.tsx
@@ -1,4 +1,4 @@
-import React, { FC, useCallback, useMemo, useRef, useState } from "react";
+import { FC, useCallback, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import {
Alert,
@@ -35,6 +35,7 @@ import TruncatedText from "../../components/TruncatedText";
import StyledTooltip from "../../components/StyledFormComponents/StyledTooltip";
import Tooltip from "../../components/Tooltip";
import { hasPermission } from "../../config/AuthPermissions";
+import CancelApplicationButton from "../../components/CancelApplicationButton";
type T = ListApplicationsResp["listApplications"]["applications"][number];
@@ -209,51 +210,27 @@ const columns: Column[] = [
label: "Action",
renderValue: (a) => (
- {({ user, handleOnReviewClick, handleOnCancelClick }) => {
- const actions: React.ReactNode[] = [];
-
- // Delete/Restore Submission Request Actions
- if (hasPermission(user, "submission_request", "cancel", a)) {
- // TODO: Implement design for delete/restore actions
- actions.push(
- handleOnCancelClick(a)}
- key={`cancel-${a._id}`}
- bg="#F1C6B3"
- text="#5F564D"
- border="#DB9C62"
- >
- {/* TODO: Test coverage */}
- {["Canceled", "Deleted"].includes(a.status) ? "Restore" : "Cancel"}
-
- );
- }
-
- // Open Submission Request Actions
+ {({ user, handleOnReviewClick }) => {
if (
hasPermission(user, "submission_request", "create") &&
a.applicant?.applicantID === user._id &&
["New", "In Progress", "Inquired"].includes(a.status)
) {
- actions.push(
-
+ return (
+
Resume
);
- } else if (
+ }
+ if (
hasPermission(user, "submission_request", "review") &&
["Submitted", "In Review"].includes(a.status)
) {
- actions.push(
+ return (
handleOnReviewClick(a)}
- key={`review-${a._id}`}
bg="#F1C6B3"
text="#5F564D"
border="#DB9C62"
@@ -261,27 +238,37 @@ const columns: Column[] = [
Review
);
- } else if (hasPermission(user, "submission_request", "view")) {
- actions.push(
-
-
- View
-
-
- );
}
- return actions;
+ return (
+
+
+ View
+
+
+ );
}}
),
sortDisabled: true,
sx: {
width: "140px",
+ textAlign: "center",
+ },
+ },
+ {
+ label: "",
+ fieldKey: "secondary-action",
+ renderValue: (a) => (
+
+ {({ tableRef }) => (
+ tableRef.current?.refresh?.()} />
+ )}
+
+ ),
+ sortDisabled: true,
+ sx: {
+ width: "0px",
},
},
];
@@ -420,16 +407,9 @@ const ListingView: FC = () => {
[navigate, reviewApp]
);
- const handleOnCancelClick = useCallback(
- async ({ _id, status }: T) => null,
- [
- /* TODO: Deps */
- ]
- );
-
const providerValue = useMemo(
- () => ({ user, handleOnReviewClick, handleOnCancelClick }),
- [user, handleOnReviewClick, handleOnCancelClick]
+ () => ({ user, handleOnReviewClick, tableRef }),
+ [user, handleOnReviewClick, tableRef.current]
);
return (
diff --git a/src/graphql/cancelApplication.ts b/src/graphql/cancelApplication.ts
new file mode 100644
index 000000000..3763f5e94
--- /dev/null
+++ b/src/graphql/cancelApplication.ts
@@ -0,0 +1,21 @@
+import { TypedDocumentNode } from "@apollo/client";
+import gql from "graphql-tag";
+
+export const mutation: TypedDocumentNode = gql`
+ mutation cancelApplication($_id: ID!) {
+ cancelApplication: deleteApplication(_id: $_id) {
+ _id
+ }
+ }
+`;
+
+export type Input = {
+ /**
+ * ID of the application to cancel
+ */
+ _id: string;
+};
+
+export type Response = {
+ cancelApplication: Pick;
+};
diff --git a/src/graphql/index.ts b/src/graphql/index.ts
index b44e6cf71..9cc45796f 100644
--- a/src/graphql/index.ts
+++ b/src/graphql/index.ts
@@ -14,6 +14,10 @@ export type { Response as InquireAppResp } from "./inquireApplication";
export { mutation as REJECT_APP } from "./rejectApplication";
export type { Response as RejectAppResp } from "./rejectApplication";
+export { mutation as CANCEL_APP } from "./cancelApplication";
+export type { Response as CancelAppResp } from "./cancelApplication";
+export type { Input as CancelAppInput } from "./cancelApplication";
+
export { mutation as SAVE_APP } from "./saveApplication";
export type { Input as SaveAppInput, Response as SaveAppResp } from "./saveApplication";