chore: Git mod - Connect/Import modal (#38098)

## 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"

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


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


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

## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ashit Rath 2024-12-12 16:34:21 +05:30 committed by GitHub
parent 8a142c43ae
commit 5262438802
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2288 additions and 31 deletions

View File

@ -1,6 +1,7 @@
export interface APIResponseError {
code: string | number;
message: string;
errorType?: string;
}
export interface ResponseMeta {

View File

@ -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 = () =>

View File

@ -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(<AddDeployKey {...defaultProps} />);
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(<AddDeployKey {...defaultProps} isImport={false} />);
expect(defaultProps.fetchSSHKeyPair).toHaveBeenCalledTimes(1);
});
it("does not call fetchSSHKeyPair if importing", () => {
render(<AddDeployKey {...defaultProps} isImport />);
expect(defaultProps.fetchSSHKeyPair).not.toHaveBeenCalled();
});
it("shows dummy key loader if loading keys", () => {
render(
<AddDeployKey {...defaultProps} isFetchingSSHKeyPair sshKeyPair="" />,
);
// 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(
<AddDeployKey
{...defaultProps}
generateSSHKey={generateSSHKey}
sshKeyPair="" // No key to force generation
/>,
);
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(<AddDeployKey {...defaultProps} errorData={errorData} />);
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(<AddDeployKey {...defaultProps} errorData={errorData} />);
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(<AddDeployKey {...defaultProps} onChange={onChange} />);
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(<AddDeployKey {...defaultProps} />);
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(<AddDeployKey {...defaultProps} connectLoading />);
expect(screen.queryByTestId("t--copy-generic")).not.toBeInTheDocument();
});
it("shows repository settings link if gitProvider is known and not 'others'", () => {
render(<AddDeployKey {...defaultProps} />);
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(
<AddDeployKey
{...defaultProps}
value={{ gitProvider: "others", remoteUrl: "git@xyz.com:repo.git" }}
/>,
);
expect(
screen.queryByRole("link", { name: "repository settings." }),
).not.toBeInTheDocument();
});
it("shows collapsible section if gitProvider is not 'others'", () => {
render(
<AddDeployKey
{...defaultProps}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
value={{
gitProvider: "gitlab",
remoteUrl: "git@gitlab.com:owner/repo.git",
}}
/>,
);
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(
<AddDeployKey
{...defaultProps}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
value={{ gitProvider: "others", remoteUrl: "git@xyz.com:repo.git" }}
/>,
);
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(<AddDeployKey {...defaultProps} />);
const docsLink = screen.getByRole("link", { name: "Read Docs" });
expect(docsLink).toHaveAttribute("href", DEFAULT_DOCS_URL);
});
it("uses custom documentation link if provided", () => {
render(
<AddDeployKey
{...defaultProps}
deployKeyDocUrl="https://custom-docs.com"
/>,
);
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(
<AddDeployKey
{...defaultProps}
generateSSHKey={generateSSHKey}
isModalOpen={false}
sshKeyPair=""
/>,
);
// 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(
<AddDeployKey
{...defaultProps}
fetchSSHKeyPair={fetchSSHKeyPair}
generateSSHKey={generateSSHKey}
isFetchingSSHKeyPair={false}
isGeneratingSSHKey={false}
sshKeyPair=""
/>,
);
expect(fetchSSHKeyPair).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(generateSSHKey).toHaveBeenCalledWith("ECDSA", expect.any(Object));
});
});
});

View File

