diff --git a/app/client/src/api/ApiResponses.tsx b/app/client/src/api/ApiResponses.tsx
index c20272bca0da..b6e0f36d2f87 100644
--- a/app/client/src/api/ApiResponses.tsx
+++ b/app/client/src/api/ApiResponses.tsx
@@ -1,6 +1,7 @@
export interface APIResponseError {
code: string | number;
message: string;
+ errorType?: string;
}
export interface ResponseMeta {
diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts
index 836e409daab3..7cba3e4b788c 100644
--- a/app/client/src/ce/constants/messages.ts
+++ b/app/client/src/ce/constants/messages.ts
@@ -1069,6 +1069,10 @@ export const IS_EMPTY_REPO_QUESTION = () =>
export const HOW_TO_CREATE_EMPTY_REPO = () => "How to create a new repository?";
export const IMPORT_APP_IF_NOT_EMPTY = () =>
"If you already have an app connected to Git, you can import it to the workspace.";
+export const IMPORT_ARTIFACT_IF_NOT_EMPTY = (artifactType: string) =>
+ `If you already have an ${artifactType.toLocaleLowerCase()} connected to Git, you can import it to the workspace.`;
+export const I_HAVE_EXISTING_ARTIFACT_REPO = (artifactType: string) =>
+ `I have an existing appsmith ${artifactType.toLocaleLowerCase()} connected to Git`;
export const I_HAVE_EXISTING_REPO = () =>
"I have an existing appsmith app connected to Git";
export const ERROR_REPO_NOT_EMPTY_TITLE = () =>
diff --git a/app/client/src/git/components/ConnectModal/AddDeployKey.test.tsx b/app/client/src/git/components/ConnectModal/AddDeployKey.test.tsx
new file mode 100644
index 000000000000..632c67334731
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/AddDeployKey.test.tsx
@@ -0,0 +1,270 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import type { AddDeployKeyProps } from "./AddDeployKey";
+import AddDeployKey from "./AddDeployKey";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import "@testing-library/jest-dom";
+
+jest.mock("ee/utils/AnalyticsUtil", () => ({
+ logEvent: jest.fn(),
+}));
+
+jest.mock("copy-to-clipboard", () => ({
+ __esModule: true,
+ default: () => true,
+}));
+
+const DEFAULT_DOCS_URL =
+ "https://docs.appsmith.com/advanced-concepts/version-control-with-git/connecting-to-git-repository";
+
+const defaultProps: AddDeployKeyProps = {
+ isModalOpen: true,
+ onChange: jest.fn(),
+ value: {
+ gitProvider: "github",
+ isAddedDeployKey: false,
+ remoteUrl: "git@github.com:owner/repo.git",
+ },
+ fetchSSHKeyPair: jest.fn(),
+ generateSSHKey: jest.fn(),
+ isFetchingSSHKeyPair: false,
+ isGeneratingSSHKey: false,
+ sshKeyPair: "ecdsa-sha2-nistp256 AAAAE2VjZHNhAAAIBaj...",
+};
+
+describe("AddDeployKey Component", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders without crashing and shows default UI", () => {
+ render();
+ expect(
+ screen.getByText("Add deploy key & give write access"),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
+ // Should show ECDSA by default since sshKeyPair includes "ecdsa"
+ expect(screen.getByText(defaultProps.sshKeyPair)).toBeInTheDocument();
+ expect(
+ screen.getByText("I've added the deploy key and gave it write access"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls fetchSSHKeyPair if modal is open and not importing", () => {
+ render();
+ expect(defaultProps.fetchSSHKeyPair).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not call fetchSSHKeyPair if importing", () => {
+ render();
+ expect(defaultProps.fetchSSHKeyPair).not.toHaveBeenCalled();
+ });
+
+ it("shows dummy key loader if loading keys", () => {
+ render(
+ ,
+ );
+ // The actual key text should not be displayed
+ expect(screen.queryByText("ecdsa-sha2-nistp256")).not.toBeInTheDocument();
+ });
+
+ it("changes SSH key type when user selects a different type and triggers generateSSHKey if needed", async () => {
+ const generateSSHKey = jest.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.mouseDown(screen.getByRole("combobox"));
+ const rsaOption = screen.getByText("RSA 4096");
+
+ fireEvent.click(rsaOption);
+
+ await waitFor(() => {
+ expect(generateSSHKey).toHaveBeenCalledWith("RSA", expect.any(Object));
+ });
+ });
+
+ it("displays a generic error when errorData is provided and error code is not AE-GIT-4032 or AE-GIT-4033", () => {
+ // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
+ const errorData = {
+ data: {},
+ responseMeta: {
+ success: false,
+ status: 503,
+ error: {
+ code: "GENERIC-ERROR",
+ errorType: "Some Error",
+ message: "Something went wrong",
+ },
+ },
+ };
+
+ render();
+ expect(screen.getByText("Some Error")).toBeInTheDocument();
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ });
+
+ it("displays a misconfiguration error if error code is AE-GIT-4032", () => {
+ // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
+ const errorData = {
+ data: {},
+ responseMeta: {
+ success: false,
+ status: 503,
+ error: {
+ code: "AE-GIT-4032",
+ errorType: "SSH Key Error",
+ message: "SSH Key misconfiguration",
+ },
+ },
+ };
+
+ render();
+ expect(screen.getByText("SSH key misconfiguration")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "It seems that your SSH key hasn't been added to your repository. To proceed, please revisit the steps below and configure your SSH key correctly.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("invokes onChange callback when checkbox is toggled", () => {
+ const onChange = jest.fn();
+
+ render();
+ const checkbox = screen.getByTestId("t--added-deploy-key-checkbox");
+
+ fireEvent.click(checkbox);
+ expect(onChange).toHaveBeenCalledWith({ isAddedDeployKey: true });
+ });
+
+ it("calls AnalyticsUtil on copy button click", () => {
+ render();
+ const copyButton = screen.getByTestId("t--copy-generic");
+
+ fireEvent.click(copyButton);
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith(
+ "GS_COPY_SSH_KEY_BUTTON_CLICK",
+ );
+ });
+
+ it("hides copy button when connectLoading is true", () => {
+ render();
+ expect(screen.queryByTestId("t--copy-generic")).not.toBeInTheDocument();
+ });
+
+ it("shows repository settings link if gitProvider is known and not 'others'", () => {
+ render();
+ const link = screen.getByRole("link", { name: "repository settings." });
+
+ expect(link).toHaveAttribute(
+ "href",
+ "https://github.com/owner/repo/settings/keys",
+ );
+ });
+
+ it("does not show repository link if gitProvider = 'others'", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByRole("link", { name: "repository settings." }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows collapsible section if gitProvider is not 'others'", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("How to paste SSH Key in repo and give write access?"),
+ ).toBeInTheDocument();
+ expect(screen.getByAltText("Add deploy key in gitlab")).toBeInTheDocument();
+ });
+
+ it("does not display collapsible if gitProvider = 'others'", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByText("How to paste SSH Key in repo and give write access?"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("uses default documentation link if none provided", () => {
+ render();
+ const docsLink = screen.getByRole("link", { name: "Read Docs" });
+
+ expect(docsLink).toHaveAttribute("href", DEFAULT_DOCS_URL);
+ });
+
+ it("uses custom documentation link if provided", () => {
+ render(
+ ,
+ );
+ const docsLink = screen.getByRole("link", { name: "Read Docs" });
+
+ expect(docsLink).toHaveAttribute("href", "https://custom-docs.com");
+ });
+
+ it("does not generate SSH key if modal is closed", () => {
+ const generateSSHKey = jest.fn();
+
+ render(
+ ,
+ );
+ // Should not call generateSSHKey since modal is not open
+ expect(generateSSHKey).not.toHaveBeenCalled();
+ });
+
+ it("generates SSH key if none is present and conditions are met", async () => {
+ const fetchSSHKeyPair = jest.fn((props) => {
+ props.onSuccessCallback && props.onSuccessCallback();
+ });
+ const generateSSHKey = jest.fn();
+
+ render(
+ ,
+ );
+
+ expect(fetchSSHKeyPair).toHaveBeenCalledTimes(1);
+
+ await waitFor(() => {
+ expect(generateSSHKey).toHaveBeenCalledWith("ECDSA", expect.any(Object));
+ });
+ });
+});
diff --git a/app/client/src/git/components/ConnectModal/AddDeployKey.tsx b/app/client/src/git/components/ConnectModal/AddDeployKey.tsx
new file mode 100644
index 000000000000..fdce026b034b
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/AddDeployKey.tsx
@@ -0,0 +1,373 @@
+import React, { useCallback, useEffect, useState } from "react";
+import {
+ DemoImage,
+ ErrorCallout,
+ FieldContainer,
+ WellContainer,
+ WellText,
+ WellTitle,
+ WellTitleContainer,
+} from "./common";
+import {
+ Button,
+ Checkbox,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleHeader,
+ Icon,
+ Link,
+ Option,
+ Select,
+ Text,
+ toast,
+} from "@appsmith/ads";
+import styled from "styled-components";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import {
+ ADD_DEPLOY_KEY_STEP_TITLE,
+ CONSENT_ADDED_DEPLOY_KEY,
+ COPY_SSH_KEY,
+ ERROR_SSH_KEY_MISCONF_MESSAGE,
+ ERROR_SSH_KEY_MISCONF_TITLE,
+ HOW_TO_ADD_DEPLOY_KEY,
+ READ_DOCS,
+ createMessage,
+} from "ee/constants/messages";
+import type { GitProvider } from "./ChooseGitProvider";
+import { GIT_DEMO_GIF } from "./constants";
+import noop from "lodash/noop";
+import type { ApiResponse } from "api/ApiResponses";
+import CopyButton from "./CopyButton";
+
+export const DeployedKeyContainer = styled.div`
+ height: 36px;
+ border: 1px solid var(--ads-v2-color-border);
+ padding: 8px;
+ box-sizing: border-box;
+ border-radius: var(--ads-v2-border-radius);
+ background-color: #fff;
+ align-items: center;
+ display: flex;
+`;
+
+export const KeyType = styled.span`
+ font-size: 10px;
+ text-transform: uppercase;
+ color: var(--ads-v2-color-fg);
+ font-weight: 700;
+`;
+
+export const KeyText = styled.span`
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ flex: 1;
+ font-size: 10px;
+ color: var(--ads-v2-color-fg);
+ direction: rtl;
+ margin-right: 8px;
+`;
+
+const StyledSelect = styled(Select)`
+ margin-bottom: 4px;
+ background-color: white;
+ width: initial;
+
+ .rc-select-selector {
+ min-width: 100px;
+ }
+
+ input {
+ width: 100px !important;
+ }
+`;
+
+const CheckboxTextContainer = styled.div`
+ display: flex;
+ justify-content: flex-start;
+`;
+
+const DummyKey = styled.div`
+ height: 36px;
+
+ background: linear-gradient(
+ 90deg,
+ var(--ads-color-black-200) 0%,
+ rgba(240, 240, 240, 0) 100%
+ );
+`;
+
+const StyledLink = styled(Link)`
+ display: inline;
+`;
+
+const StyledIcon = styled(Icon)`
+ margin-right: var(--ads-v2-spaces-2);
+`;
+
+const getRepositorySettingsUrl = (
+ gitProvider?: GitProvider,
+ remoteUrl?: string,
+) => {
+ if (!gitProvider) {
+ return "";
+ }
+
+ const ownerRepo = remoteUrl?.split(":")?.[1]?.split(".git")?.[0];
+
+ if (!ownerRepo) {
+ return "";
+ }
+
+ switch (gitProvider) {
+ case "github":
+ return `https://github.com/${ownerRepo}/settings/keys`;
+ case "gitlab":
+ return `https://gitlab.com/${ownerRepo}/-/settings/repository`;
+ case "bitbucket":
+ return `https://bitbucket.org/${ownerRepo}/admin/access-keys/`;
+ default:
+ return "";
+ }
+};
+
+const DEFAULT_DOCS_URL =
+ "https://docs.appsmith.com/advanced-concepts/version-control-with-git/connecting-to-git-repository";
+
+interface AddDeployKeyState {
+ gitProvider?: GitProvider;
+ isAddedDeployKey: boolean;
+ remoteUrl: string;
+}
+
+interface Callback {
+ onSuccessCallback?: () => void;
+ onErrorCallback?: () => void;
+}
+
+export interface FetchSSHKeyPairProps extends Callback {}
+
+export interface AddDeployKeyProps {
+ isModalOpen: boolean;
+ onChange: (args: Partial) => void;
+ value: Partial;
+ isImport?: boolean;
+ errorData?: ApiResponse;
+ connectLoading?: boolean;
+ deployKeyDocUrl?: string;
+ isFetchingSSHKeyPair: boolean;
+ fetchSSHKeyPair: (props: FetchSSHKeyPairProps) => void;
+ generateSSHKey: (keyType: string, callback: Callback) => void;
+ isGeneratingSSHKey: boolean;
+ sshKeyPair: string;
+}
+
+function AddDeployKey({
+ connectLoading = false,
+ deployKeyDocUrl,
+ errorData,
+ fetchSSHKeyPair,
+ generateSSHKey,
+ isFetchingSSHKeyPair,
+ isGeneratingSSHKey,
+ isImport = false,
+ isModalOpen,
+ onChange = noop,
+ sshKeyPair,
+ value = {},
+}: AddDeployKeyProps) {
+ const [fetched, setFetched] = useState(false);
+ const [sshKeyType, setSshKeyType] = useState();
+
+ useEffect(
+ function fetchKeyPair() {
+ if (isModalOpen && !isImport) {
+ if (!fetched) {
+ fetchSSHKeyPair({
+ onSuccessCallback: () => {
+ setFetched(true);
+ },
+ onErrorCallback: () => {
+ setFetched(true);
+ },
+ });
+ }
+ } else {
+ if (!fetched) {
+ setFetched(true);
+ }
+ }
+ },
+ [isImport, isModalOpen, fetched, fetchSSHKeyPair],
+ );
+
+ useEffect(
+ function setKeyType() {
+ if (isModalOpen && fetched && !isFetchingSSHKeyPair) {
+ if (sshKeyPair && sshKeyPair.includes("rsa")) {
+ setSshKeyType("RSA");
+ } else if (
+ !sshKeyPair &&
+ value?.remoteUrl &&
+ value.remoteUrl.toString().toLocaleLowerCase().includes("azure")
+ ) {
+ setSshKeyType("RSA");
+ } else {
+ setSshKeyType("ECDSA");
+ }
+ }
+ },
+ [isModalOpen, fetched, sshKeyPair, isFetchingSSHKeyPair, value.remoteUrl],
+ );
+
+ useEffect(
+ function generateSSH() {
+ if (
+ isModalOpen &&
+ ((sshKeyType && !sshKeyPair) ||
+ (sshKeyType && !sshKeyPair?.includes(sshKeyType.toLowerCase())))
+ ) {
+ generateSSHKey(sshKeyType, {
+ onSuccessCallback: () => {
+ toast.show("SSH Key generated successfully", { kind: "success" });
+ },
+ });
+ }
+ },
+ [sshKeyType, sshKeyPair, isModalOpen, generateSSHKey],
+ );
+
+ const repositorySettingsUrl = getRepositorySettingsUrl(
+ value?.gitProvider,
+ value?.remoteUrl,
+ );
+
+ const loading = isFetchingSSHKeyPair || isGeneratingSSHKey;
+
+ const onCopy = useCallback(() => {
+ AnalyticsUtil.logEvent("GS_COPY_SSH_KEY_BUTTON_CLICK");
+ }, []);
+
+ const onDeployKeyAddedCheckChange = useCallback(
+ (isAddedDeployKey: boolean) => {
+ onChange({ isAddedDeployKey });
+ },
+ [onChange],
+ );
+
+ return (
+ <>
+ {errorData &&
+ errorData?.responseMeta?.error?.code !== "AE-GIT-4033" &&
+ errorData?.responseMeta?.error?.code !== "AE-GIT-4032" && (
+
+
+ {errorData?.responseMeta?.error?.errorType}
+
+ {errorData?.responseMeta?.error?.message}
+
+ )}
+
+ {/* hardcoding message because server doesn't support feature flag. Will change this later */}
+ {errorData && errorData?.responseMeta?.error?.code === "AE-GIT-4032" && (
+
+
+ {createMessage(ERROR_SSH_KEY_MISCONF_TITLE)}
+
+
+ {createMessage(ERROR_SSH_KEY_MISCONF_MESSAGE)}
+
+
+ )}
+
+
+
+
+ {createMessage(ADD_DEPLOY_KEY_STEP_TITLE)}
+
+
+
+
+
+ Copy below SSH key and paste it in your{" "}
+ {!!repositorySettingsUrl && value.gitProvider !== "others" ? (
+
+ repository settings.
+
+ ) : (
+ "repository settings."
+ )}{" "}
+ Now, give write access to it.
+
+
+
+
+
+
+ {!loading ? (
+
+
+ {sshKeyType}
+ {sshKeyPair}
+ {!connectLoading && (
+
+ )}
+
+ ) : (
+
+ )}
+
+ {value?.gitProvider !== "others" && (
+
+
+
+ {createMessage(HOW_TO_ADD_DEPLOY_KEY)}
+
+
+
+
+
+ )}
+
+
+
+ {createMessage(CONSENT_ADDED_DEPLOY_KEY)}
+
+ *
+
+
+
+ >
+ );
+}
+
+export default AddDeployKey;
diff --git a/app/client/src/git/components/ConnectModal/ChooseGitProvider.test.tsx b/app/client/src/git/components/ConnectModal/ChooseGitProvider.test.tsx
new file mode 100644
index 000000000000..8de7501e9602
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/ChooseGitProvider.test.tsx
@@ -0,0 +1,234 @@
+/* eslint-disable react-perf/jsx-no-new-object-as-prop */
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { GIT_DEMO_GIF } from "./constants";
+import "@testing-library/jest-dom";
+import ChooseGitProvider, { type GitProvider } from "./ChooseGitProvider";
+import { BrowserRouter as Router } from "react-router-dom";
+
+jest.mock("utils/hooks/useDeviceDetect", () => ({
+ useIsMobileDevice: jest.fn(() => false),
+}));
+
+const defaultProps = {
+ artifactId: "123",
+ artifactType: "application",
+ onChange: jest.fn(),
+ onImportFromCalloutLinkClick: jest.fn(),
+ value: {
+ gitProvider: undefined as GitProvider | undefined,
+ gitEmptyRepoExists: "",
+ gitExistingRepoExists: false,
+ },
+ isImport: false,
+ canCreateNewArtifact: true,
+};
+
+describe("ChooseGitProvider Component", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the component and initial fields", () => {
+ render();
+ expect(screen.getByText("Choose a Git provider")).toBeInTheDocument();
+ expect(
+ screen.getByText("i. To begin with, choose your Git service provider"),
+ ).toBeInTheDocument();
+
+ // Provider radios
+ expect(
+ screen.getByTestId("t--git-provider-radio-github"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("t--git-provider-radio-gitlab"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("t--git-provider-radio-bitbucket"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("t--git-provider-radio-others"),
+ ).toBeInTheDocument();
+ });
+
+ it("allows selecting a git provider and updates state via onChange", () => {
+ const onChange = jest.fn();
+
+ render();
+
+ const githubRadio = screen.getByTestId("t--git-provider-radio-github");
+
+ fireEvent.click(githubRadio);
+ expect(onChange).toHaveBeenCalledWith({ gitProvider: "github" });
+ });
+
+ it("disables the second question (empty repo) if no git provider selected", () => {
+ render();
+ // The empty repo radios should be disabled initially
+ const yesRadio = screen.getByTestId(
+ "t--existing-empty-repo-yes",
+ ) as HTMLInputElement;
+ const noRadio = screen.getByTestId(
+ "t--existing-empty-repo-no",
+ ) as HTMLInputElement;
+
+ expect(yesRadio).toBeDisabled();
+ expect(noRadio).toBeDisabled();
+ });
+
+ it("enables empty repo question after provider is selected", () => {
+ const onChange = jest.fn();
+
+ render(
+ ,
+ );
+
+ const yesRadio = screen.getByTestId(
+ "t--existing-empty-repo-yes",
+ ) as HTMLInputElement;
+ const noRadio = screen.getByTestId(
+ "t--existing-empty-repo-no",
+ ) as HTMLInputElement;
+
+ expect(yesRadio).not.toBeDisabled();
+ expect(noRadio).not.toBeDisabled();
+ });
+
+ it("calls onChange when empty repo question changes", () => {
+ const onChange = jest.fn();
+
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByTestId("t--existing-empty-repo-no"));
+ expect(onChange).toHaveBeenCalledWith({ gitEmptyRepoExists: "no" });
+ });
+
+ it("displays the collapsible instructions if gitEmptyRepoExists = no and provider != others", () => {
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.getByText("How to create a new repository?"),
+ ).toBeInTheDocument();
+
+ // Check if DemoImage is rendered
+ const img = screen.getByAltText("Create an empty repo in github");
+
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute("src", GIT_DEMO_GIF.create_repo.github);
+ });
+
+ it("displays a warning callout if gitEmptyRepoExists = no and provider = others", () => {
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.getByText(
+ "You need an empty repository to connect to Git on Appsmith, please create one on your Git service provider to continue.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the import callout if gitEmptyRepoExists = no and not in import mode", () => {
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.getByText(
+ "If you already have an application connected to Git, you can import it to the workspace.",
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Import via git")).toBeInTheDocument();
+ });
+
+ it("clicking on 'Import via git' link calls onImportFromCalloutLinkClick", () => {
+ const onImportFromCalloutLinkClick = jest.fn();
+
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText("Import via git"));
+ expect(onImportFromCalloutLinkClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("when isImport = true, shows a checkbox for existing repo", () => {
+ render();
+ expect(screen.getByTestId("t--existing-repo-checkbox")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "I have an existing appsmith application connected to Git",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("toggles existing repo checkbox and calls onChange", () => {
+ const onChange = jest.fn();
+
+ render(
+ ,
+ );
+ const checkbox = screen.getByTestId("t--existing-repo-checkbox");
+
+ fireEvent.click(checkbox);
+ expect(onChange).toHaveBeenCalledWith({ gitExistingRepoExists: true });
+ });
+
+ it("does not show second question if isImport = true", () => {
+ render();
+ expect(
+ screen.queryByText("ii. Does an empty repository exist?"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("respects canCreateNewArtifact and device conditions for links", () => {
+ // If canCreateNewArtifact is false, "Import via git" should not appear even if conditions are met
+ render(
+ ,
+ );
+ // This should be null because we have no permission to create new artifact
+ expect(screen.queryByText("Import via git")).not.toBeInTheDocument();
+ });
+
+ it("if provider is not chosen and user tries to select empty repo option, it remains disabled", () => {
+ render();
+ const yesRadio = screen.getByTestId("t--existing-empty-repo-yes");
+
+ fireEvent.click(yesRadio);
+ // onChange should not be called because it's disabled
+ expect(defaultProps.onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/client/src/git/components/ConnectModal/ChooseGitProvider.tsx b/app/client/src/git/components/ConnectModal/ChooseGitProvider.tsx
new file mode 100644
index 000000000000..3fcd9349c2ff
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/ChooseGitProvider.tsx
@@ -0,0 +1,233 @@
+import React, { useCallback, useMemo } from "react";
+import {
+ DemoImage,
+ FieldContainer,
+ FieldControl,
+ FieldQuestion,
+ WellContainer,
+ WellTitle,
+ WellTitleContainer,
+} from "./common";
+import {
+ Callout,
+ Checkbox,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleHeader,
+ Icon,
+ Radio,
+ RadioGroup,
+ Text,
+} from "@appsmith/ads";
+import styled from "styled-components";
+import { GIT_DEMO_GIF } from "./constants";
+import noop from "lodash/noop";
+import { useIsMobileDevice } from "utils/hooks/useDeviceDetect";
+import {
+ CHOOSE_A_GIT_PROVIDER_STEP,
+ CHOOSE_GIT_PROVIDER_QUESTION,
+ HOW_TO_CREATE_EMPTY_REPO,
+ IMPORT_ARTIFACT_IF_NOT_EMPTY,
+ IS_EMPTY_REPO_QUESTION,
+ I_HAVE_EXISTING_ARTIFACT_REPO,
+ NEED_EMPTY_REPO_MESSAGE,
+ createMessage,
+} from "ee/constants/messages";
+import log from "loglevel";
+
+const WellInnerContainer = styled.div`
+ padding-left: 16px;
+`;
+
+const CheckboxTextContainer = styled.div`
+ display: flex;
+ justify-content: flex-start;
+`;
+
+const GIT_PROVIDERS = ["github", "gitlab", "bitbucket", "others"] as const;
+
+export type GitProvider = (typeof GIT_PROVIDERS)[number];
+
+interface ChooseGitProviderState {
+ gitProvider?: GitProvider;
+ gitEmptyRepoExists: string;
+ gitExistingRepoExists: boolean;
+}
+interface ChooseGitProviderProps {
+ artifactId: string;
+ artifactType: string;
+ onChange: (args: Partial) => void;
+ value: Partial;
+ isImport?: boolean;
+ // Replaces handleImport in original ChooseGitProvider.tsx
+ onImportFromCalloutLinkClick: () => void;
+ // Replaces hasCreateNewApplicationPermission = hasCreateNewAppPermission(workspace.userPermissions)
+ canCreateNewArtifact: boolean;
+}
+
+function ChooseGitProvider({
+ artifactType,
+ canCreateNewArtifact,
+ isImport = false,
+ onChange = noop,
+ onImportFromCalloutLinkClick,
+ value = {},
+}: ChooseGitProviderProps) {
+ const isMobile = useIsMobileDevice();
+
+ const hasCreateNewArtifactPermission = canCreateNewArtifact && !isMobile;
+
+ const onGitProviderChange = useCallback(
+ (value: string) => {
+ const gitProvider = GIT_PROVIDERS.includes(value as GitProvider)
+ ? (value as GitProvider)
+ : undefined;
+
+ if (gitProvider) {
+ onChange({ gitProvider });
+ } else {
+ log.error(`Invalid git provider: ${value}`);
+ }
+ },
+ [onChange],
+ );
+
+ const onEmptyRepoOptionChange = useCallback(
+ (gitEmptyRepoExists) => onChange({ gitEmptyRepoExists }),
+ [onChange],
+ );
+
+ const onExistingRepoOptionChange = useCallback(
+ (gitExistingRepoExists) => onChange({ gitExistingRepoExists }),
+ [onChange],
+ );
+
+ const importCalloutLinks = useMemo(() => {
+ return hasCreateNewArtifactPermission
+ ? [{ children: "Import via git", onClick: onImportFromCalloutLinkClick }]
+ : [];
+ }, [hasCreateNewArtifactPermission, onImportFromCalloutLinkClick]);
+
+ return (
+ <>
+
+
+
+ {createMessage(CHOOSE_A_GIT_PROVIDER_STEP)}
+
+
+
+
+
+ i. {createMessage(CHOOSE_GIT_PROVIDER_QUESTION)}{" "}
+ *
+
+
+
+
+ Github
+
+
+ Gitlab
+
+
+ Bitbucket
+
+
+ Others
+
+
+
+
+ {!isImport && (
+
+
+ ii. {createMessage(IS_EMPTY_REPO_QUESTION)}{" "}
+ *
+
+
+
+
+ Yes
+
+
+ No
+
+
+
+
+ )}
+ {!isImport &&
+ value?.gitProvider !== "others" &&
+ value?.gitEmptyRepoExists === "no" && (
+
+
+
+ {createMessage(HOW_TO_CREATE_EMPTY_REPO)}
+
+
+
+
+
+ )}
+ {!isImport &&
+ value?.gitProvider === "others" &&
+ value?.gitEmptyRepoExists === "no" && (
+
+ {createMessage(NEED_EMPTY_REPO_MESSAGE)}
+
+ )}
+
+
+ {!isImport && value?.gitEmptyRepoExists === "no" ? (
+
+ {createMessage(IMPORT_ARTIFACT_IF_NOT_EMPTY, artifactType)}
+
+ ) : null}
+ {isImport && (
+
+
+
+ {createMessage(I_HAVE_EXISTING_ARTIFACT_REPO, artifactType)}
+
+
+ *
+
+
+
+ )}
+ >
+ );
+}
+
+export default ChooseGitProvider;
diff --git a/app/client/src/git/components/ConnectModal/CopyButton.tsx b/app/client/src/git/components/ConnectModal/CopyButton.tsx
new file mode 100644
index 000000000000..e1ac17da33f2
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/CopyButton.tsx
@@ -0,0 +1,99 @@
+import type { CSSProperties } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { Button, Icon, Tooltip } from "@appsmith/ads";
+import styled from "styled-components";
+import copy from "copy-to-clipboard";
+import noop from "lodash/noop";
+import log from "loglevel";
+
+export const TooltipWrapper = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`;
+
+export const IconContainer = styled.div``;
+
+interface CopyButtonProps {
+ style?: CSSProperties;
+ value?: string;
+ delay?: number;
+ onCopy?: () => void;
+ tooltipMessage?: string;
+ isDisabled?: boolean;
+ testIdSuffix?: string;
+}
+
+function CopyButton({
+ delay = 2000,
+ isDisabled = false,
+ onCopy = noop,
+ style,
+ testIdSuffix = "generic",
+ tooltipMessage,
+ value,
+}: CopyButtonProps) {
+ const timerRef = useRef();
+ const [showCopied, setShowCopied] = useState(false);
+
+ useEffect(function clearShowCopiedTimeout() {
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, []);
+
+ const stopShowingCopiedAfterDelay = useCallback(() => {
+ timerRef.current = setTimeout(() => {
+ setShowCopied(false);
+ }, delay);
+ }, [delay]);
+
+ const copyToClipboard = useCallback(() => {
+ if (value) {
+ try {
+ const success = copy(value);
+
+ if (success) {
+ setShowCopied(true);
+ stopShowingCopiedAfterDelay();
+ onCopy();
+ }
+ } catch (error) {
+ log.error("Failed to copy to clipboard:", error);
+ }
+ }
+ }, [onCopy, stopShowingCopiedAfterDelay, value]);
+
+ return (
+ <>
+ {showCopied ? (
+
+
+
+ ) : (
+
+
+
+
+
+ )}{" "}
+ >
+ );
+}
+
+export default CopyButton;
diff --git a/app/client/src/git/components/ConnectModal/GenerateSSH.test.tsx b/app/client/src/git/components/ConnectModal/GenerateSSH.test.tsx
new file mode 100644
index 000000000000..36a0c0ab4a7f
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/GenerateSSH.test.tsx
@@ -0,0 +1,146 @@
+/* eslint-disable react-perf/jsx-no-new-object-as-prop */
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { isValidGitRemoteUrl } from "../utils";
+import GenerateSSH from "./GenerateSSH";
+import type { GitProvider } from "./ChooseGitProvider";
+import "@testing-library/jest-dom";
+
+jest.mock("../utils", () => ({
+ isValidGitRemoteUrl: jest.fn(),
+}));
+
+const defaultProps = {
+ onChange: jest.fn(),
+ value: {
+ gitProvider: "github" as GitProvider,
+ remoteUrl: "",
+ },
+};
+
+describe("GenerateSSH Component", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the component correctly", () => {
+ render();
+ expect(screen.getByText("Generate SSH key")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("git-connect-remote-url-input"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders an error callout when errorData has code 'AE-GIT-4033'", () => {
+ const errorData = {
+ data: {},
+ responseMeta: {
+ status: 503,
+ success: false,
+ error: {
+ message: "",
+ code: "AE-GIT-4033",
+ },
+ },
+ };
+
+ render();
+ expect(
+ screen.getByText("The repo you added isn't empty"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Kindly create a new repository and provide its remote SSH URL here. We require an empty repository to continue.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render error callout for other error codes", () => {
+ const errorData = {
+ data: {},
+ responseMeta: {
+ status: 503,
+ success: false,
+ error: {
+ message: "",
+ code: "SOME_OTHER_ERROR",
+ },
+ },
+ };
+
+ render();
+ expect(
+ screen.queryByText("The repo you added isn't empty"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("handles remote URL input changes", () => {
+ const onChange = jest.fn();
+
+ render();
+ const input = screen.getByTestId("git-connect-remote-url-input");
+
+ fireEvent.change(input, {
+ target: { value: "git@example.com:user/repo.git" },
+ });
+ expect(onChange).toHaveBeenCalledWith({
+ remoteUrl: "git@example.com:user/repo.git",
+ });
+ });
+
+ it("shows an error message if remote URL is invalid", async () => {
+ (isValidGitRemoteUrl as jest.Mock).mockReturnValue(false);
+
+ render();
+ const input = screen.getByTestId("git-connect-remote-url-input");
+
+ fireEvent.change(input, { target: { value: "invalid-url" } });
+ fireEvent.blur(input); // Trigger validation
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Please enter a valid SSH URL of your repository"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("does not show an error message for a valid remote URL", async () => {
+ (isValidGitRemoteUrl as jest.Mock).mockReturnValue(true);
+
+ render();
+ const input = screen.getByTestId("git-connect-remote-url-input");
+
+ fireEvent.change(input, {
+ target: { value: "git@example.com:user/repo.git" },
+ });
+ fireEvent.blur(input); // Trigger validation
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Please enter a valid SSH URL of your repository"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("renders the collapsible section if gitProvider is not 'others'", () => {
+ render();
+ expect(
+ screen.getByText("How to copy & paste SSH remote URL"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByAltText("Copy and paste remote url from github"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render the collapsible section if gitProvider is 'others'", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByText("How to copy & paste SSH remote URL"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/app/client/src/git/components/ConnectModal/GenerateSSH.tsx b/app/client/src/git/components/ConnectModal/GenerateSSH.tsx
new file mode 100644
index 000000000000..c598cce17dad
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/GenerateSSH.tsx
@@ -0,0 +1,133 @@
+import React, { useCallback, useState } from "react";
+import noop from "lodash/noop";
+import {
+ DemoImage,
+ ErrorCallout,
+ FieldContainer,
+ WellContainer,
+ WellText,
+ WellTitle,
+ WellTitleContainer,
+} from "./common";
+import {
+ Button,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleHeader,
+ Icon,
+ Input,
+ Text,
+} from "@appsmith/ads";
+import {
+ COPY_SSH_URL_MESSAGE,
+ ERROR_REPO_NOT_EMPTY_MESSAGE,
+ ERROR_REPO_NOT_EMPTY_TITLE,
+ GENERATE_SSH_KEY_STEP,
+ HOW_TO_COPY_REMOTE_URL,
+ PASTE_SSH_URL_INFO,
+ READ_DOCS,
+ REMOTE_URL_INPUT_LABEL,
+ createMessage,
+} from "ee/constants/messages";
+import { GIT_DEMO_GIF } from "./constants";
+import { isValidGitRemoteUrl } from "../utils";
+import type { GitProvider } from "./ChooseGitProvider";
+import type { ApiResponse } from "api/ApiResponses";
+
+interface GenerateSSHState {
+ gitProvider?: GitProvider;
+ remoteUrl: string;
+}
+interface GenerateSSHProps {
+ onChange: (args: Partial) => void;
+ value: Partial;
+ errorData?: ApiResponse;
+}
+
+const CONNECTING_TO_GIT_DOCS_URL =
+ "https://docs.appsmith.com/advanced-concepts/version-control-with-git/connecting-to-git-repository";
+
+function GenerateSSH({
+ errorData,
+ onChange = noop,
+ value = {},
+}: GenerateSSHProps) {
+ const [isTouched, setIsTouched] = useState(false);
+ const isInvalid =
+ isTouched &&
+ (typeof value?.remoteUrl !== "string" ||
+ !isValidGitRemoteUrl(value?.remoteUrl));
+
+ const handleChange = useCallback(
+ (remoteUrl: string) => {
+ setIsTouched(true);
+ onChange({ remoteUrl });
+ },
+ [onChange],
+ );
+
+ return (
+ <>
+ {/* hardcoding messages because server doesn't support feature flag. Will change this later */}
+ {errorData && errorData?.responseMeta?.error?.code === "AE-GIT-4033" && (
+
+
+ {createMessage(ERROR_REPO_NOT_EMPTY_TITLE)}
+
+
+ {createMessage(ERROR_REPO_NOT_EMPTY_MESSAGE)}
+
+
+ )}
+
+
+
+ {createMessage(GENERATE_SSH_KEY_STEP)}
+
+
+
+ {createMessage(COPY_SSH_URL_MESSAGE)}
+
+
+
+ {value?.gitProvider !== "others" && (
+
+
+
+ {createMessage(HOW_TO_COPY_REMOTE_URL)}
+
+
+
+
+
+ )}
+
+ >
+ );
+}
+
+export default GenerateSSH;
diff --git a/app/client/src/git/components/ConnectModal/Steps.tsx b/app/client/src/git/components/ConnectModal/Steps.tsx
new file mode 100644
index 000000000000..a67af1e1c4fd
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/Steps.tsx
@@ -0,0 +1,121 @@
+import { Button, Divider, Text } from "@appsmith/ads";
+import noop from "lodash/noop";
+import React, { Fragment } from "react";
+import styled from "styled-components";
+
+const Container = styled.div`
+ display: flex;
+ margin-bottom: 16px;
+ align-items: center;
+`;
+
+const StepButton = styled(Button)`
+ display: flex;
+ align-items: center;
+
+ .ads-v2-button__content {
+ padding: 4px;
+ }
+
+ .ads-v2-button__content-children {
+ font-weight: var(--ads-v2-font-weight-bold);
+ }
+
+ .ads-v2-button__content-children > * {
+ font-weight: var(--ads-v2-font-weight-bold);
+ }
+
+ opacity: ${({ isDisabled }) => (isDisabled ? "0.6" : "1")};
+`;
+
+interface StepNumberProps {
+ active: boolean;
+}
+
+const StepNumber = styled.div`
+ width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ border-style: solid;
+ border-width: 1px;
+ border-color: ${(p) =>
+ p.active
+ ? "var(--ads-v2-color-border-success)"
+ : "var(--ads-v2-color-border-emphasis)"};
+ background-color: ${(p) =>
+ p.active
+ ? "var(--ads-v2-color-bg-success)"
+ : "var(--ads-v2-color-bg-subtle)"};
+ color: ${(p) =>
+ p.active
+ ? "var(--ads-v2-color-border-success)"
+ : "var(--ads-v2-color-text)"};
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ line-height: 1;
+ margin-right: 4px;
+ flex-shrink: 0;
+`;
+
+const StepText = styled(Text)`
+ font-weight: 500;
+`;
+
+const StepLine = styled(Divider)`
+ width: initial;
+ margin-left: 8px;
+ margin-right: 8px;
+ flex: 1;
+`;
+
+interface Step {
+ key: string;
+ text: string;
+}
+
+interface StepsProps {
+ steps: Step[];
+ activeKey: string;
+ onActiveKeyChange: (activeKey: string) => void;
+}
+
+function Steps({
+ activeKey,
+ onActiveKeyChange = noop,
+ steps = [],
+}: StepsProps) {
+ const activeIndex = steps.findIndex((s) => s.key === activeKey);
+
+ const onClick = (step: Step, index: number) => () => {
+ if (index < activeIndex) {
+ onActiveKeyChange(step.key);
+ }
+ };
+
+ return (
+
+ {steps.map((step, index) => {
+ return (
+
+ {index > 0 && }
+ activeIndex}
+ kind="tertiary"
+ onClick={onClick(step, index)}
+ role="button"
+ size="md"
+ >
+
+ {index + 1}
+
+ {step.text}
+
+
+ );
+ })}
+
+ );
+}
+
+export default Steps;
diff --git a/app/client/src/git/components/ConnectModal/common.tsx b/app/client/src/git/components/ConnectModal/common.tsx
new file mode 100644
index 000000000000..cd69abf40ea3
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/common.tsx
@@ -0,0 +1,52 @@
+import { Callout, Text } from "@appsmith/ads";
+import styled from "styled-components";
+
+export const WellContainer = styled.div`
+ padding: 16px;
+ border-radius: 4px;
+ background-color: var(--ads-v2-color-gray-100);
+ margin-bottom: 16px;
+ flex: 1;
+ flex-shrink: 1;
+ overflow-y: auto;
+`;
+
+export const WellTitleContainer = styled.div`
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+ justify-content: space-between;
+`;
+
+export const WellTitle = styled(Text)`
+ font-weight: 600;
+`;
+
+export const WellText = styled(Text)`
+ margin-bottom: 16px;
+`;
+
+export const FieldContainer = styled.div`
+ margin-bottom: 16px;
+`;
+
+export const FieldControl = styled.div`
+ padding-left: 24px;
+`;
+
+export const FieldQuestion = styled(Text)<{ isDisabled?: boolean }>`
+ opacity: ${({ isDisabled = false }) => (isDisabled ? "0.5" : "1")};
+ margin-bottom: 16px;
+`;
+
+export const DemoImage = styled.img`
+ width: 100%;
+ height: 300px;
+ object-fit: cover;
+ object-position: 50% 0;
+ background-color: var(--ads-color-black-200);
+`;
+
+export const ErrorCallout = styled(Callout)`
+ margin-bottom: 16px;
+`;
diff --git a/app/client/src/git/components/ConnectModal/constants.ts b/app/client/src/git/components/ConnectModal/constants.ts
new file mode 100644
index 000000000000..a15001af865d
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/constants.ts
@@ -0,0 +1,26 @@
+import { getAssetUrl } from "ee/utils/airgapHelpers";
+import { ASSETS_CDN_URL } from "constants/ThirdPartyConstants";
+
+export const GIT_CONNECT_STEPS = {
+ CHOOSE_PROVIDER: "choose-provider",
+ GENERATE_SSH_KEY: "generate-ssh-key",
+ ADD_DEPLOY_KEY: "add-deploy-key",
+};
+
+export const GIT_DEMO_GIF = {
+ create_repo: {
+ github: getAssetUrl(`${ASSETS_CDN_URL}/Github_create_empty_repo.gif`),
+ gitlab: getAssetUrl(`${ASSETS_CDN_URL}/Gitlab_create_a_repo.gif`),
+ bitbucket: getAssetUrl(`${ASSETS_CDN_URL}/Bitbucket_create_a_repo.gif`),
+ },
+ copy_remoteurl: {
+ github: getAssetUrl(`${ASSETS_CDN_URL}/Github_SSHkey.gif`),
+ gitlab: getAssetUrl(`${ASSETS_CDN_URL}/Gitlab_SSHKey.gif`),
+ bitbucket: getAssetUrl(`${ASSETS_CDN_URL}/Bitbucket_Copy_SSHKey.gif`),
+ },
+ add_deploykey: {
+ github: getAssetUrl(`${ASSETS_CDN_URL}/Github_add_deploykey.gif`),
+ gitlab: getAssetUrl(`${ASSETS_CDN_URL}/Gitlab_add_deploy_key.gif`),
+ bitbucket: getAssetUrl(`${ASSETS_CDN_URL}/Bitbucket_add_a_deploykey.gif`),
+ },
+};
diff --git a/app/client/src/git/components/ConnectModal/index.test.tsx b/app/client/src/git/components/ConnectModal/index.test.tsx
new file mode 100644
index 000000000000..71d5b27d7a65
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/index.test.tsx
@@ -0,0 +1,216 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { isValidGitRemoteUrl } from "../utils";
+import ConnectModal from ".";
+import "@testing-library/jest-dom";
+
+jest.mock("ee/utils/AnalyticsUtil", () => ({
+ logEvent: jest.fn(),
+}));
+
+jest.mock("../utils", () => ({
+ isValidGitRemoteUrl: jest.fn(),
+}));
+
+const defaultProps = {
+ artifactId: "artifact-123",
+ artifactType: "application",
+ canCreateNewArtifact: true,
+ connectTo: jest.fn(),
+ importFrom: jest.fn(),
+ isConnecting: false,
+ isImport: false,
+ isImporting: false,
+ onImportFromCalloutLinkClick: jest.fn(),
+ deployKeyDocUrl: "https://docs.example.com",
+ fetchSSHKeyPair: jest.fn(),
+ generateSSHKey: jest.fn(),
+ isFetchingSSHKeyPair: false,
+ isGeneratingSSHKey: false,
+ sshKeyPair: "ssh-rsa AAAAB3...",
+ isModalOpen: true,
+};
+
+function completeChooseProviderStep(isImport = false) {
+ fireEvent.click(screen.getByTestId("t--git-provider-radio-github"));
+
+ if (isImport) {
+ fireEvent.click(screen.getByTestId("t--existing-repo-checkbox"));
+ } else {
+ fireEvent.click(screen.getByTestId("t--existing-empty-repo-yes"));
+ }
+
+ fireEvent.click(screen.getByTestId("t--git-connect-next-button"));
+}
+
+function completeGenerateSSHKeyStep() {
+ fireEvent.change(screen.getByTestId("git-connect-remote-url-input"), {
+ target: { value: "git@example.com:user/repo.git" },
+ });
+ fireEvent.click(screen.getByTestId("t--git-connect-next-button"));
+}
+
+function completeAddDeployKeyStep() {
+ fireEvent.click(screen.getByTestId("t--added-deploy-key-checkbox"));
+ fireEvent.click(screen.getByTestId("t--git-connect-next-button"));
+}
+
+describe("ConnectModal Component", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (isValidGitRemoteUrl as jest.Mock).mockImplementation((url) =>
+ url.startsWith("git@"),
+ );
+ });
+
+ it("renders the initial step (ChooseGitProvider)", () => {
+ render();
+ expect(
+ screen.getByText("i. To begin with, choose your Git service provider"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("t--git-connect-next-button")).toHaveTextContent(
+ "Configure Git",
+ );
+ });
+
+ it("disables the next button when form data is incomplete in ChooseGitProvider step", () => {
+ render();
+ expect(screen.getByTestId("t--git-connect-next-button")).toBeDisabled();
+ });
+
+ it("navigates to the next step (GenerateSSH) and validates SSH URL input", () => {
+ render();
+
+ completeChooseProviderStep();
+
+ const sshInput = screen.getByTestId("git-connect-remote-url-input");
+
+ fireEvent.change(sshInput, { target: { value: "invalid-url" } });
+ fireEvent.blur(sshInput);
+
+ expect(
+ screen.getByText("Please enter a valid SSH URL of your repository"),
+ ).toBeInTheDocument();
+
+ fireEvent.change(sshInput, {
+ target: { value: "git@example.com:user/repo.git" },
+ });
+ fireEvent.blur(sshInput);
+
+ expect(
+ screen.queryByText("Please enter a valid SSH URL of your repository"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders AddDeployKey step and validates state transitions", () => {
+ render();
+
+ completeChooseProviderStep();
+ completeGenerateSSHKeyStep();
+
+ expect(
+ screen.getByText("Add deploy key & give write access"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls connectTo on completing AddDeployKey step in connect mode", async () => {
+ render();
+ completeChooseProviderStep();
+ completeGenerateSSHKeyStep();
+ completeAddDeployKeyStep();
+
+ await waitFor(() => {
+ expect(defaultProps.connectTo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: {
+ remoteUrl: "git@example.com:user/repo.git",
+ gitProfile: {
+ authorName: "",
+ authorEmail: "",
+ useGlobalProfile: true,
+ },
+ },
+ }),
+ );
+ });
+ });
+
+ it("calls importFrom on completing AddDeployKey step in import mode", async () => {
+ render();
+ completeChooseProviderStep(true);
+ completeGenerateSSHKeyStep();
+ completeAddDeployKeyStep();
+
+ await waitFor(() => {
+ expect(defaultProps.importFrom).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: {
+ remoteUrl: "git@example.com:user/repo.git",
+ gitProfile: {
+ authorName: "",
+ authorEmail: "",
+ useGlobalProfile: true,
+ },
+ },
+ }),
+ );
+ });
+ });
+
+ it("shows an error callout when an error occurs during connectTo", async () => {
+ const mockConnectTo = jest.fn((props) => {
+ props.onErrorCallback(new Error("Error"), {
+ responseMeta: { error: { code: "AE-GIT-4033" } },
+ });
+ });
+
+ render();
+ completeChooseProviderStep();
+ completeGenerateSSHKeyStep();
+
+ expect(
+ screen.queryByText("The repo you added isn't empty"),
+ ).not.toBeInTheDocument();
+
+ completeAddDeployKeyStep();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("The repo you added isn't empty"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("renders the previous step when Previous button is clicked", () => {
+ render();
+ expect(
+ screen.getByText("i. To begin with, choose your Git service provider"),
+ ).toBeInTheDocument();
+ completeChooseProviderStep();
+ expect(
+ screen.queryByText("i. To begin with, choose your Git service provider"),
+ ).not.toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("t--git-connect-prev-button")); // Back to ChooseGitProvider step
+ expect(
+ screen.getByText("i. To begin with, choose your Git service provider"),
+ ).toBeInTheDocument();
+ });
+
+ it("disables next button when form data is invalid in any step", () => {
+ render();
+ const nextButton = screen.getByTestId("t--git-connect-next-button");
+
+ fireEvent.click(nextButton); // Try to move to next step
+ expect(nextButton).toBeDisabled();
+ });
+
+ it("renders loading state and removes buttons when connecting", () => {
+ render();
+ expect(
+ screen.getByText("Please wait while we connect to Git..."),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("t--git-connect-next-button"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/app/client/src/git/components/ConnectModal/index.tsx b/app/client/src/git/components/ConnectModal/index.tsx
new file mode 100644
index 000000000000..f7c8d097213d
--- /dev/null
+++ b/app/client/src/git/components/ConnectModal/index.tsx
@@ -0,0 +1,339 @@
+import React, { useCallback, useState } from "react";
+import styled from "styled-components";
+
+import AddDeployKey, { type AddDeployKeyProps } from "./AddDeployKey";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import ChooseGitProvider from "./ChooseGitProvider";
+import GenerateSSH from "./GenerateSSH";
+import Steps from "./Steps";
+import Statusbar from "../Statusbar";
+import { Button, ModalBody, ModalFooter } from "@appsmith/ads";
+import { GIT_CONNECT_STEPS } from "./constants";
+import type { GitProvider } from "./ChooseGitProvider";
+import {
+ ADD_DEPLOY_KEY_STEP,
+ CHOOSE_A_GIT_PROVIDER_STEP,
+ CONFIGURE_GIT,
+ CONNECT_GIT_TEXT,
+ GENERATE_SSH_KEY_STEP,
+ GIT_CONNECT_WAITING,
+ GIT_IMPORT_WAITING,
+ IMPORT_APP_CTA,
+ PREVIOUS_STEP,
+ createMessage,
+} from "ee/constants/messages";
+import { isValidGitRemoteUrl } from "../utils";
+import type { ApiResponse } from "api/ApiResponses";
+
+const OFFSET = 200;
+const OUTER_PADDING = 32;
+const FOOTER = 56;
+const HEADER = 44;
+
+const StyledModalBody = styled(ModalBody)`
+ flex: 1;
+ overflow-y: initial;
+ display: flex;
+ flex-direction: column;
+ max-height: calc(
+ 100vh - ${OFFSET}px - ${OUTER_PADDING}px - ${FOOTER}px - ${HEADER}px
+ );
+`;
+
+const StyledModalFooter = styled(ModalFooter)`
+ justify-content: space-between;
+ flex-direction: ${(p) => (!p.loading ? "row-reverse" : "row")};
+`;
+
+const steps = [
+ {
+ key: GIT_CONNECT_STEPS.CHOOSE_PROVIDER,
+ text: createMessage(CHOOSE_A_GIT_PROVIDER_STEP),
+ },
+ {
+ key: GIT_CONNECT_STEPS.GENERATE_SSH_KEY,
+ text: createMessage(GENERATE_SSH_KEY_STEP),
+ },
+ {
+ key: GIT_CONNECT_STEPS.ADD_DEPLOY_KEY,
+ text: createMessage(ADD_DEPLOY_KEY_STEP),
+ },
+];
+
+const possibleSteps = steps.map((s) => s.key);
+
+interface StyledModalFooterProps {
+ loading?: boolean;
+}
+
+interface FormDataState {
+ gitProvider?: GitProvider;
+ gitEmptyRepoExists?: string;
+ gitExistingRepoExists?: boolean;
+ remoteUrl?: string;
+ isAddedDeployKey?: boolean;
+ sshKeyType?: "RSA" | "ECDSA";
+}
+
+interface GitProfile {
+ authorName: string;
+ authorEmail: string;
+ useDefaultProfile?: boolean;
+}
+
+interface ConnectOrImportPayload {
+ remoteUrl: string;
+ gitProfile: GitProfile;
+}
+
+interface ConnectOrImportProps {
+ payload: ConnectOrImportPayload;
+ onErrorCallback: (error: Error, response: ApiResponse) => void;
+}
+
+// Remove comments after integration
+interface ConnectModalProps {
+ isImport?: boolean;
+ // It replaces const isImportingViaGit in GitConnectionV2/index.tsx
+ isImporting?: boolean;
+ // Replaces dispatch(importAppFromGit)
+ importFrom: (props: ConnectOrImportProps) => void;
+ // Replaces connectToGit from useGitConnect hook
+ connectTo: (props: ConnectOrImportProps) => void;
+ // Replaces isConnectingToGit
+ isConnectingTo?: boolean;
+ isConnecting: boolean;
+ artifactId: string;
+ artifactType: string;
+ // Replaces handleImport in original ChooseGitProvider.tsx
+ onImportFromCalloutLinkClick: () => void;
+ // Replaces hasCreateNewApplicationPermission = hasCreateNewAppPermission(workspace.userPermissions)
+ canCreateNewArtifact: boolean;
+ isModalOpen: boolean;
+ deployKeyDocUrl: AddDeployKeyProps["deployKeyDocUrl"];
+ isFetchingSSHKeyPair: AddDeployKeyProps["isFetchingSSHKeyPair"];
+ fetchSSHKeyPair: AddDeployKeyProps["fetchSSHKeyPair"];
+ generateSSHKey: AddDeployKeyProps["generateSSHKey"];
+ isGeneratingSSHKey: AddDeployKeyProps["isGeneratingSSHKey"];
+ sshKeyPair: AddDeployKeyProps["sshKeyPair"];
+}
+
+function ConnectModal({
+ artifactId,
+ artifactType,
+ canCreateNewArtifact,
+ connectTo,
+ deployKeyDocUrl,
+ fetchSSHKeyPair,
+ generateSSHKey,
+ importFrom,
+ isConnecting = false,
+ isFetchingSSHKeyPair,
+ isGeneratingSSHKey,
+ isImport = false,
+ isImporting = false,
+ isModalOpen,
+ onImportFromCalloutLinkClick,
+ sshKeyPair,
+}: ConnectModalProps) {
+ const [errorData, setErrorData] = useState>();
+
+ const nextStepText = {
+ [GIT_CONNECT_STEPS.CHOOSE_PROVIDER]: createMessage(CONFIGURE_GIT),
+ [GIT_CONNECT_STEPS.GENERATE_SSH_KEY]: createMessage(GENERATE_SSH_KEY_STEP),
+ [GIT_CONNECT_STEPS.ADD_DEPLOY_KEY]: createMessage(
+ isImport ? IMPORT_APP_CTA : CONNECT_GIT_TEXT,
+ ),
+ };
+
+ const [formData, setFormData] = useState({
+ gitProvider: undefined,
+ gitEmptyRepoExists: undefined,
+ gitExistingRepoExists: false,
+ remoteUrl: undefined,
+ isAddedDeployKey: false,
+ sshKeyType: "ECDSA",
+ });
+
+ const handleChange = (partialFormData: Partial) => {
+ setFormData((s) => ({ ...s, ...partialFormData }));
+ };
+
+ const [activeStep, setActiveStep] = useState(
+ GIT_CONNECT_STEPS.CHOOSE_PROVIDER,
+ );
+ const currentIndex = steps.findIndex((s) => s.key === activeStep);
+
+ const isDisabled = {
+ [GIT_CONNECT_STEPS.CHOOSE_PROVIDER]: !isImport
+ ? !formData.gitProvider ||
+ !formData.gitEmptyRepoExists ||
+ formData.gitEmptyRepoExists === "no"
+ : !formData.gitProvider || !formData.gitExistingRepoExists,
+ [GIT_CONNECT_STEPS.GENERATE_SSH_KEY]:
+ typeof formData?.remoteUrl !== "string" ||
+ !isValidGitRemoteUrl(formData?.remoteUrl),
+ [GIT_CONNECT_STEPS.ADD_DEPLOY_KEY]: !formData.isAddedDeployKey,
+ };
+
+ const handlePreviousStep = useCallback(() => {
+ if (currentIndex > 0) {
+ setActiveStep(steps[currentIndex - 1].key);
+ }
+ }, [currentIndex]);
+
+ const handleNextStep = useCallback(() => {
+ if (currentIndex < steps.length) {
+ switch (activeStep) {
+ case GIT_CONNECT_STEPS.CHOOSE_PROVIDER: {
+ setActiveStep(GIT_CONNECT_STEPS.GENERATE_SSH_KEY);
+ AnalyticsUtil.logEvent("GS_CONFIGURE_GIT");
+ break;
+ }
+ case GIT_CONNECT_STEPS.GENERATE_SSH_KEY: {
+ setActiveStep(GIT_CONNECT_STEPS.ADD_DEPLOY_KEY);
+ AnalyticsUtil.logEvent("GS_GENERATE_KEY_BUTTON_CLICK", {
+ repoUrl: formData?.remoteUrl,
+ connectFlow: "v2",
+ });
+ break;
+ }
+ case GIT_CONNECT_STEPS.ADD_DEPLOY_KEY: {
+ const gitProfile = {
+ authorName: "",
+ authorEmail: "",
+ useGlobalProfile: true,
+ };
+
+ if (formData.remoteUrl) {
+ if (!isImport) {
+ connectTo({
+ payload: {
+ remoteUrl: formData.remoteUrl,
+ gitProfile,
+ },
+ onErrorCallback: (error, response) => {
+ // AE-GIT-4033 is repo not empty error
+ if (response?.responseMeta?.error?.code === "AE-GIT-4033") {
+ setActiveStep(GIT_CONNECT_STEPS.GENERATE_SSH_KEY);
+ }
+
+ setErrorData(response);
+ },
+ });
+ AnalyticsUtil.logEvent(
+ "GS_CONNECT_BUTTON_ON_GIT_SYNC_MODAL_CLICK",
+ { repoUrl: formData?.remoteUrl, connectFlow: "v2" },
+ );
+ } else {
+ importFrom({
+ payload: {
+ remoteUrl: formData.remoteUrl,
+ gitProfile,
+ // isDefaultProfile: true,
+ },
+ onErrorCallback(error, response) {
+ setErrorData(response);
+ },
+ });
+ }
+ }
+
+ break;
+ }
+ }
+ }
+ }, [
+ activeStep,
+ connectTo,
+ currentIndex,
+ formData.remoteUrl,
+ importFrom,
+ isImport,
+ ]);
+
+ const stepProps = {
+ onChange: handleChange,
+ value: formData,
+ isImport,
+ errorData,
+ };
+
+ const loading = (!isImport && isConnecting) || (isImport && isImporting);
+
+ return (
+ <>
+
+ {possibleSteps.includes(activeStep) && (
+
+ )}
+ {activeStep === GIT_CONNECT_STEPS.CHOOSE_PROVIDER && (
+
+ )}
+ {activeStep === GIT_CONNECT_STEPS.GENERATE_SSH_KEY && (
+
+ )}
+ {activeStep === GIT_CONNECT_STEPS.ADD_DEPLOY_KEY && (
+
+ )}
+
+
+ {loading && (
+
+ )}
+ {!loading && (
+
+ )}
+ {possibleSteps.includes(activeStep) && currentIndex > 0 && !loading && (
+
+ )}
+
+ >
+ );
+}
+
+export default ConnectModal;
diff --git a/app/client/src/git/components/GitQuickActions/index.test.tsx b/app/client/src/git/components/GitQuickActions/index.test.tsx
index 72138f9b9b38..06f305fb9644 100644
--- a/app/client/src/git/components/GitQuickActions/index.test.tsx
+++ b/app/client/src/git/components/GitQuickActions/index.test.tsx
@@ -15,8 +15,8 @@ jest.mock("./ConnectButton", () => () => (
ConnectButton
));
-jest.mock("./AutocommitStatusbar", () => () => (
- AutocommitStatusbar
+jest.mock("./../Statusbar", () => () => (
+ Statusbar
));
describe("QuickActions Component", () => {
@@ -79,7 +79,7 @@ describe("QuickActions Component", () => {
).toBe(1);
});
- it("should render AutocommitStatusbar when isAutocommitEnabled and isPollingAutocommit are true", () => {
+ it("should render Statusbar when isAutocommitEnabled and isPollingAutocommit are true", () => {
const props = {
...defaultProps,
isGitConnected: true,
diff --git a/app/client/src/git/components/GitQuickActions/index.tsx b/app/client/src/git/components/GitQuickActions/index.tsx
index e43bf2f0d3d1..3dabeb95c860 100644
--- a/app/client/src/git/components/GitQuickActions/index.tsx
+++ b/app/client/src/git/components/GitQuickActions/index.tsx
@@ -13,7 +13,7 @@ import { GitOpsTab } from "../../constants/enums";
import { GitSettingsTab } from "../../constants/enums";
import ConnectButton from "./ConnectButton";
import QuickActionButton from "./QuickActionButton";
-import AutocommitStatusbar from "./AutocommitStatusbar";
+import Statusbar from "../Statusbar";
import getPullBtnStatus from "./helpers/getPullButtonStatus";
import noop from "lodash/noop";
@@ -127,7 +127,7 @@ function GitQuickActions({
{/* */}
{isAutocommitEnabled && isAutocommitPolling ? (
-
+
) : (
<>
{
+describe("Statusbar Component", () => {
afterEach(() => {
jest.clearAllTimers();
});
it("should render with initial percentage 0 when completed is false", () => {
- render();
+ render();
const statusbar = screen.getByTestId("statusbar");
expect(statusbar).toBeInTheDocument();
@@ -31,7 +31,7 @@ describe("AutocommitStatusbar Component", () => {
});
it("should increment percentage over time when completed is false", () => {
- render();
+ render();
const statusbar = screen.getByTestId("statusbar");
// Initial percentage
@@ -57,7 +57,7 @@ describe("AutocommitStatusbar Component", () => {
});
it("should not increment percentage beyond 90 when completed is false", () => {
- render();
+ render();
const statusbar = screen.getByTestId("statusbar");
// Advance time beyond the total interval duration
@@ -74,7 +74,7 @@ describe("AutocommitStatusbar Component", () => {
});
it("should set percentage to 100 when completed is true", () => {
- render();
+ render();
const statusbar = screen.getByTestId("statusbar");
expect(statusbar).toHaveTextContent("100%");
@@ -83,7 +83,7 @@ describe("AutocommitStatusbar Component", () => {
it("should call onHide after 1 second when completed is true", () => {
const onHide = jest.fn();
- render();
+ render();
expect(onHide).not.toHaveBeenCalled();
// Advance timer by 1 second
@@ -96,9 +96,7 @@ describe("AutocommitStatusbar Component", () => {
it("should clean up intervals and timeouts on unmount", () => {
const onHide = jest.fn();
- const { unmount } = render(
- ,
- );
+ const { unmount } = render();
// Start the interval
act(() => {
@@ -118,7 +116,7 @@ describe("AutocommitStatusbar Component", () => {
it("should handle transition from false to true for completed prop", () => {
const onHide = jest.fn();
const { rerender } = render(
- ,
+ ,
);
const statusbar = screen.getByTestId("statusbar");
@@ -129,7 +127,7 @@ describe("AutocommitStatusbar Component", () => {
expect(statusbar).toHaveTextContent("10%");
// Update the completed prop to true
- rerender();
+ rerender();
expect(statusbar).toHaveTextContent("100%");
// Ensure onHide is called after 1 second
@@ -140,13 +138,13 @@ describe("AutocommitStatusbar Component", () => {
});
it("should not reset percentage when completed changes from true to false", () => {
- const { rerender } = render();
+ const { rerender } = render();
const statusbar = screen.getByTestId("statusbar");
expect(statusbar).toHaveTextContent("100%");
// Change completed to false
- rerender();
+ rerender();
expect(statusbar).toHaveTextContent("100%");
// Advance timer to check if percentage increments beyond 100%
diff --git a/app/client/src/git/components/GitQuickActions/AutocommitStatusbar.tsx b/app/client/src/git/components/Statusbar/index.tsx
similarity index 85%
rename from app/client/src/git/components/GitQuickActions/AutocommitStatusbar.tsx
rename to app/client/src/git/components/Statusbar/index.tsx
index 88a3eb460734..a65332643780 100644
--- a/app/client/src/git/components/GitQuickActions/AutocommitStatusbar.tsx
+++ b/app/client/src/git/components/Statusbar/index.tsx
@@ -1,14 +1,13 @@
import React, { useEffect, useRef, useState } from "react";
-import { Statusbar } from "@appsmith/ads-old";
+import { Statusbar as ADSStatusBar } from "@appsmith/ads-old";
import styled from "styled-components";
-import {
- AUTOCOMMIT_IN_PROGRESS_MESSAGE,
- createMessage,
-} from "ee/constants/messages";
-interface AutocommitStatusbarProps {
+interface StatusbarProps {
+ message?: string;
completed: boolean;
onHide?: () => void;
+ className?: string;
+ testId?: string;
}
const PROGRESSBAR_WIDTH = 150;
@@ -36,10 +35,11 @@ const StatusbarWrapper = styled.div`
}
`;
-export default function AutocommitStatusbar({
+export default function Statusbar({
completed,
+ message,
onHide,
-}: AutocommitStatusbarProps) {
+}: StatusbarProps) {
const intervalRef = useRef(null);
const timeoutRef = useRef(null);
const [percentage, setPercentage] = useState(0);
@@ -74,7 +74,7 @@ export default function AutocommitStatusbar({
};
},
[completed],
- ); // Removed 'percentage' from dependencies
+ );
// Effect for setting percentage to 100% when completed
useEffect(
@@ -106,10 +106,10 @@ export default function AutocommitStatusbar({
);
return (
-
-
+
diff --git a/app/client/src/git/components/utils.ts b/app/client/src/git/components/utils.ts
new file mode 100644
index 000000000000..903f59918684
--- /dev/null
+++ b/app/client/src/git/components/utils.ts
@@ -0,0 +1,12 @@
+const GIT_REMOTE_URL_PATTERN =
+ /^((git|ssh)|([\w\-\.]+@[\w\-\.]+))(:(\/\/)?)([\w\.@\:\/\-~\(\)%]+)[^\/]$/im;
+
+const gitRemoteUrlRegExp = new RegExp(GIT_REMOTE_URL_PATTERN);
+
+/**
+ * isValidGitRemoteUrl: returns true if a url follows valid SSH/git url scheme, see GIT_REMOTE_URL_PATTERN
+ * @param url {string} remote url input
+ * @returns {boolean} true if valid remote url, false otherwise
+ */
+export const isValidGitRemoteUrl = (url: string) =>
+ gitRemoteUrlRegExp.test(url);