From 526243880212ad71ad04b33aabd2f37420c8e21d Mon Sep 17 00:00:00 2001 From: Ashit Rath Date: Thu, 12 Dec 2024 16:34:21 +0530 Subject: [PATCH 01/10] chore: Git mod - Connect/Import modal (#38098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Git mod components, add connect/import from git modal components Fixes https://github.com/appsmithorg/appsmith/issues/37812 Fixes https://github.com/appsmithorg/appsmith/issues/37802 ## Automation /ok-to-test tags="@tag.Git" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: e94ebe0722dcf52ea078675449771d1ee671718d > Cypress dashboard. > Tags: `@tag.Git` > Spec: >
Thu, 12 Dec 2024 07:43:05 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit ## Release Notes - **New Features** - Introduced a multi-step `ConnectModal` for Git provider connections. - Added components for generating SSH keys and managing Git remote URLs. - New constants for Git integration steps and demo GIFs for user guidance. - Added optional `errorType` property to enhance error handling in API responses. - New `Steps` component for step navigation in the modal. - New `CopyButton` component for clipboard functionality with visual feedback. - **Improvements** - Enhanced error handling and user prompts related to Git operations. - Improved user interface with styled components for better layout and presentation. - **Bug Fixes** - Improved validation and error messaging for SSH URL inputs. - **Refactor** - Renamed `AutocommitStatusbar` to `Statusbar` for consistency across components and tests. - **Tests** - Comprehensive test coverage for new components and functionalities related to Git integration. --- app/client/src/api/ApiResponses.tsx | 1 + app/client/src/ce/constants/messages.ts | 4 + .../ConnectModal/AddDeployKey.test.tsx | 270 +++++++++++++ .../components/ConnectModal/AddDeployKey.tsx | 373 ++++++++++++++++++ .../ConnectModal/ChooseGitProvider.test.tsx | 234 +++++++++++ .../ConnectModal/ChooseGitProvider.tsx | 233 +++++++++++ .../components/ConnectModal/CopyButton.tsx | 99 +++++ .../ConnectModal/GenerateSSH.test.tsx | 146 +++++++ .../components/ConnectModal/GenerateSSH.tsx | 133 +++++++ .../src/git/components/ConnectModal/Steps.tsx | 121 ++++++ .../git/components/ConnectModal/common.tsx | 52 +++ .../git/components/ConnectModal/constants.ts | 26 ++ .../components/ConnectModal/index.test.tsx | 216 ++++++++++ .../src/git/components/ConnectModal/index.tsx | 339 ++++++++++++++++ .../components/GitQuickActions/index.test.tsx | 6 +- .../git/components/GitQuickActions/index.tsx | 4 +- .../index.test.tsx} | 26 +- .../index.tsx} | 24 +- app/client/src/git/components/utils.ts | 12 + 19 files changed, 2288 insertions(+), 31 deletions(-) create mode 100644 app/client/src/git/components/ConnectModal/AddDeployKey.test.tsx create mode 100644 app/client/src/git/components/ConnectModal/AddDeployKey.tsx create mode 100644 app/client/src/git/components/ConnectModal/ChooseGitProvider.test.tsx create mode 100644 app/client/src/git/components/ConnectModal/ChooseGitProvider.tsx create mode 100644 app/client/src/git/components/ConnectModal/CopyButton.tsx create mode 100644 app/client/src/git/components/ConnectModal/GenerateSSH.test.tsx create mode 100644 app/client/src/git/components/ConnectModal/GenerateSSH.tsx create mode 100644 app/client/src/git/components/ConnectModal/Steps.tsx create mode 100644 app/client/src/git/components/ConnectModal/common.tsx create mode 100644 app/client/src/git/components/ConnectModal/constants.ts create mode 100644 app/client/src/git/components/ConnectModal/index.test.tsx create mode 100644 app/client/src/git/components/ConnectModal/index.tsx rename app/client/src/git/components/{GitQuickActions/AutocommitStatusbar.test.tsx => Statusbar/index.test.tsx} (84%) rename app/client/src/git/components/{GitQuickActions/AutocommitStatusbar.tsx => Statusbar/index.tsx} (85%) create mode 100644 app/client/src/git/components/utils.ts 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 ? ( + + + + ) : ( + + + + + {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); From 2754698d0f90a7ee79a422fa674507ddd558ab97 Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Thu, 12 Dec 2024 17:04:08 +0530 Subject: [PATCH 02/10] chore: fix chat ui bugs (#38105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /ok-to-test tags="@tag.Anvil" > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 558f4684ed8b43e7290715e8461ef4510a3aec87 > Cypress dashboard. > Tags: `@tag.Anvil` > Spec: >
Wed, 11 Dec 2024 12:19:21 UTC ## Summary by CodeRabbit - **New Features** - Enhanced button styling in the sidebar for non-mobile view. - Introduced a new expand button for the sidebar header. - **Bug Fixes** - Improved layout and visibility of sidebar elements, ensuring proper alignment and display. - **Style** - Added minimum height to the sidebar header. - Implemented styles to hide empty sidebar headers. - Adjusted margin for the sidebar header expand button. - Removed horizontal overflow property from sidebar content. --- .../src/components/Sidebar/src/SidebarContent.tsx | 1 + .../src/components/Sidebar/src/styles.module.css | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/client/packages/design-system/widgets/src/components/Sidebar/src/SidebarContent.tsx b/app/client/packages/design-system/widgets/src/components/Sidebar/src/SidebarContent.tsx index 7f88187391f6..9c88ff3cf81a 100644 --- a/app/client/packages/design-system/widgets/src/components/Sidebar/src/SidebarContent.tsx +++ b/app/client/packages/design-system/widgets/src/components/Sidebar/src/SidebarContent.tsx @@ -46,6 +46,7 @@ const _SidebarContent = ( )} {!isMobile && (