@ -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<AddDeployKeyState>) => void;
value: Partial<AddDeployKeyState>;
isImport?: boolean;
errorData?: ApiResponse<unknown>;
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<string>();
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" && (
<ErrorCallout kind="error">
<Text kind="heading-xs" renderAs="h3">
{errorData?.responseMeta?.error?.errorType}
</Text>
<Text renderAs="p">{errorData?.responseMeta?.error?.message}</Text>
</ErrorCallout>
)}
{/* hardcoding message because server doesn't support feature flag. Will change this later */}
{errorData && errorData?.responseMeta?.error?.code === "AE-GIT-4032" && (
<ErrorCallout kind="error">
<Text kind="heading-xs" renderAs="h3">
{createMessage(ERROR_SSH_KEY_MISCONF_TITLE)}
</Text>
<Text renderAs="p">
{createMessage(ERROR_SSH_KEY_MISCONF_MESSAGE)}
</Text>
</ErrorCallout>
)}
<WellContainer>
<WellTitleContainer>
<WellTitle kind="heading-s" renderAs="h3">
{createMessage(ADD_DEPLOY_KEY_STEP_TITLE)}
</WellTitle>
<Button
href={deployKeyDocUrl || DEFAULT_DOCS_URL}
kind="tertiary"
renderAs="a"
size="sm"
startIcon="book-line"
target="_blank"
>
{" "}
{createMessage(READ_DOCS)}
</Button>
</WellTitleContainer>
<WellText renderAs="p">
Copy below SSH key and paste it in your{" "}
{!!repositorySettingsUrl && value.gitProvider !== "others" ? (
<StyledLink
rel="noreferrer"
target="_blank"
to={repositorySettingsUrl}
>
repository settings.
</StyledLink>
) : (
"repository settings."
)}{" "}
Now, give write access to it.
</WellText>
<FieldContainer>
<StyledSelect onChange={setSshKeyType} size="sm" value={sshKeyType}>
<Option value="ECDSA">ECDSA 256</Option>
<Option value="RSA">RSA 4096</Option>
</StyledSelect>
{!loading ? (
<DeployedKeyContainer>
<StyledIcon
color="var(--ads-v2-color-fg)"
name="key-2-line"
size="md"
/>
<KeyType>{sshKeyType}</KeyType>
<KeyText>{sshKeyPair}</KeyText>
{!connectLoading && (
<CopyButton
onCopy={onCopy}
tooltipMessage={createMessage(COPY_SSH_KEY)}
value={sshKeyPair}
/>
)}
</DeployedKeyContainer>
) : (
<DummyKey />
)}
</FieldContainer>
{value?.gitProvider !== "others" && (
<Collapsible isOpen>
<CollapsibleHeader arrowPosition="end">
<Icon name="play-circle-line" size="md" />
<Text>{createMessage(HOW_TO_ADD_DEPLOY_KEY)}</Text>
</CollapsibleHeader>
<CollapsibleContent>
<DemoImage
alt={`Add deploy key in ${value?.gitProvider}`}
src={GIT_DEMO_GIF.add_deploykey[value?.gitProvider || "github"]}
/>
</CollapsibleContent>
</Collapsible>
)}
</WellContainer>
<Checkbox
data-testid="t--added-deploy-key-checkbox"
isSelected={value?.isAddedDeployKey}
onChange={onDeployKeyAddedCheckChange}
>
<CheckboxTextContainer>
<Text renderAs="p">{createMessage(CONSENT_ADDED_DEPLOY_KEY)}</Text>
<Text color="var(--ads-v2-color-red-600)" renderAs="p">
&nbsp;*
</Text>
</CheckboxTextContainer>
</Checkbox>
</>
);
}
export default AddDeployKey;

View File

@ -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(<ChooseGitProvider {...defaultProps} />);
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(<ChooseGitProvider {...defaultProps} onChange={onChange} />);
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(<ChooseGitProvider {...defaultProps} />);
// 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(
<ChooseGitProvider
{...defaultProps}
onChange={onChange}
value={{ gitProvider: "github" }}
/>,
);
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(
<ChooseGitProvider
{...defaultProps}
onChange={onChange}
value={{ gitProvider: "github" }}
/>,
);
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(
<Router>
<ChooseGitProvider
{...defaultProps}
value={{ gitProvider: "github", gitEmptyRepoExists: "no" }}
/>
</Router>,
);
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(
<Router>
<ChooseGitProvider
{...defaultProps}
value={{ gitProvider: "others", gitEmptyRepoExists: "no" }}
/>
</Router>,
);
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(
<Router>
<ChooseGitProvider
{...defaultProps}
value={{ gitProvider: "github", gitEmptyRepoExists: "no" }}
/>
</Router>,
);
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(
<Router>
<ChooseGitProvider
{...defaultProps}
onImportFromCalloutLinkClick={onImportFromCalloutLinkClick}
value={{ gitProvider: "github", gitEmptyRepoExists: "no" }}
/>
</Router>,
);
fireEvent.click(screen.getByText("Import via git"));
expect(onImportFromCalloutLinkClick).toHaveBeenCalledTimes(1);
});
it("when isImport = true, shows a checkbox for existing repo", () => {
render(<ChooseGitProvider {...defaultProps} isImport />);
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(
<ChooseGitProvider {...defaultProps} isImport onChange={onChange} />,
);
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(<ChooseGitProvider {...defaultProps} isImport />);
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(
<ChooseGitProvider
{...defaultProps}
canCreateNewArtifact={false}
value={{ gitProvider: "github", gitEmptyRepoExists: "no" }}
/>,
);
// 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(<ChooseGitProvider {...defaultProps} />);
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();
});
});

