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:
parent
8a142c43ae
commit
5262438802
|
|
@ -1,6 +1,7 @@
|
|||
export interface APIResponseError {
|
||||
code: string | number;
|
||||
message: string;
|
||||
errorType?: string;
|
||||
}
|
||||
|
||||
export interface ResponseMeta {
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
270
app/client/src/git/components/ConnectModal/AddDeployKey.test.tsx
Normal file
270
app/client/src/git/components/ConnectModal/AddDeployKey.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
373
app/client/src/git/components/ConnectModal/AddDeployKey.tsx
Normal file
373
app/client/src/git/components/ConnectModal/AddDeployKey.tsx
Normal 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">
|
||||
*
|
||||
</Text>
|
||||
</CheckboxTextContainer>
|
||||
</Checkbox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddDeployKey;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
233
app/client/src/git/components/ConnectModal/ChooseGitProvider.tsx
Normal file
233
app/client/src/git/components/ConnectModal/ChooseGitProvider.tsx
Normal 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">
|
||||
*
|
||||
</Text>
|
||||
</CheckboxTextContainer>
|
||||
</Checkbox>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChooseGitProvider;
|
||||
99
app/client/src/git/components/ConnectModal/CopyButton.tsx
Normal file
99
app/client/src/git/components/ConnectModal/CopyButton.tsx
Normal 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;
|
||||
146
app/client/src/git/components/ConnectModal/GenerateSSH.test.tsx
Normal file
146
app/client/src/git/components/ConnectModal/GenerateSSH.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
133
app/client/src/git/components/ConnectModal/GenerateSSH.tsx
Normal file
133
app/client/src/git/components/ConnectModal/GenerateSSH.tsx
Normal 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;
|
||||
121
app/client/src/git/components/ConnectModal/Steps.tsx
Normal file
121
app/client/src/git/components/ConnectModal/Steps.tsx
Normal 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;
|
||||
52
app/client/src/git/components/ConnectModal/common.tsx
Normal file
52
app/client/src/git/components/ConnectModal/common.tsx
Normal 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;
|
||||
`;
|
||||
26
app/client/src/git/components/ConnectModal/constants.ts
Normal file
26
app/client/src/git/components/ConnectModal/constants.ts
Normal 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`),
|
||||
},
|
||||
};
|
||||
216
app/client/src/git/components/ConnectModal/index.test.tsx
Normal file
216
app/client/src/git/components/ConnectModal/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
339
app/client/src/git/components/ConnectModal/index.tsx
Normal file
339
app/client/src/git/components/ConnectModal/index.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
|
|
@ -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
|
||||
/>
|
||||
12
app/client/src/git/components/utils.ts
Normal file
12
app/client/src/git/components/utils.ts
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user