chore: git modularisation disconnect modal (#37938)

## Description
Decouple and move disconnect modal

Fixes https://github.com/appsmithorg/appsmith/issues/37811

## Automation

/ok-to-test tags="@tag.Git"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/12155733900>
> Commit: f30ad803c1d26d6880fe549965d9dd364b3a1a4f
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=12155733900&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Git`
> Spec:
> <hr>Wed, 04 Dec 2024 09:17:09 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced the `DisconnectModal` component for disconnecting Git
repositories from applications.
- Added input validation for the application name with a "Revoke" button
that is conditionally enabled.
- Included a warning message about the irreversible action and a link to
documentation.

- **Tests**
- Implemented a comprehensive test suite for the `DisconnectModal` to
ensure correct functionality and user interactions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ashit Rath 2024-12-06 10:49:02 +05:30 committed by GitHub
parent 6d41e77b1e
commit a9471f06d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 311 additions and 0 deletions

View File

@ -0,0 +1,165 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import DisconnectModal from ".";
jest.mock("ee/utils/AnalyticsUtil", () => ({
logEvent: jest.fn(),
}));
describe("DisconnectModal", () => {
const defaultProps = {
isModalOpen: true,
disconnectingApp: {
id: "app123",
name: "TestApp",
},
closeModal: jest.fn(),
onBackClick: jest.fn(),
onDisconnect: jest.fn(),
};
afterEach(() => {
jest.clearAllMocks();
});
it("should render the modal when isModalOpen is true", () => {
render(<DisconnectModal {...defaultProps} />);
expect(screen.getByTestId("t--disconnect-git-modal")).toBeInTheDocument();
});
it("should not render the modal when isModalOpen is false", () => {
render(<DisconnectModal {...defaultProps} isModalOpen={false} />);
expect(
screen.queryByTestId("t--disconnect-git-modal"),
).not.toBeInTheDocument();
});
it("should display the correct modal header", () => {
render(<DisconnectModal {...defaultProps} />);
expect(screen.getByText("Revoke access to TestApp")).toBeInTheDocument();
});
it("should display the correct instruction text", () => {
render(<DisconnectModal {...defaultProps} />);
expect(
screen.getByText("Type “TestApp” in the input box to revoke access."),
).toBeInTheDocument();
});
it("should update appName state when input changes", () => {
render(<DisconnectModal {...defaultProps} />);
const input = screen.getByLabelText("Application name");
fireEvent.change(input, { target: { value: "TestApp" } });
expect(input).toHaveValue("TestApp");
});
it("should enable Revoke button when appName matches disconnectingApp.name", () => {
render(<DisconnectModal {...defaultProps} />);
const input = screen.getByLabelText("Application name");
const revokeButton = document.getElementsByClassName(
"t--git-revoke-button",
)[0];
expect(revokeButton).toBeDisabled();
fireEvent.change(input, { target: { value: "TestApp" } });
expect(revokeButton).toBeEnabled();
});
it("should disable Revoke button when appName does not match disconnectingApp.name", () => {
render(<DisconnectModal {...defaultProps} />);
const input = screen.getByLabelText("Application name");
const revokeButton = document.getElementsByClassName(
"t--git-revoke-button",
)[0];
fireEvent.change(input, { target: { value: "WrongAppName" } });
expect(revokeButton).toBeDisabled();
});
it("should call onBackClick when Go Back button is clicked", () => {
render(<DisconnectModal {...defaultProps} />);
const goBackButton = document.getElementsByClassName(
"t--git-revoke-back-button",
)[0];
fireEvent.click(goBackButton);
expect(defaultProps.onBackClick).toHaveBeenCalledTimes(1);
});
it("should call onDisconnect when Revoke button is clicked", () => {
render(<DisconnectModal {...defaultProps} />);
const input = screen.getByLabelText("Application name");
const revokeButton = document.getElementsByClassName(
"t--git-revoke-button",
)[0];
fireEvent.change(input, { target: { value: "TestApp" } });
fireEvent.click(revokeButton);
expect(defaultProps.onDisconnect).toHaveBeenCalledTimes(1);
});
it("should disable Revoke button when isRevoking is true", () => {
const { rerender } = render(<DisconnectModal {...defaultProps} />);
const input = screen.getByLabelText("Application name");
const revokeButton = document.getElementsByClassName(
"t--git-revoke-button",
)[0];
fireEvent.change(input, { target: { value: "TestApp" } });
expect(revokeButton).toBeEnabled();
fireEvent.click(revokeButton);
// Rerender to reflect state change
rerender(<DisconnectModal {...defaultProps} />);
expect(defaultProps.onDisconnect).toHaveBeenCalledTimes(1);
expect(revokeButton).toBeDisabled();
});
it("should log analytics event on input blur", () => {
render(<DisconnectModal {...defaultProps} />);
const input = screen.getByLabelText("Application name");
fireEvent.change(input, { target: { value: "SomeValue" } });
fireEvent.blur(input);
expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith(
"GS_MATCHING_REPO_NAME_ON_GIT_DISCONNECT_MODAL",
{
value: "SomeValue",
expecting: "TestApp",
},
);
});
it("should display callout with non-reversible message and learn more link", () => {
render(<DisconnectModal {...defaultProps} />);
expect(
screen.getByText(
"This action is non-reversible. Please proceed with caution.",
),
).toBeInTheDocument();
const learnMoreLink = screen.getByText("Learn more").parentElement;
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://docs.appsmith.com/advanced-concepts/version-control-with-git/disconnect-the-git-repository",
);
});
it("should not call onDisconnect when Revoke button is clicked and appName does not match", () => {
render(<DisconnectModal {...defaultProps} />);
const revokeButton = document.getElementsByClassName(
"t--git-revoke-button",
)[0];
fireEvent.click(revokeButton);
expect(defaultProps.onDisconnect).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,146 @@
import React, { useCallback, useState } from "react";
import {
Button,
Callout,
Flex,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Text,
} from "@appsmith/ads";
import {
APPLICATION_NAME,
createMessage,
GIT_REVOKE_ACCESS,
GIT_TYPE_REPO_NAME_FOR_REVOKING_ACCESS,
GO_BACK,
NONE_REVERSIBLE_MESSAGE,
REVOKE,
} from "ee/constants/messages";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import styled from "styled-components";
const DOCS_URL =
"https://docs.appsmith.com/advanced-concepts/version-control-with-git/disconnect-the-git-repository";
const DOCS_LINK_PROPS = [
{
children: "Learn more",
to: DOCS_URL,
className: "t--disconnect-learn-more",
},
];
const MODAL_WIDTH = 640;
interface DisconnectModalProps {
isModalOpen: boolean;
disconnectingApp: {
id: string;
name: string;
};
closeModal: () => void;
onBackClick: () => void;
onDisconnect: () => void;
}
const StyledModalContent = styled(ModalContent)`
width: ${MODAL_WIDTH}px;
`;
function DisconnectModal({
closeModal,
disconnectingApp,
isModalOpen,
onBackClick,
onDisconnect,
}: DisconnectModalProps) {
const [appName, setAppName] = useState("");
const [isRevoking, setIsRevoking] = useState(false);
const onDisconnectGit = useCallback(() => {
setIsRevoking(true);
onDisconnect();
}, [onDisconnect]);
const shouldDisableRevokeButton =
disconnectingApp.id === "" ||
appName !== disconnectingApp.name ||
isRevoking;
const onModalOpenValueChange = useCallback(
(open: boolean) => {
if (!open) {
closeModal();
}
},
[closeModal],
);
const inputOnBlur = useCallback(
(event: React.FocusEvent<Element, Element>) => {
AnalyticsUtil.logEvent("GS_MATCHING_REPO_NAME_ON_GIT_DISCONNECT_MODAL", {
value: "value" in event.target ? event.target.value : "",
expecting: disconnectingApp.name,
});
},
[disconnectingApp.name],
);
const inputOnChange = useCallback((value: string) => {
setAppName(value);
}, []);
return (
<Modal onOpenChange={onModalOpenValueChange} open={isModalOpen}>
<StyledModalContent data-testid="t--disconnect-git-modal">
<ModalHeader>
{createMessage(GIT_REVOKE_ACCESS, disconnectingApp.name)}
</ModalHeader>
<ModalBody>
<Flex flexDirection="column" gap="spaces-1">
<Text color={"var(--ads-v2-color-fg-emphasis)"} kind="heading-s">
{createMessage(
GIT_TYPE_REPO_NAME_FOR_REVOKING_ACCESS,
disconnectingApp.name,
)}
</Text>
<Input
className="t--git-app-name-input"
label={createMessage(APPLICATION_NAME)}
onBlur={inputOnBlur}
onChange={inputOnChange}
size="md"
value={appName}
/>
<Callout kind="error" links={DOCS_LINK_PROPS}>
{createMessage(NONE_REVERSIBLE_MESSAGE)}
</Callout>
</Flex>
</ModalBody>
<ModalFooter>
<Button
className="t--git-revoke-back-button"
kind="secondary"
onClick={onBackClick}
size="md"
>
{createMessage(GO_BACK)}
</Button>
<Button
className="t--git-revoke-button"
isDisabled={shouldDisableRevokeButton}
kind="primary"
onClick={onDisconnectGit}
size="md"
>
{createMessage(REVOKE)}
</Button>
</ModalFooter>
</StyledModalContent>
</Modal>
);
}
export default DisconnectModal;