View File

@ -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<ChooseGitProviderState>) => void;
value: Partial<ChooseGitProviderState>;
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 (
<>
<WellContainer>
<WellTitleContainer>
<WellTitle kind="heading-s" renderAs="h3">
{createMessage(CHOOSE_A_GIT_PROVIDER_STEP)}
</WellTitle>
</WellTitleContainer>
<WellInnerContainer>
<FieldContainer>
<FieldQuestion renderAs="p">
i. {createMessage(CHOOSE_GIT_PROVIDER_QUESTION)}{" "}
<Text color="var(--ads-v2-color-red-600)">*</Text>
</FieldQuestion>
<FieldControl>
<RadioGroup
onChange={onGitProviderChange}
orientation="horizontal"
value={value?.gitProvider}
>
<Radio
data-testid="t--git-provider-radio-github"
value="github"
>
Github
</Radio>
<Radio
data-testid="t--git-provider-radio-gitlab"
value="gitlab"
>
Gitlab
</Radio>
<Radio
data-testid="t--git-provider-radio-bitbucket"
value="bitbucket"
>
Bitbucket
</Radio>
<Radio
data-testid="t--git-provider-radio-others"
value="others"
>
Others
</Radio>
</RadioGroup>
</FieldControl>
</FieldContainer>
{!isImport && (
<FieldContainer>
<FieldQuestion isDisabled={!value?.gitProvider} renderAs="p">
ii. {createMessage(IS_EMPTY_REPO_QUESTION)}{" "}
<Text color="var(--ads-v2-color-red-600)">*</Text>
</FieldQuestion>
<FieldControl>
<RadioGroup
isDisabled={!value?.gitProvider}
onChange={onEmptyRepoOptionChange}
orientation="horizontal"
value={value?.gitEmptyRepoExists}
>
<Radio data-testid="t--existing-empty-repo-yes" value="yes">
Yes
</Radio>
<Radio data-testid="t--existing-empty-repo-no" value="no">
No
</Radio>
</RadioGroup>
</FieldControl>
</FieldContainer>
)}
{!isImport &&
value?.gitProvider !== "others" &&
value?.gitEmptyRepoExists === "no" && (
<Collapsible isOpen>
<CollapsibleHeader arrowPosition="end">
<Icon name="play-circle-line" size="md" />
<Text>{createMessage(HOW_TO_CREATE_EMPTY_REPO)}</Text>
</CollapsibleHeader>
<CollapsibleContent>
<DemoImage
alt={`Create an empty repo in ${value?.gitProvider}`}
src={
GIT_DEMO_GIF.create_repo[value?.gitProvider || "github"]
}
/>
</CollapsibleContent>
</Collapsible>
)}
{!isImport &&
value?.gitProvider === "others" &&
value?.gitEmptyRepoExists === "no" && (
<Callout kind="warning">
{createMessage(NEED_EMPTY_REPO_MESSAGE)}
</Callout>
)}
</WellInnerContainer>
</WellContainer>
{!isImport && value?.gitEmptyRepoExists === "no" ? (
<Callout kind="info" links={importCalloutLinks}>
{createMessage(IMPORT_ARTIFACT_IF_NOT_EMPTY, artifactType)}
</Callout>
) : null}
{isImport && (
<Checkbox
data-testid="t--existing-repo-checkbox"
isSelected={value?.gitExistingRepoExists}
onChange={onExistingRepoOptionChange}
>
<CheckboxTextContainer>
<Text renderAs="p">
{createMessage(I_HAVE_EXISTING_ARTIFACT_REPO, artifactType)}
</Text>
<Text color="var(--ads-v2-color-red-600)" renderAs="p">
&nbsp;*
</Text>
</CheckboxTextContainer>
</Checkbox>
)}
</>
);
}
export default ChooseGitProvider;

View File

@ -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<number>();
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 ? (
<IconContainer style={style}>
<Icon
color="var(--ads-v2-color-fg-success)"
name="check-line"
size="lg"
/>
</IconContainer>
) : (
<TooltipWrapper style={style}>
<Tooltip content={tooltipMessage}>
<Button
aria-label={`Copy ${tooltipMessage || "text"}`}
data-testid={`t--copy-${testIdSuffix}`}
isDisabled={isDisabled}
isIconButton
kind="tertiary"
onClick={copyToClipboard}
size="sm"
startIcon="duplicate"
/>
</Tooltip>
</TooltipWrapper>
)}{" "}
</>
);
}
export default CopyButton;

View File

@ -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(<GenerateSSH {...defaultProps} />);
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(<GenerateSSH {...defaultProps} errorData={errorData} />);
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(<GenerateSSH {...defaultProps} errorData={errorData} />);
expect(
screen.queryByText("The repo you added isn't empty"),
).not.toBeInTheDocument();
});
it("handles remote URL input changes", () => {
const onChange = jest.fn();
render(<GenerateSSH {...defaultProps} onChange={onChange} />);
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(<GenerateSSH {...defaultProps} />);
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(<GenerateSSH {...defaultProps} />);
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(<GenerateSSH {...defaultProps} />);
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(
<GenerateSSH
{...defaultProps}
value={{ gitProvider: "others", remoteUrl: "" }}
/>,
);
expect(
screen.queryByText("How to copy & paste SSH remote URL"),
).not.toBeInTheDocument();
});
});

View File

@ -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<GenerateSSHState>) => void;
value: Partial<GenerateSSHState>;
errorData?: ApiResponse<unknown>;
}
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" && (
<ErrorCallout kind="error">
<Text kind="heading-xs" renderAs="h3">
{createMessage(ERROR_REPO_NOT_EMPTY_TITLE)}
</Text>
<Text renderAs="p">
{createMessage(ERROR_REPO_NOT_EMPTY_MESSAGE)}
</Text>
</ErrorCallout>
)}
<WellContainer>
<WellTitleContainer>
<WellTitle kind="heading-s" renderAs="h3">
{createMessage(GENERATE_SSH_KEY_STEP)}
</WellTitle>
<Button
href={CONNECTING_TO_GIT_DOCS_URL}
kind="tertiary"
renderAs="a"
size="sm"
startIcon="book-line"
target="_blank"
>
{" "}
{createMessage(READ_DOCS)}
</Button>
</WellTitleContainer>
<WellText renderAs="p">{createMessage(COPY_SSH_URL_MESSAGE)}</WellText>
<FieldContainer>
<Input
data-testid="git-connect-remote-url-input"
errorMessage={isInvalid ? createMessage(PASTE_SSH_URL_INFO) : ""}
isRequired
label={createMessage(REMOTE_URL_INPUT_LABEL)}
onChange={handleChange}
placeholder="git@example.com:user/repository.git"
size="md"
value={value?.remoteUrl}
/>
</FieldContainer>
{value?.gitProvider !== "others" && (
<Collapsible isOpen>
<CollapsibleHeader arrowPosition="end">
<Icon name="play-circle-line" size="md" />
<Text>{createMessage(HOW_TO_COPY_REMOTE_URL)}</Text>
</CollapsibleHeader>
<CollapsibleContent>
<DemoImage
alt={`Copy and paste remote url from ${value?.gitProvider}`}
src={
GIT_DEMO_GIF.copy_remoteurl[value?.gitProvider || "github"]
}
/>
</CollapsibleContent>
</Collapsible>
)}
</WellContainer>
</>
);
}
export default GenerateSSH;

View File

@ -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<StepNumberProps>`
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 (
<Container>
{steps.map((step, index) => {
return (
<Fragment key={step.key}>
{index > 0 && <StepLine />}
<StepButton
isDisabled={index > activeIndex}
kind="tertiary"
onClick={onClick(step, index)}
role="button"
size="md"
>
<StepNumber active={step.key === activeKey}>
{index + 1}
</StepNumber>
<StepText>{step.text}</StepText>
</StepButton>
</Fragment>
);
})}
</Container>
);
}
export default Steps;

View File

@ -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;
`;

View File

@ -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`),
},
};

View File

@ -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(<ConnectModal {...defaultProps} />);
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(<ConnectModal {...defaultProps} />);
expect(screen.getByTestId("t--git-connect-next-button")).toBeDisabled();
});
it("navigates to the next step (GenerateSSH) and validates SSH URL input", () => {
render(<ConnectModal {...defaultProps} />);
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(<ConnectModal {...defaultProps} />);
completeChooseProviderStep();
completeGenerateSSHKeyStep();
expect(
screen.getByText("Add deploy key & give write access"),
).toBeInTheDocument();
});
it("calls connectTo on completing AddDeployKey step in connect mode", async () => {
render(<ConnectModal {...defaultProps} />);
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(<ConnectModal {...defaultProps} isImport />);
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(<ConnectModal {...defaultProps} connectTo={mockConnectTo} />);
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(<ConnectModal {...defaultProps} />);
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(<ConnectModal {...defaultProps} />);
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(<ConnectModal {...defaultProps} isConnecting />);
expect(
screen.getByText("Please wait while we connect to Git..."),
).toBeInTheDocument();
expect(
screen.queryByTestId("t--git-connect-next-button"),
).not.toBeInTheDocument();
});
});

View File

@ -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)<StyledModalFooterProps>`
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<unknown>) => 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<ApiResponse<unknown>>();
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<FormDataState>({
gitProvider: undefined,
gitEmptyRepoExists: undefined,
gitExistingRepoExists: false,
remoteUrl: undefined,
isAddedDeployKey: false,
sshKeyType: "ECDSA",
});
const handleChange = (partialFormData: Partial<FormDataState>) => {
setFormData((s) => ({ ...s, ...partialFormData }));
};
const [activeStep, setActiveStep] = useState<string>(
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 (
<>
<StyledModalBody>
{possibleSteps.includes(activeStep) && (
<Steps
activeKey={activeStep}
onActiveKeyChange={setActiveStep}
steps={steps}
/>
)}
{activeStep === GIT_CONNECT_STEPS.CHOOSE_PROVIDER && (
<ChooseGitProvider
{...stepProps}
artifactId={artifactId}
artifactType={artifactType}
canCreateNewArtifact={canCreateNewArtifact}
onImportFromCalloutLinkClick={onImportFromCalloutLinkClick}
/>
)}
{activeStep === GIT_CONNECT_STEPS.GENERATE_SSH_KEY && (
<GenerateSSH {...stepProps} />
)}
{activeStep === GIT_CONNECT_STEPS.ADD_DEPLOY_KEY && (
<AddDeployKey
{...stepProps}
connectLoading={loading}
deployKeyDocUrl={deployKeyDocUrl}
fetchSSHKeyPair={fetchSSHKeyPair}
generateSSHKey={generateSSHKey}
isFetchingSSHKeyPair={isFetchingSSHKeyPair}
isGeneratingSSHKey={isGeneratingSSHKey}
isModalOpen={isModalOpen}
sshKeyPair={sshKeyPair}
/>
)}
</StyledModalBody>
<StyledModalFooter loading={loading}>
{loading && (
<Statusbar
completed={!loading}
message={createMessage(
isImport ? GIT_IMPORT_WAITING : GIT_CONNECT_WAITING,
)}
/>
)}
{!loading && (
<Button
data-testid="t--git-connect-next-button"
endIcon={
currentIndex < steps.length - 1 ? "arrow-right-s-line" : undefined
}
isDisabled={isDisabled[activeStep]}
onClick={handleNextStep}
size="md"
>
{nextStepText[activeStep]}
</Button>
)}
{possibleSteps.includes(activeStep) && currentIndex > 0 && !loading && (
<Button
data-testid="t--git-connect-prev-button"
isDisabled={loading}
kind="secondary"
onClick={handlePreviousStep}
size="md"
startIcon="arrow-left-s-line"
>
{createMessage(PREVIOUS_STEP)}
</Button>
)}
</StyledModalFooter>
</>
);
}
export default ConnectModal;

View File

@ -15,8 +15,8 @@ jest.mock("./ConnectButton", () => () => (
<div data-testid="connect-button">ConnectButton</div>
));
jest.mock("./AutocommitStatusbar", () => () => (
<div data-testid="autocommit-statusbar">AutocommitStatusbar</div>
jest.mock("./../Statusbar", () => () => (
<div data-testid="autocommit-statusbar">Statusbar</div>
));
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,

View File

@ -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({
<Container>
{/* <BranchButton /> */}
{isAutocommitEnabled && isAutocommitPolling ? (
<AutocommitStatusbar completed={!isAutocommitPolling} />
<Statusbar completed={!isAutocommitPolling} />
) : (
<>
<QuickActionButton

View File

@ -1,6 +1,6 @@
import React from "react";
import { render, screen, act } from "@testing-library/react";
import AutocommitStatusbar from "./AutocommitStatusbar";
import Statusbar from ".";
import "@testing-library/jest-dom";
// Mock timers using Jest
@ -17,13 +17,13 @@ const TOTAL_DURATION_MS = 4000;
const STEPS = 9;
const INTERVAL_MS = TOTAL_DURATION_MS / STEPS;
describe("AutocommitStatusbar Component", () => {
describe("Statusbar Component", () => {
afterEach(() => {
jest.clearAllTimers();
});
it("should render with initial percentage 0 when completed is false", () => {
render(<AutocommitStatusbar completed={false} />);
render(<Statusbar completed={false} />);
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(<AutocommitStatusbar completed={false} />);
render(<Statusbar completed={false} />);
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(<AutocommitStatusbar completed={false} />);
render(<Statusbar completed={false} />);
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(<AutocommitStatusbar completed />);
render(<Statusbar completed />);
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(<AutocommitStatusbar completed onHide={onHide} />);
render(<Statusbar completed onHide={onHide} />);
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(
<AutocommitStatusbar completed={false} onHide={onHide} />,
);
const { unmount } = render(<Statusbar completed={false} onHide={onHide} />);
// 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(
<AutocommitStatusbar completed={false} onHide={onHide} />,
<Statusbar completed={false} onHide={onHide} />,
);
const statusbar = screen.getByTestId("statusbar");
@ -129,7 +127,7 @@ describe("AutocommitStatusbar Component", () => {
expect(statusbar).toHaveTextContent("10%");
// Update the completed prop to true
rerender(<AutocommitStatusbar completed onHide={onHide} />);
rerender(<Statusbar completed onHide={onHide} />);
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(<AutocommitStatusbar completed />);
const { rerender } = render(<Statusbar completed />);
const statusbar = screen.getByTestId("statusbar");
expect(statusbar).toHaveTextContent("100%");
// Change completed to false
rerender(<AutocommitStatusbar completed={false} />);
rerender(<Statusbar completed={false} />);
expect(statusbar).toHaveTextContent("100%");
// Advance timer to check if percentage increments beyond 100%

View File

@ -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<number | null>(null);
const timeoutRef = useRef<number | null>(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 (
<StatusbarWrapper data-testid="t--autocommit-statusbar">
<Statusbar
<StatusbarWrapper data-testid="t--git-statusbar">
<ADSStatusBar
active={false}
message={createMessage(AUTOCOMMIT_IN_PROGRESS_MESSAGE)}
message={message}
percentage={percentage}
showOnlyMessage
/>

View File

@ -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);