feat: git connect v2 (#26725)

## Description
UX improvements for Git Connect Flow
https://zpl.io/W4AQoek

#### PR fixes following issue(s)
Fixes #25588

#### Media
> A video or a GIF is preferred. when using Loom, don’t embed because it
looks like it’s a GIF. instead, just link to the video
>
>
#### Type of change
- New feature (non-breaking change which adds functionality)
- This change requires a documentation update
>
>
>
## Testing
>
#### How Has This Been Tested?
> Please describe the tests that you ran to verify your changes. Also
list any relevant details for your test configuration.
> Delete anything that is not relevant
- [ ] Manual
- [ ] JUnit
- [ ] Jest
- [ ] Cypress
>
>
#### Test Plan
> Add Testsmith test cases links that relate to this PR
>
>
#### Issues raised during DP testing

https://github.com/appsmithorg/appsmith/pull/26725#issuecomment-1709723205

https://github.com/appsmithorg/appsmith/pull/26725#issuecomment-1711136694
>
>
>
## Checklist:
#### Dev activity
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


#### QA activity:
- [ ] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#speedbreakers-)
have been covered
- [ ] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#areas-of-interest-)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [ ] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [ ] Cypress test cases have been added and approved by SDET/manual QA
- [ ] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed
This commit is contained in:
Rudraprasad Das 2023-09-11 11:24:16 +05:30 committed by GitHub
parent 66d5027126
commit 68a439345d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 2366 additions and 133 deletions

View File

@ -1,10 +1,10 @@
import * as _ from "../../../../support/Objects/ObjectsCore";
let guid;
let guid: any;
let ws1Name;
let ws2Name;
let app1Name;
let repoName;
let app1Name: any;
let repoName: any;
let branchName;
describe("Issue 24486 - Issue with Export Application", () => {
@ -73,7 +73,7 @@ describe("Issue 24486 - Issue with Export Application", () => {
_.propPane.ValidatePropertyFieldValue("Label", "Submit");
});
// after(() => {
// _.gitSync.DeleteTestGithubRepo(repoName);
// });
after(() => {
_.gitSync.DeleteTestGithubRepo(repoName);
});
});

View File

@ -0,0 +1,30 @@
import { featureFlagIntercept } from "../../../../../support/Objects/FeatureFlags";
import * as _ from "../../../../../support/Objects/ObjectsCore";
let repoName: any;
describe("Git Connect V2", function () {
before(() => {
_.agHelper.GenerateUUID();
cy.get("@guid").then((uid) => {
_.homePage.CreateNewWorkspace("GitConnectV2" + uid, true);
_.homePage.CreateAppInWorkspace("GitConnectV2" + uid);
});
});
it("Test Git Connect V2", function () {
featureFlagIntercept({
release_git_connect_v2_enabled: true,
});
_.gitSync.CreateNConnectToGitV2();
cy.get("@gitRepoName").then((repName) => {
repoName = repName;
});
});
after(() => {
_.gitSync.DeleteTestGithubRepo(repoName);
});
});

View File

@ -160,6 +160,85 @@ export class GitSync {
}
}
private providerRadioOthers = "[data-testid='t--git-provider-radio-others']";
private existingEmptyRepoYes = "[data-testid='t--existing-empty-repo-yes']";
private gitConnectNextBtn = "[data-testid='t--git-connect-next-button']";
private remoteUrlInput = "[data-testid='git-connect-remote-url-input']";
private addedDeployKeyCheckbox =
"[data-testid='t--added-deploy-key-checkbox']";
private startUsingGitButton = "[data-testid='t--start-using-git-button']";
CreateNConnectToGitV2(
repoName = "Repo",
assertConnect = true,
privateFlag = false,
) {
this.agHelper.GenerateUUID();
cy.get("@guid").then((uid) => {
repoName += uid;
this.CreateTestGiteaRepo(repoName, privateFlag);
this.AuthorizeKeyToGiteaV2(repoName, assertConnect);
cy.wrap(repoName).as("gitRepoName");
});
}
public AuthorizeKeyToGiteaV2(repo: string, assertConnect = true) {
let generatedKey;
cy.intercept("POST", "/api/v1/applications/ssh-keypair/*").as(
`generateKey-${repo}`,
);
this.OpenGitSyncModal();
this.agHelper.GetNClick(this.providerRadioOthers);
this.agHelper.GetNClick(this.existingEmptyRepoYes);
this.agHelper.GetNClick(this.gitConnectNextBtn);
this.agHelper.AssertAttribute(
this.remoteUrlInput,
"placeholder",
"git@example.com:user/repository.git",
);
this.agHelper.TypeText(
this.remoteUrlInput,
`${this.dataManager.GITEA_API_URL_TED}/${repo}.git`,
);
this.agHelper.GetNClick(this.gitConnectNextBtn);
this.agHelper.GenerateUUID();
cy.get("@guid").then((uid) => {
cy.wait(`@generateKey-${repo}`).then((result: any) => {
generatedKey = result.response.body.data.publicKey;
generatedKey = generatedKey.slice(0, generatedKey.length - 1);
// fetch the generated key and post to the github repo
cy.request({
method: "POST",
url: `${this.dataManager.GITEA_API_BASE_TED}:${this.dataManager.GITEA_API_PORT_TED}/api/v1/repos/Cypress/${repo}/keys`,
headers: {
Authorization: `token ${Cypress.env("GITEA_TOKEN")}`,
},
body: {
title: "key_" + uid,
key: generatedKey,
read_only: false,
},
}).then((resp: any) => {
cy.log("Deploy Key Id ", resp.body.key_id);
cy.wrap(resp.body.key_id).as("deployKeyId");
});
});
});
this.agHelper.GetNClick(this.addedDeployKeyCheckbox, 0, true);
this.agHelper.GetNClick(this.gitConnectNextBtn);
if (assertConnect) {
this.assertHelper.AssertNetworkStatus("@connectGitLocalRepo");
this.agHelper.GetNClick(this.startUsingGitButton);
this.agHelper.AssertElementExist(this._bottomBarCommit, 0, 30000);
this.CloseGitSyncModal();
}
}
public ImportAppFromGit(
workspaceName: string,
repo: string,

View File

@ -1,4 +1,7 @@
import type { ReduxActionWithCallbacks } from "@appsmith/constants/ReduxActionConstants";
import type {
ReduxAction,
ReduxActionWithCallbacks,
} from "@appsmith/constants/ReduxActionConstants";
import {
ReduxActionErrorTypes,
ReduxActionTypes,
@ -11,6 +14,7 @@ import type {
GitRemoteStatusData,
} from "reducers/uiReducers/gitSyncReducer";
import type { ResponseMeta } from "api/ApiResponses";
import { noop } from "lodash";
export type GitStatusParams = {
compareRemote?: boolean;
@ -64,14 +68,14 @@ export type ConnectToGitResponse = {
type ConnectToGitRequestParams = {
payload: ConnectToGitPayload;
onSuccessCallback?: (payload: ConnectToGitResponse) => void;
onErrorCallback?: (error: string) => void;
onErrorCallback?: (error: any, response?: any) => void;
};
export type ConnectToGitReduxAction = ReduxActionWithCallbacks<
ConnectToGitPayload,
ConnectToGitResponse,
string
>;
export interface ConnectToGitReduxAction
extends ReduxAction<ConnectToGitPayload> {
onSuccessCallback?: (response: ConnectToGitResponse) => void;
onErrorCallback?: (error: Error, response?: any) => void;
}
export const connectToGitInit = ({
onErrorCallback,
@ -177,8 +181,13 @@ export const fetchGitStatusSuccess = (payload: GitStatusData) => ({
payload,
});
export const fetchGitRemoteStatusInit = () => ({
export const fetchGitRemoteStatusInit = ({
onSuccessCallback = noop,
onErrorCallback = noop,
} = {}) => ({
type: ReduxActionTypes.FETCH_GIT_REMOTE_STATUS_INIT,
onSuccessCallback,
onErrorCallback,
});
export const fetchGitRemoteStatusSuccess = (payload: GitRemoteStatusData) => ({

View File

@ -760,6 +760,9 @@ export const GIT_DISCONNECT_POPUP_SUBTITLE = () =>
`Git features will no more be shown for this application`;
export const GIT_DISCONNECT_POPUP_MAIN_HEADING = () => `Are you sure?`;
export const CONFIGURE_GIT = () => "Configure git";
export const SETTINGS_GIT = () => "Settings";
export const GIT_CONNECTION = () => "Git connection";
export const GIT_IMPORT = () => "Git import";
export const MERGE = () => "Merge";
@ -774,11 +777,14 @@ export const IMPORT_URL_INFO = () => `Paste the remote URL here:`;
export const REMOTE_URL_VIA = () => "Remote URL via";
export const USER_PROFILE_SETTINGS_TITLE = () => "User settings";
export const GIT_USER_SETTINGS_TITLE = () => "Git author";
export const USE_DEFAULT_CONFIGURATION = () => "Use default configuration";
export const AUTHOR_NAME = () => "Author name";
export const AUTHOR_NAME_CANNOT_BE_EMPTY = () => "Author name cannot be empty";
export const AUTHOR_EMAIL = () => "Author email";
export const AUTHOR_EMAIL_CANNOT_BE_EMPTY = () =>
"Author email cannot be empty";
export const NAME_YOUR_NEW_BRANCH = () => "Name your new branch";
export const SWITCH_BRANCHES = () => "Switch branches";
@ -829,7 +835,6 @@ export const GIT_USER_UPDATED_SUCCESSFULLY = () =>
export const REMOTE_URL_INPUT_PLACEHOLDER = () =>
"git@example.com:user/repository.git";
export const GIT_COMMIT_MESSAGE_PLACEHOLDER = () => "Your commit message here";
export const COPIED_SSH_KEY = () => "Copied SSH key";
export const INVALID_USER_DETAILS_MSG = () => "Please enter valid user details";
export const PASTE_SSH_URL_INFO = () =>
"Please enter a valid SSH URL of your repository";
@ -972,6 +977,69 @@ export const ERROR_GIT_AUTH_FAIL = () =>
export const ERROR_GIT_INVALID_REMOTE = () =>
"Either the remote repository doesn't exist or is unreachable.";
// GIT ERRORS end
// Git Connect V2
export const CHOOSE_A_GIT_PROVIDER_STEP = () => "Choose a git provider";
export const GENERATE_SSH_KEY_STEP = () => "Generate SSH key";
export const ADD_DEPLOY_KEY_STEP = () => "Add deploy key";
export const CHOOSE_GIT_PROVIDER_QUESTION = () =>
"To begin with, choose your git service provider";
export const IS_EMPTY_REPO_QUESTION = () =>
"Do you have an existing empty repository to connect to git?";
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 I_HAVE_EXISTING_REPO = () =>
"I have an existing appsmith app connected to git";
export const ERROR_REPO_NOT_EMPTY_TITLE = () =>
"The repo you added isn't empty";
export const ERROR_REPO_NOT_EMPTY_MESSAGE = () =>
"Kindly create a new repository and provide its remote SSH URL here. We require an empty repository to continue.";
export const READ_DOCS = () => "Read Docs";
export const COPY_SSH_URL_MESSAGE = () =>
"In your repo, copy the Remote SSH URL & paste it in the input field below.";
export const REMOTE_URL_INPUT_LABEL = () => "Remote SSH URL";
export const HOW_TO_COPY_REMOTE_URL = () =>
"How to copy & paste SSH remote URL";
export const ERROR_SSH_KEY_MISCONF_TITLE = () => "SSH key misconfiguration";
export const ERROR_SSH_KEY_MISCONF_MESSAGE = () =>
"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.";
export const ADD_DEPLOY_KEY_STEP_TITLE = () =>
"Add deploy key & give write access";
export const HOW_TO_ADD_DEPLOY_KEY = () =>
"How to paste SSH Key in repo and give write access?";
export const CONSENT_ADDED_DEPLOY_KEY = () =>
"I've added deploy key and gave it write access";
export const PREVIOUS_STEP = () => "Previous step";
export const GIT_CONNECT_SUCCESS_TITLE = () =>
"Successfully connected to your git remote repository";
export const GIT_CONNECT_SUCCESS_MESSAGE = () =>
"Now you can start collaborating with your team members by committing, merging and deploying your app";
export const START_USING_GIT = () => "Start using git";
export const GIT_AUTHOR = () => "Git author";
export const DISCONNECT_GIT = () => "Disconnect git";
export const DISCONNECT_GIT_MESSAGE = () =>
"Once you delete a repository, there is no going back. Please be certain.";
export const NEED_EMPTY_REPO_MESSAGE = () =>
"You need an empty repository to connect to Git on Appsmith, please create one on your Git service provider to continue.";
export const GIT_IMPORT_WAITING = () =>
"Please wait while we import the app...";
export const GIT_CONNECT_WAITING = () =>
"Please wait while we connect to git...";
export const CONNECT_GIT_TEXT = () => "Connect git";
export const ERROR_SSH_RECONNECT_MESSAGE = () =>
"We couldn't connect to the repo due to a missing deploy key. You can fix this in two ways:";
export const ERROR_SSH_RECONNECT_OPTION1 = () =>
"Copy the SSH key below and add it to your repository.";
export const ERROR_SSH_RECONNECT_OPTION2 = () =>
"If you want to connect a new repository, you can disconnect and do that instead.";
export const COPIED_SSH_KEY = () => "Copied SSH key";
export const NO_COPIED_SSH_KEY = () => "Could not copy SSH key";
// Git Connect V2 end
export const NAV_DESCRIPTION = () =>
`Navigate to any page, widget or file across this project.`;
export const ACTION_OPERATION_DESCRIPTION = () =>

View File

@ -22,6 +22,7 @@ export const FEATURE_FLAG = {
release_git_status_lite_enabled: "release_git_status_lite_enabled",
license_sso_saml_enabled: "license_sso_saml_enabled",
license_sso_oidc_enabled: "license_sso_oidc_enabled",
release_git_connect_v2_enabled: "release_git_connect_v2_enabled",
deprecate_custom_fusioncharts_enabled:
"deprecate_custom_fusioncharts_enabled",
} as const;
@ -49,6 +50,7 @@ export const DEFAULT_FEATURE_FLAG_VALUE: FeatureFlags = {
release_git_status_lite_enabled: false,
license_sso_saml_enabled: false,
license_sso_oidc_enabled: false,
release_git_connect_v2_enabled: false,
deprecate_custom_fusioncharts_enabled: false,
};

View File

@ -177,6 +177,12 @@ export type EventName =
| "SIGNPOSTING_MODAL_FIRST_TIME_OPEN"
| "SIGNPOSTING_STEP_COMPLETE"
| "GS_BRANCH_MORE_MENU_OPEN"
| "GS_CONFIGURE_GIT"
| "GS_IMPORT_VIA_GIT_DURING_GC"
| "GS_EXISTING_EMPTY_REPO"
| "GS_GENERATE_KEY_BUTTON_CLICK"
| "GS_CONNECT_BUTTON_ON_GIT_SYNC_MODAL_CLICK"
| "GS_START_USING_GIT"
| "GIT_DISCARD_WARNING"
| "GIT_DISCARD_CANCEL"
| "GIT_DISCARD"
@ -192,6 +198,7 @@ export type EventName =
| "GS_DEPLOY_GIT_CLICK"
| "GS_DEPLOY_GIT_MODAL_TRIGGERED"
| "GS_MERGE_GIT_MODAL_TRIGGERED"
| "GS_SETTINGS_GIT_MODAL_TRIGGERED"
| "GS_REPO_LIMIT_ERROR_MODAL_TRIGGERED"
| "GS_GIT_DOCUMENTATION_LINK_CLICK"
| "GS_MERGE_CHANGES_BUTTON_CLICK"

View File

@ -2,6 +2,7 @@ export enum GitSyncModalTab {
GIT_CONNECTION = "GIT_CONNECTION",
DEPLOY = "DEPLOY",
MERGE = "MERGE",
SETTINGS = "SETTINGS",
}
export type GitConfig = {

View File

@ -33,8 +33,14 @@ import {
} from "@appsmith/constants/messages";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { Space } from "./components/StyledComponents";
import { GitSyncModalTab } from "entities/GitSync";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "@appsmith/entities/FeatureFlag";
function DisconnectGitModal() {
const isGitConnectV2Enabled = useFeatureFlag(
FEATURE_FLAG.release_git_connect_v2_enabled,
);
const dispatch = useDispatch();
const isModalOpen = useSelector(getIsDisconnectGitModalOpen);
const disconnectingApp = useSelector(getDisconnectingGitApplication);
@ -44,7 +50,14 @@ function DisconnectGitModal() {
const handleClickOnBack = useCallback(() => {
dispatch(setIsDisconnectGitModalOpen(false));
dispatch(setIsGitSyncModalOpen({ isOpen: true }));
dispatch(
setIsGitSyncModalOpen({
isOpen: true,
tab: isGitConnectV2Enabled
? GitSyncModalTab.SETTINGS
: GitSyncModalTab.GIT_CONNECTION,
}),
);
dispatch(setDisconnectingGitApplication({ id: "", name: "" }));
}, [dispatch]);

View File

@ -7,46 +7,67 @@ import {
import { useDispatch, useSelector } from "react-redux";
import { setIsGitSyncModalOpen } from "actions/gitSyncActions";
import { setWorkspaceIdForImport } from "@appsmith/actions/applicationActions";
import Menu from "./Menu";
import { MENU_ITEMS_MAP } from "./constants";
import Deploy from "./Tabs/Deploy";
import Merge from "./Tabs/Merge";
import GitConnection from "./Tabs/GitConnection";
import Menu from "../Menu";
import Deploy from "../Tabs/Deploy";
import Merge from "../Tabs/Merge";
import GitConnection from "../Tabs/GitConnection";
import GitErrorPopup from "./components/GitErrorPopup";
import GitErrorPopup from "../components/GitErrorPopup";
import styled from "styled-components";
import { GitSyncModalTab } from "entities/GitSync";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { Modal, ModalContent, ModalHeader } from "design-system";
import { EnvInfoHeader } from "@appsmith/components/EnvInfoHeader";
import {
createMessage,
GIT_CONNECTION,
DEPLOY,
MERGE,
CONNECT_TO_GIT,
DEPLOY_YOUR_APPLICATION,
MERGE_CHANGES,
GIT_IMPORT,
IMPORT_FROM_GIT_REPOSITORY,
} from "@appsmith/constants/messages";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { useGitConnect } from "./hooks";
import { Modal, ModalContent, ModalHeader } from "design-system";
import { EnvInfoHeader } from "@appsmith/components/EnvInfoHeader";
import { GitSyncModalTab } from "entities/GitSync";
const ModalContentContainer = styled(ModalContent)`
min-height: 650px;
`;
const ComponentsByTab = {
const ComponentsByTab: { [K in GitSyncModalTab]?: any } = {
[GitSyncModalTab.GIT_CONNECTION]: GitConnection,
[GitSyncModalTab.DEPLOY]: Deploy,
[GitSyncModalTab.MERGE]: Merge,
};
const MENU_ITEMS_MAP: { [K in GitSyncModalTab]?: any } = {
[GitSyncModalTab.GIT_CONNECTION]: {
key: GitSyncModalTab.GIT_CONNECTION,
title: createMessage(GIT_CONNECTION),
modalTitle: createMessage(CONNECT_TO_GIT),
},
[GitSyncModalTab.DEPLOY]: {
key: GitSyncModalTab.DEPLOY,
title: createMessage(DEPLOY),
modalTitle: createMessage(DEPLOY_YOUR_APPLICATION),
},
[GitSyncModalTab.MERGE]: {
key: GitSyncModalTab.MERGE,
title: createMessage(MERGE),
modalTitle: createMessage(MERGE_CHANGES),
},
};
const allMenuOptions = Object.values(MENU_ITEMS_MAP);
function GitSyncModal(props: { isImport?: boolean }) {
function GitSyncModalV1(props: { isImport?: boolean }) {
const dispatch = useDispatch();
const isModalOpen = useSelector(getIsGitSyncModalOpen);
const isGitConnected = useSelector(getIsGitConnected);
const activeTabKey = useSelector(getActiveGitSyncModalTab);
const { onGitConnectFailure: resetGitConnectStatus } = useGitConnect();
const handleClose = useCallback(() => {
resetGitConnectStatus();
dispatch(setIsGitSyncModalOpen({ isOpen: false }));
dispatch(setWorkspaceIdForImport(""));
}, [dispatch, setIsGitSyncModalOpen]);
@ -144,4 +165,4 @@ function GitSyncModal(props: { isImport?: boolean }) {
);
}
export default GitSyncModal;
export default GitSyncModalV1;

View File

@ -0,0 +1,147 @@
import React, { useCallback } from "react";
import {
getActiveGitSyncModalTab,
getIsGitConnected,
getIsGitSyncModalOpen,
} from "selectors/gitSyncSelectors";
import { useDispatch, useSelector } from "react-redux";
import { setIsGitSyncModalOpen } from "actions/gitSyncActions";
import { setWorkspaceIdForImport } from "@appsmith/actions/applicationActions";
import Menu from "../Menu";
import Deploy from "../Tabs/Deploy";
import Merge from "../Tabs/Merge";
import GitErrorPopup from "../components/GitErrorPopup";
import {
CONFIGURE_GIT,
createMessage,
DEPLOY,
MERGE,
SETTINGS_GIT,
} from "@appsmith/constants/messages";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { Modal, ModalContent, ModalHeader } from "design-system";
import { EnvInfoHeader } from "@appsmith/components/EnvInfoHeader";
import GitConnectionV2 from "../Tabs/GitConnectionV2";
import GitSettings from "../Tabs/GitSettings";
import { GitSyncModalTab } from "entities/GitSync";
import ConnectionSuccess from "../Tabs/ConnectionSuccess";
import styled from "styled-components";
import ReconnectSSHError from "../components/ReconnectSSHError";
import { getCurrentAppGitMetaData } from "@appsmith/selectors/applicationSelectors";
const StyledModalContent = styled(ModalContent)`
&&& {
width: 640px;
transform: none !important;
top: 100px;
left: calc(50% - 320px);
max-height: calc(100vh - 200px);
}
`;
export const modalTitle: Partial<{ [K in GitSyncModalTab]: string }> = {
[GitSyncModalTab.GIT_CONNECTION]: createMessage(CONFIGURE_GIT),
};
const menuOptions = [
{
key: GitSyncModalTab.DEPLOY,
title: createMessage(DEPLOY),
},
{
key: GitSyncModalTab.MERGE,
title: createMessage(MERGE),
},
{
key: GitSyncModalTab.SETTINGS,
title: createMessage(SETTINGS_GIT),
},
];
const possibleMenuOptions = menuOptions.map((option) => option.key);
interface GitSyncModalV2Props {
isImport?: boolean;
}
function GitSyncModalV2({ isImport = false }: GitSyncModalV2Props) {
const gitMetadata = useSelector(getCurrentAppGitMetaData);
const isModalOpen = useSelector(getIsGitSyncModalOpen);
const isGitConnected = useSelector(getIsGitConnected);
let activeTabKey = useSelector(getActiveGitSyncModalTab);
if (!isGitConnected && activeTabKey !== GitSyncModalTab.GIT_CONNECTION) {
activeTabKey = GitSyncModalTab.GIT_CONNECTION;
}
const dispatch = useDispatch();
const setActiveTabKey = useCallback(
(tabKey: GitSyncModalTab) => {
if (tabKey === GitSyncModalTab.DEPLOY) {
AnalyticsUtil.logEvent("GS_DEPLOY_GIT_MODAL_TRIGGERED", {
source: `${activeTabKey}_TAB`,
});
} else if (tabKey === GitSyncModalTab.MERGE) {
AnalyticsUtil.logEvent("GS_MERGE_GIT_MODAL_TRIGGERED", {
source: `${activeTabKey}_TAB`,
});
} else if (tabKey === GitSyncModalTab.SETTINGS) {
AnalyticsUtil.logEvent("GS_SETTINGS_GIT_MODAL_TRIGGERED", {
source: `${activeTabKey}_TAB`,
});
}
dispatch(setIsGitSyncModalOpen({ isOpen: isModalOpen, tab: tabKey }));
},
[dispatch, setIsGitSyncModalOpen, isModalOpen],
);
const handleClose = useCallback(() => {
dispatch(setIsGitSyncModalOpen({ isOpen: false }));
dispatch(setWorkspaceIdForImport(""));
}, [dispatch, setIsGitSyncModalOpen]);
return (
<>
<Modal
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
open={isModalOpen}
>
<StyledModalContent data-testid="t--git-sync-modal">
<ModalHeader>
{modalTitle[activeTabKey] || gitMetadata?.repoName}
</ModalHeader>
<EnvInfoHeader />
{isGitConnected && <ReconnectSSHError />}
{possibleMenuOptions.includes(activeTabKey) && (
<Menu
activeTabKey={activeTabKey}
onSelect={(tabKey: string) =>
setActiveTabKey(tabKey as GitSyncModalTab)
}
options={menuOptions}
/>
)}
{activeTabKey === GitSyncModalTab.GIT_CONNECTION &&
(!isGitConnected ? (
<GitConnectionV2 isImport={isImport} />
) : (
<ConnectionSuccess />
))}
{activeTabKey === GitSyncModalTab.DEPLOY && <Deploy />}
{activeTabKey === GitSyncModalTab.MERGE && <Merge />}
{activeTabKey === GitSyncModalTab.SETTINGS && <GitSettings />}
</StyledModalContent>
</Modal>
<GitErrorPopup />
</>
);
}
export default GitSyncModalV2;

View File

@ -0,0 +1,23 @@
import { FEATURE_FLAG } from "@appsmith/entities/FeatureFlag";
import React from "react";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import GitSyncModalV1 from "./GitSyncModalV1";
import GitSyncModalV2 from "./GitSyncModalV2";
interface GitSyncModalProps {
isImport?: boolean;
}
function GitSyncModal(props: GitSyncModalProps) {
const isGitConnectV2Enabled = useFeatureFlag(
FEATURE_FLAG.release_git_connect_v2_enabled,
);
return isGitConnectV2Enabled ? (
<GitSyncModalV2 {...props} />
) : (
<GitSyncModalV1 {...props} />
);
}
export default GitSyncModal;

View File

@ -22,11 +22,7 @@ import {
import { Colors } from "constants/Colors";
import { useDispatch, useSelector } from "react-redux";
import {
gitPullInit,
setIsGitSyncModalOpen,
showConnectGitModal,
} from "actions/gitSyncActions";
import { gitPullInit, setIsGitSyncModalOpen } from "actions/gitSyncActions";
import { GitSyncModalTab } from "entities/GitSync";
import {
getCountOfChangesToCommit,
@ -41,6 +37,8 @@ import { inGuidedTour } from "selectors/onboardingSelectors";
import { getTypographyByKey } from "design-system-old";
import { Button, Icon, Tooltip } from "design-system";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "@appsmith/entities/FeatureFlag";
type QuickActionButtonProps = {
className?: string;
@ -139,18 +137,18 @@ const getPullBtnStatus = (gitStatus: any, pullFailed: boolean) => {
const getQuickActionButtons = ({
changesToCommit,
commit,
connect,
gitStatus,
isFetchingGitStatus,
merge,
pull,
pullDisabled,
pullTooltipMessage,
settings,
showPullLoadingState,
}: {
changesToCommit: number;
commit: () => void;
connect: () => void;
settings: () => void;
pull: () => void;
merge: () => void;
gitStatus: any;
@ -186,7 +184,7 @@ const getQuickActionButtons = ({
{
className: "t--bottom-git-settings",
icon: "settings-2-line",
onClick: connect,
onClick: settings,
tooltipText: createMessage(GIT_SETTINGS),
},
];
@ -253,7 +251,12 @@ function ConnectGitPlaceholder() {
source: "BOTTOM_BAR_GIT_CONNECT_BUTTON",
});
dispatch(showConnectGitModal());
dispatch(
setIsGitSyncModalOpen({
isOpen: true,
tab: GitSyncModalTab.GIT_CONNECTION,
}),
);
}}
size="sm"
>
@ -284,6 +287,10 @@ export default function QuickGitActions() {
const showPullLoadingState = isPullInProgress || isFetchingGitStatus;
const changesToCommit = useSelector(getCountOfChangesToCommit);
const isGitConnectV2Enabled = useFeatureFlag(
FEATURE_FLAG.release_git_connect_v2_enabled,
);
const quickActionButtons = getQuickActionButtons({
commit: () => {
dispatch(
@ -296,11 +303,13 @@ export default function QuickGitActions() {
source: "BOTTOM_BAR_GIT_COMMIT_BUTTON",
});
},
connect: () => {
settings: () => {
dispatch(
setIsGitSyncModalOpen({
isOpen: true,
tab: GitSyncModalTab.GIT_CONNECTION,
tab: isGitConnectV2Enabled
? GitSyncModalTab.SETTINGS
: GitSyncModalTab.GIT_CONNECTION,
}),
);
AnalyticsUtil.logEvent("GS_SETTING_CLICK", {

View File

@ -0,0 +1,75 @@
import { setIsGitSyncModalOpen } from "actions/gitSyncActions";
import {
GIT_CONNECT_SUCCESS_MESSAGE,
GIT_CONNECT_SUCCESS_TITLE,
START_USING_GIT,
createMessage,
} from "@appsmith/constants/messages";
import { Button, Icon, ModalBody, ModalFooter, Text } from "design-system";
import { GitSyncModalTab } from "entities/GitSync";
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import styled from "styled-components";
import { getCurrentAppGitMetaData } from "@appsmith/selectors/applicationSelectors";
import AnalyticsUtil from "utils/AnalyticsUtil";
const Container = styled.div``;
const TitleContainer = styled.div`
display: flex;
align-items: center;
margin-bottom: 16px;
`;
const TitleText = styled(Text)`
flex: 1;
font-weight: 600;
`;
const StyledIcon = styled(Icon)`
margin-right: 8px;
`;
function ConnectionSuccess() {
const gitMetadata = useSelector(getCurrentAppGitMetaData);
const dispatch = useDispatch();
const handleClose = () => {
dispatch(
setIsGitSyncModalOpen({
isOpen: true,
tab: GitSyncModalTab.DEPLOY,
}),
);
AnalyticsUtil.logEvent("GS_START_USING_GIT", {
repoUrl: gitMetadata?.remoteUrl,
});
};
return (
<>
<ModalBody>
<Container>
<TitleContainer>
<StyledIcon color="#059669" name="oval-check" size="lg" />
<TitleText kind="heading-s" renderAs="h3">
{createMessage(GIT_CONNECT_SUCCESS_TITLE)}
</TitleText>
</TitleContainer>
<Text renderAs="p">{createMessage(GIT_CONNECT_SUCCESS_MESSAGE)}</Text>
</Container>
</ModalBody>
<ModalFooter>
<Button
data-testid="t--start-using-git-button"
onClick={handleClose}
size="md"
>
{createMessage(START_USING_GIT)}
</Button>
</ModalFooter>
</>
);
}
export default ConnectionSuccess;

View File

@ -285,6 +285,7 @@ function Deploy() {
<Container
data-testid={"t--deploy-tab-container"}
ref={scrollWrapperRef}
style={{ minHeight: 360 }}
>
<Section>
{hasChangesToCommit && (
@ -428,7 +429,7 @@ function Deploy() {
)}
</Container>
</ModalBody>
<ModalFooter key="footer">
<ModalFooter key="footer" style={{ minHeight: 52 }}>
{showPullButton && (
<Button
className="t--pull-button"

View File

@ -0,0 +1,349 @@
import React, { useEffect, useState } from "react";
import {
DemoImage,
ErrorCallout,
FieldContainer,
WellContainer,
WellText,
WellTitle,
WellTitleContainer,
} from "./styles";
import {
Button,
Checkbox,
Collapsible,
CollapsibleContent,
CollapsibleHeader,
Icon,
Option,
Select,
Text,
toast,
} from "design-system";
import styled from "styled-components";
import { CopyButton } from "../../components/CopyButton";
import AnalyticsUtil from "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 "@appsmith/constants/messages";
import { useSSHKeyPair } from "../../hooks";
import type { GitProvider } from "./ChooseGitProvider";
import { GIT_DEMO_GIF } from "./constants";
import noop from "lodash/noop";
import { useSelector } from "react-redux";
import { getIsGitSyncModalOpen } from "selectors/gitSyncSelectors";
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 FlexRow = styled.div`
display: flex;
flex-direction: row;
width: 100%;
gap: 3px;
`;
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;
text-transform: uppercase;
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 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 "";
}
};
interface AddDeployKeyState {
gitProvider?: GitProvider;
isAddedDeployKey: boolean;
remoteUrl: string;
}
interface AddDeployKeyProps {
onChange: (args: Partial<AddDeployKeyState>) => void;
value: Partial<AddDeployKeyState>;
isImport?: boolean;
errorData?: any;
connectLoading?: boolean;
}
function AddDeployKey({
onChange = noop,
value = {},
isImport = false,
errorData,
connectLoading = false,
}: AddDeployKeyProps) {
const isModalOpen = useSelector(getIsGitSyncModalOpen);
const [fetched, setFetched] = useState(false);
const [sshKeyType, setSshKeyType] = useState<string>();
const {
deployKeyDocUrl,
fetchingSSHKeyPair,
fetchSSHKeyPair,
generateSSHKey,
generatingSSHKey,
SSHKeyPair,
} = useSSHKeyPair();
useEffect(() => {
if (isModalOpen && !isImport) {
if (!fetched) {
fetchSSHKeyPair({
onSuccessCallback: () => {
setFetched(true);
},
onErrorCallback: () => {
setFetched(true);
},
});
}
} else {
if (!fetched) {
setFetched(true);
}
}
}, [isImport, isModalOpen, fetched]);
useEffect(() => {
if (isModalOpen && fetched && !fetchingSSHKeyPair) {
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, fetchingSSHKeyPair, SSHKeyPair]);
useEffect(() => {
if (
isModalOpen &&
((sshKeyType && !SSHKeyPair) ||
(sshKeyType && !SSHKeyPair?.includes(sshKeyType.toLowerCase())))
) {
generateSSHKey(sshKeyType, {
onSuccessCallback: () => {
toast.show("SSH Key generated successfully", { kind: "success" });
},
});
}
}, [sshKeyType, SSHKeyPair, isModalOpen]);
const repositorySettingsUrl = getRepositorySettingsUrl(
value?.gitProvider,
value?.remoteUrl,
);
const loading = fetchingSSHKeyPair || generatingSSHKey;
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 ||
"https://docs.appsmith.com/advanced-concepts/version-control-with-git/connecting-to-git-repository"
}
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" ? (
<a
href={repositorySettingsUrl}
rel="noreferrer"
style={{ color: "var(--ads-color-brand)" }}
target="_blank"
>
repository settings.
</a>
) : (
"repository settings."
)}{" "}
Now, give write access to it.
</WellText>
<FieldContainer>
<StyledSelect
onChange={(v) => setSshKeyType(v)}
size="sm"
value={sshKeyType}
>
<Option value="ECDSA">ECDSA 256</Option>
<Option value="RSA">RSA 4096</Option>
</StyledSelect>
{!loading ? (
<DeployedKeyContainer>
<Icon
color="var(--ads-v2-color-fg)"
name="key-2-line"
size="md"
style={{ marginRight: 4 }}
/>
<KeyType>{sshKeyType}</KeyType>
<KeyText>{SSHKeyPair}</KeyText>
{!connectLoading && (
<CopyButton
onCopy={() => {
AnalyticsUtil.logEvent("GS_COPY_SSH_KEY_BUTTON_CLICK");
}}
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={(v) => onChange({ isAddedDeployKey: v })}
>
<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,235 @@
import React from "react";
import {
DemoImage,
FieldContainer,
FieldControl,
FieldQuestion,
WellContainer,
WellTitle,
WellTitleContainer,
} from "./styles";
import {
Callout,
Checkbox,
Collapsible,
CollapsibleContent,
CollapsibleHeader,
Icon,
Radio,
RadioGroup,
Text,
} from "design-system";
import styled from "styled-components";
import { GIT_DEMO_GIF } from "./constants";
import { useDispatch, useSelector } from "react-redux";
import { ReduxActionTypes } from "@appsmith/constants/ReduxActionConstants";
import { setWorkspaceIdForImport } from "@appsmith/actions/applicationActions";
import { setIsGitSyncModalOpen } from "actions/gitSyncActions";
import { GitSyncModalTab } from "entities/GitSync";
import { getCurrentAppWorkspace } from "@appsmith/selectors/workspaceSelectors";
import history from "utils/history";
import noop from "lodash/noop";
import { hasCreateNewAppPermission } from "@appsmith/utils/permissionHelpers";
import { useIsMobileDevice } from "utils/hooks/useDeviceDetect";
import {
CHOOSE_A_GIT_PROVIDER_STEP,
CHOOSE_GIT_PROVIDER_QUESTION,
HOW_TO_CREATE_EMPTY_REPO,
IMPORT_APP_IF_NOT_EMPTY,
IS_EMPTY_REPO_QUESTION,
I_HAVE_EXISTING_REPO,
NEED_EMPTY_REPO_MESSAGE,
createMessage,
} from "@appsmith/constants/messages";
import AnalyticsUtil from "utils/AnalyticsUtil";
const WellInnerContainer = styled.div`
padding-left: 16px;
`;
const CheckboxTextContainer = styled.div`
display: flex;
justify-content: flex-start;
`;
export type GitProvider = "github" | "gitlab" | "bitbucket" | "others";
interface ChooseGitProviderState {
gitProvider?: GitProvider;
gitEmptyRepoExists: string;
gitExistingRepoExists: boolean;
}
interface ChooseGitProviderProps {
onChange: (args: Partial<ChooseGitProviderState>) => void;
value: Partial<ChooseGitProviderState>;
isImport?: boolean;
}
function ChooseGitProvider({
onChange = noop,
value = {},
isImport = false,
}: ChooseGitProviderProps) {
const workspace = useSelector(getCurrentAppWorkspace);
const isMobile = useIsMobileDevice();
const dispatch = useDispatch();
const handleImport = () => {
history.push("/applications");
dispatch({
type: ReduxActionTypes.GIT_INFO_INIT,
});
dispatch(setWorkspaceIdForImport(workspace.id));
dispatch(
setIsGitSyncModalOpen({
isOpen: true,
tab: GitSyncModalTab.GIT_CONNECTION,
}),
);
AnalyticsUtil.logEvent("GS_IMPORT_VIA_GIT_DURING_GC");
};
const hasCreateNewApplicationPermission =
hasCreateNewAppPermission(workspace.userPermissions) && !isMobile;
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={(v) => {
if (
v === "github" ||
v === "gitlab" ||
v === "bitbucket" ||
v === "others"
) {
onChange({ gitProvider: v });
}
}}
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
renderAs="p"
style={{ opacity: !value?.gitProvider ? 0.5 : 1 }}
>
ii. {createMessage(IS_EMPTY_REPO_QUESTION)}{" "}
<Text color="var(--ads-v2-color-red-600)">*</Text>
</FieldQuestion>
<FieldControl>
<RadioGroup
isDisabled={!value?.gitProvider}
onChange={(v) => onChange({ gitEmptyRepoExists: v })}
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={
hasCreateNewApplicationPermission
? [{ children: "Import via git", onClick: handleImport }]
: []
}
>
{createMessage(IMPORT_APP_IF_NOT_EMPTY)}
</Callout>
) : null}
{isImport && (
<Checkbox
isSelected={value?.gitExistingRepoExists}
onChange={(v) => onChange({ gitExistingRepoExists: v })}
>
<CheckboxTextContainer>
<Text renderAs="p">{createMessage(I_HAVE_EXISTING_REPO)}</Text>
<Text color="var(--ads-v2-color-red-600)" renderAs="p">
&nbsp;*
</Text>
</CheckboxTextContainer>
</Checkbox>
)}
</>
);
}
export default ChooseGitProvider;

View File

@ -0,0 +1,126 @@
import React, { useState } from "react";
import {
DemoImage,
ErrorCallout,
FieldContainer,
WellContainer,
WellText,
WellTitle,
WellTitleContainer,
} from "./styles";
import {
Button,
Collapsible,
CollapsibleContent,
CollapsibleHeader,
Icon,
Input,
Text,
} from "design-system";
import { isValidGitRemoteUrl } from "../../utils";
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 "@appsmith/constants/messages";
import type { GitProvider } from "./ChooseGitProvider";
import { GIT_DEMO_GIF } from "./constants";
import noop from "lodash/noop";
interface GenerateSSHState {
gitProvider?: GitProvider;
remoteUrl: string;
}
interface GenerateSSHProps {
onChange: (args: Partial<GenerateSSHState>) => void;
value: Partial<GenerateSSHState>;
errorData?: any;
}
function GenerateSSH({
onChange = noop,
value = {},
errorData,
}: GenerateSSHProps) {
const [isTouched, setIsTouched] = useState(false);
const isInvalid =
isTouched &&
(typeof value?.remoteUrl !== "string" ||
!isValidGitRemoteUrl(value?.remoteUrl));
const handleChange = (v: string) => {
setIsTouched(true);
onChange({ remoteUrl: v });
};
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="https://docs.appsmith.com/advanced-concepts/version-control-with-git/connecting-to-git-repository"
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,116 @@
import { Button, Divider, Text } from "design-system";
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);
}
`;
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 StepsProps {
steps: {
key: string;
text: string;
}[];
activeKey: string;
onActiveKeyChange: (activeKey: string) => void;
}
function Steps({
steps = [],
activeKey,
onActiveKeyChange = noop,
}: StepsProps) {
const activeIndex = steps.findIndex((s) => s.key === activeKey);
return (
<Container>
{steps.map((step, index) => {
return (
<Fragment key={step.key}>
{index > 0 && <StepLine />}
<StepButton
isDisabled={index > activeIndex}
kind="tertiary"
onClick={() => {
if (index < activeIndex) {
onActiveKeyChange(step.key);
}
}}
role="button"
size="md"
style={{ opacity: index > activeIndex ? 0.6 : 1 }}
>
<StepNumber active={step.key === activeKey}>
{index + 1}
</StepNumber>
<StepText>{step.text}</StepText>
</StepButton>
</Fragment>
);
})}
</Container>
);
}
export default Steps;

View File

@ -0,0 +1,26 @@
import { getAssetUrl } from "@appsmith/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,280 @@
import React, { useState } from "react";
import { Button, ModalBody, ModalFooter } from "design-system";
import Steps from "./Steps";
import type { GitProvider } from "./ChooseGitProvider";
import ChooseGitProvider from "./ChooseGitProvider";
import GenerateSSH from "./GenerateSSH";
import AddDeployKey from "./AddDeployKey";
import styled from "styled-components";
import { GIT_CONNECT_STEPS } from "./constants";
import { useGitConnect } from "../../hooks";
import { isValidGitRemoteUrl } from "../../utils";
import { importAppFromGit } from "actions/gitSyncActions";
import { useDispatch, useSelector } from "react-redux";
import { getIsImportingApplicationViaGit } from "selectors/gitSyncSelectors";
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,
PREVIOUS_STEP,
createMessage,
} from "@appsmith/constants/messages";
import GitSyncStatusbar from "../../components/Statusbar";
import AnalyticsUtil from "utils/AnalyticsUtil";
interface StyledModalFooterProps {
loading?: boolean;
}
const StepContent = styled.div`
display: flex;
flex-direction: column;
max-height: 580px;
`;
const StyledModalFooter = styled(ModalFooter)<StyledModalFooterProps>`
justify-content: space-between;
flex-direction: ${(p) => (!p.loading ? "row-reverse" : "row")};
`;
const StatusbarWrapper = styled.div`
margin-top: 16px;
> div {
display: flex;
height: initial;
align-items: center;
}
> div > div {
margin-top: 0px;
width: 200px;
margin-right: 12px;
}
> div > p {
margin-top: 0;
}
`;
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);
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(CONNECT_GIT_TEXT),
};
interface FormDataState {
gitProvider?: GitProvider;
gitEmptyRepoExists?: string;
gitExistingRepoExists?: boolean;
remoteUrl?: string;
isAddedDeployKey?: boolean;
sshKeyType?: "RSA" | "ECDSA";
}
interface GitConnectionV2Props {
isImport?: boolean;
}
function GitConnectionV2({ isImport = false }: GitConnectionV2Props) {
const [errorData, setErrorData] = useState<any>();
const isImportingViaGit = useSelector(getIsImportingApplicationViaGit);
const dispatch = useDispatch();
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 { connectToGit, isConnectingToGit } = useGitConnect();
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 = () => {
if (currentIndex > 0) {
setActiveStep(steps[currentIndex - 1].key);
}
};
const handleNextStep = () => {
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: "",
};
if (formData.remoteUrl) {
if (!isImport) {
connectToGit(
{
remoteUrl: formData.remoteUrl,
gitProfile,
isDefaultProfile: true,
},
{
onErrorCallback: (err: Error, response?: any) => {
// 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 {
dispatch(
importAppFromGit({
payload: {
remoteUrl: formData.remoteUrl,
gitProfile,
isDefaultProfile: true,
},
onErrorCallback(error, response) {
setErrorData(response);
},
}),
);
}
}
break;
}
}
}
};
const stepProps = {
onChange: handleChange,
value: formData,
isImport,
errorData,
};
const loading =
(!isImport && isConnectingToGit) || (isImport && isImportingViaGit);
return (
<>
<ModalBody>
{possibleSteps.includes(activeStep) && (
<Steps
activeKey={activeStep}
onActiveKeyChange={setActiveStep}
steps={steps}
/>
)}
<StepContent>
{activeStep === GIT_CONNECT_STEPS.CHOOSE_PROVIDER && (
<ChooseGitProvider {...stepProps} />
)}
{activeStep === GIT_CONNECT_STEPS.GENERATE_SSH_KEY && (
<GenerateSSH {...stepProps} />
)}
{activeStep === GIT_CONNECT_STEPS.ADD_DEPLOY_KEY && (
<AddDeployKey {...stepProps} connectLoading={loading} />
)}
</StepContent>
</ModalBody>
<StyledModalFooter loading={loading}>
{loading && (
<StatusbarWrapper className="t--connect-statusbar">
<GitSyncStatusbar
completed={!loading}
message={createMessage(
isImport ? GIT_IMPORT_WAITING : GIT_CONNECT_WAITING,
)}
period={4}
/>
</StatusbarWrapper>
)}
{!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
isDisabled={loading}
kind="secondary"
onClick={handlePreviousStep}
size="md"
startIcon="arrow-left-s-line"
>
{createMessage(PREVIOUS_STEP)}
</Button>
)}
</StyledModalFooter>
</>
);
}
export default GitConnectionV2;

View File

@ -0,0 +1,50 @@
import { Callout, Text } from "design-system";
import styled from "styled-components";
export const WellContainer = styled.div`
padding: 16px;
border-radius: 4px;
background-color: var(--ads-color-background-secondary);
margin-bottom: 16px;
overflow-y: auto;
max-height: calc(100vh - 540px);
`;
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)`
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,81 @@
import {
DANGER_ZONE,
DISCONNECT_GIT,
DISCONNECT_GIT_MESSAGE,
createMessage,
} from "@appsmith/constants/messages";
import {
setDisconnectingGitApplication,
setIsDisconnectGitModalOpen,
setIsGitSyncModalOpen,
} from "actions/gitSyncActions";
import { Button, Text } from "design-system";
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { getCurrentApplication } from "selectors/editorSelectors";
import styled from "styled-components";
import AnalyticsUtil from "utils/AnalyticsUtil";
const Container = styled.div`
padding-top: 16px;
padding-bottom: 16px;
`;
const HeadContainer = styled.div`
margin-bottom: 16px;
`;
const BodyContainer = styled.div`
display: flex;
`;
const BodyInnerContainer = styled.div`
flex: 1;
margin-right: 32px;
`;
const SectionTitle = styled(Text)`
font-weight: 600;
`;
function GitDisconnect() {
const dispatch = useDispatch();
const currentApp = useSelector(getCurrentApplication);
const handleDisconnect = () => {
AnalyticsUtil.logEvent("GS_DISCONNECT_GIT_CLICK", {
source: "GIT_CONNECTION_MODAL",
});
dispatch(setIsGitSyncModalOpen({ isOpen: false }));
dispatch(
setDisconnectingGitApplication({
id: currentApp?.id || "",
name: currentApp?.name || "",
}),
);
dispatch(setIsDisconnectGitModalOpen(true));
};
return (
<Container>
<HeadContainer>
<SectionTitle kind="heading-s">
{createMessage(DANGER_ZONE)}
</SectionTitle>
</HeadContainer>
<BodyContainer>
<BodyInnerContainer>
<Text kind="heading-xs" renderAs="p">
{createMessage(DISCONNECT_GIT)}
</Text>
<Text renderAs="p">{createMessage(DISCONNECT_GIT_MESSAGE)}</Text>
</BodyInnerContainer>
<Button kind="error" onClick={handleDisconnect} size="md">
{createMessage(DISCONNECT_GIT)}
</Button>
</BodyContainer>
</Container>
);
}
export default GitDisconnect;

View File

@ -0,0 +1,296 @@
import React, { useEffect, useMemo } from "react";
import { Space } from "../../components/StyledComponents";
import {
AUTHOR_EMAIL,
AUTHOR_EMAIL_CANNOT_BE_EMPTY,
AUTHOR_NAME,
AUTHOR_NAME_CANNOT_BE_EMPTY,
FORM_VALIDATION_INVALID_EMAIL,
GIT_USER_SETTINGS_TITLE,
UPDATE_CONFIG,
USE_DEFAULT_CONFIGURATION,
createMessage,
} from "@appsmith/constants/messages";
import styled, { keyframes } from "styled-components";
import { Button, Input, Switch, Text } from "design-system";
import {
getGlobalGitConfig,
getLocalGitConfig,
getIsFetchingGlobalGitConfig,
getIsFetchingLocalGitConfig,
} from "selectors/gitSyncSelectors";
import { useDispatch, useSelector } from "react-redux";
import type { SubmitHandler } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { omit } from "lodash";
import {
fetchGlobalGitConfigInit,
fetchLocalGitConfigInit,
updateLocalGitConfigInit,
} from "actions/gitSyncActions";
const Container = styled.div`
padding-top: 16px;
padding-bottom: 16px;
`;
const HeadContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const InputContainer = styled.div`
margin-bottom: ${(props) => props.theme.spaces[5]}px;
`;
const SectionTitle = styled(Text)`
font-weight: 600;
`;
const loadingKeyframe = keyframes`
100% {
transform: translateX(100%);
}
`;
const DummyLabel = styled.div`
height: 17px;
width: 100px;
margin-bottom: 8px;
border-radius: 4px;
background-color: var(--ads-color-black-100);
position: relative;
overflow: hidden;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.5) 20%,
rgba(255, 255, 255, 0.8) 60%,
rgba(255, 255, 255, 0)
);
animation: ${loadingKeyframe} 5s infinite;
content: "";
}
`;
const DummyInput = styled.div`
height: 36px;
border-radius: 4px;
background-color: linear-gradient(
90deg,
var(--ads-color-black-200) 0%,
rgba(240, 240, 240, 0) 100%
);
background-color: var(--ads-color-black-100);
position: relative;
overflow: hidden;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.5) 20%,
rgba(255, 255, 255, 0.8) 60%,
rgba(255, 255, 255, 0)
);
animation: ${loadingKeyframe} 5s infinite;
content: "";
}
`;
const DummyField = () => {
return (
<div style={{ width: "100%" }}>
<DummyLabel />
<DummyInput />
</div>
);
};
export type AuthorInfo = {
authorName: string;
authorEmail: string;
useGlobalProfile: boolean;
};
const GitUserSettings = () => {
const dispatch = useDispatch();
const isFetchingGlobalGitConfig = useSelector(getIsFetchingGlobalGitConfig);
const isFetchingLocalGitConfig = useSelector(getIsFetchingLocalGitConfig);
const globalConfig = useSelector(getGlobalGitConfig);
const localConfig = useSelector(getLocalGitConfig);
const {
control,
formState: { errors },
handleSubmit,
register,
setValue,
watch,
} = useForm<AuthorInfo>();
const useGlobalProfile = watch("useGlobalProfile");
const authorName = watch("authorName");
const authorEmail = watch("authorEmail");
useEffect(() => {
dispatch(fetchGlobalGitConfigInit());
dispatch(fetchLocalGitConfigInit());
}, []);
useEffect(() => {
if (!isFetchingGlobalGitConfig && !isFetchingLocalGitConfig) {
setValue("useGlobalProfile", !!localConfig?.useGlobalProfile);
}
}, [isFetchingGlobalGitConfig, isFetchingLocalGitConfig]);
useEffect(() => {
if (!isFetchingGlobalGitConfig && !isFetchingLocalGitConfig) {
if (!useGlobalProfile) {
setValue("authorName", localConfig?.authorName);
setValue("authorEmail", localConfig?.authorEmail);
} else {
setValue("authorName", globalConfig?.authorName);
setValue("authorEmail", globalConfig?.authorEmail);
}
}
}, [isFetchingGlobalGitConfig, isFetchingLocalGitConfig, useGlobalProfile]);
const onSubmit: SubmitHandler<AuthorInfo> = (data) => {
if (data.useGlobalProfile) {
data.authorName = localConfig?.authorName;
data.authorEmail = localConfig?.authorEmail;
}
dispatch(updateLocalGitConfigInit(data));
};
const isSubmitAllowed = useMemo(() => {
if (!isFetchingGlobalGitConfig && !isFetchingLocalGitConfig) {
if (useGlobalProfile) {
return (
authorName !== globalConfig?.authorName ||
authorEmail !== globalConfig?.authorEmail ||
useGlobalProfile !== localConfig?.useGlobalProfile
);
} else {
return (
authorName !== localConfig?.authorName ||
authorEmail !== localConfig?.authorEmail ||
useGlobalProfile !== localConfig?.useGlobalProfile
);
}
} else {
return false;
}
}, [
isFetchingGlobalGitConfig,
isFetchingLocalGitConfig,
localConfig,
globalConfig,
useGlobalProfile,
authorName,
authorEmail,
]);
const loading = isFetchingGlobalGitConfig || isFetchingLocalGitConfig;
return (
<Container>
<form onSubmit={handleSubmit(onSubmit)}>
<HeadContainer>
<SectionTitle kind="heading-s">
{createMessage(GIT_USER_SETTINGS_TITLE)}
</SectionTitle>
<div>
<Controller
control={control}
name="useGlobalProfile"
render={({ field }) => {
return (
<Switch
data-testid="t--git-user-settings-switch"
isDisabled={loading}
{...omit(field, ["value"])}
isSelected={field.value}
>
{createMessage(USE_DEFAULT_CONFIGURATION)}
</Switch>
);
}}
/>
</div>
</HeadContainer>
<Space size={5} />
<InputContainer>
{!loading ? (
<Input
data-testid="t--git-user-settings-author-name-input"
errorMessage={errors?.authorName?.message}
isReadOnly={useGlobalProfile}
isValid={!errors?.authorName}
label={createMessage(AUTHOR_NAME)}
size="md"
type="text"
{...register("authorName", {
required: createMessage(AUTHOR_NAME_CANNOT_BE_EMPTY),
})}
// onChange is overwritten with setValue
onChange={(v) => setValue("authorName", v)}
/>
) : (
<DummyField />
)}
</InputContainer>
<InputContainer>
{!loading ? (
<Input
data-testid="t--git-user-settings-author-email-input"
errorMessage={errors?.authorEmail?.message}
isReadOnly={useGlobalProfile}
isValid={!errors?.authorEmail}
label={createMessage(AUTHOR_EMAIL)}
size="md"
// type="email"
{...register("authorEmail", {
required: createMessage(AUTHOR_EMAIL_CANNOT_BE_EMPTY),
pattern: {
value: /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/,
message: createMessage(FORM_VALIDATION_INVALID_EMAIL),
},
})}
// onChange is overwritten with setValue
onChange={(v) => setValue("authorEmail", v)}
/>
) : (
<DummyField />
)}
</InputContainer>
<div>
<Button isDisabled={!isSubmitAllowed} size="md" type="submit">
{createMessage(UPDATE_CONFIG)}
</Button>
</div>
</form>
</Container>
);
};
export default GitUserSettings;

View File

@ -0,0 +1,23 @@
import React from "react";
import GitUserSettings from "./GitUserSettings";
import GitDisconnect from "./GitDisconnect";
import styled from "styled-components";
import { ModalBody } from "design-system";
const Container = styled.div`
overflow: auto;
min-height: calc(360px + 52px);
`;
function GitSettings() {
return (
<ModalBody>
<Container>
<GitUserSettings />
<GitDisconnect />
</Container>
</ModalBody>
);
}
export default GitSettings;

View File

@ -220,7 +220,9 @@ export default function Merge() {
return (
<>
<ModalBody>
<Container style={{ overflow: "unset", paddingBottom: "4px" }}>
<Container
style={{ minHeight: 360, overflow: "unset", paddingBottom: "4px" }}
>
<Text color={"var(--ads-v2-color-fg-emphasis)"} kind="heading-s">
{createMessage(SELECT_BRANCH_TO_MERGE)}
</Text>
@ -288,7 +290,7 @@ export default function Merge() {
) : null}
</Container>
</ModalBody>
<ModalFooter>
<ModalFooter style={{ minHeight: 52 }}>
{!showMergeSuccessIndicator && showMergeButton ? (
<Button
className="t--git-merge-button"

View File

@ -0,0 +1,74 @@
import React, { useRef, useState } from "react";
import { Button, Icon, Tooltip } from "design-system";
import styled from "styled-components";
import copy from "copy-to-clipboard";
import noop from "lodash/noop";
export const TooltipWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
export const IconContainer = styled.div``;
interface CopyButtonProps {
value?: string;
delay?: number;
onCopy?: () => void;
tooltipMessage?: string;
isDisabled?: boolean;
}
export function CopyButton({
value,
delay = 2000,
onCopy = noop,
tooltipMessage,
isDisabled = false,
}: CopyButtonProps) {
const timerRef = useRef<number>();
const [showCopied, setShowCopied] = useState(false);
const stopShowingCopiedAfterDelay = () => {
timerRef.current = setTimeout(() => {
setShowCopied(false);
}, delay);
};
const copyToClipboard = () => {
if (value) {
copy(value);
setShowCopied(true);
stopShowingCopiedAfterDelay();
}
onCopy();
};
return (
<>
{showCopied ? (
<IconContainer>
<Icon
color="var(--ads-v2-color-fg-success)"
name="check-line"
size="lg"
/>
</IconContainer>
) : (
<TooltipWrapper>
<Tooltip content={tooltipMessage}>
<Button
className="t--copy-ssh-key"
isDisabled={isDisabled}
isIconButton
kind="tertiary"
onClick={copyToClipboard}
size="sm"
startIcon="duplicate"
/>
</Tooltip>
</TooltipWrapper>
)}{" "}
</>
);
}

View File

@ -0,0 +1,89 @@
import { fetchGitRemoteStatusInit } from "actions/gitSyncActions";
import { Callout, Text, toast } from "design-system";
import React, { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import styled from "styled-components";
import { useSSHKeyPair } from "../hooks";
import copy from "copy-to-clipboard";
import {
COPIED_SSH_KEY,
COPY_SSH_KEY,
ERROR_SSH_RECONNECT_MESSAGE,
ERROR_SSH_RECONNECT_OPTION1,
ERROR_SSH_RECONNECT_OPTION2,
NO_COPIED_SSH_KEY,
createMessage,
} from "@appsmith/constants/messages";
const NumberedList = styled.ol`
list-style-type: decimal;
margin-left: 20px;
`;
const StyledCallout = styled(Callout)`
margin-bottom: 24px;
`;
function ReconnectSSHError() {
const [errorData, setErrorData] = useState<{ error: Error; response: any }>();
const dispatch = useDispatch();
const { fetchingSSHKeyPair, fetchSSHKeyPair, SSHKeyPair } = useSSHKeyPair();
useEffect(() => {
dispatch(
fetchGitRemoteStatusInit({
onErrorCallback: (error, response) => {
setErrorData({ error, response });
},
}),
);
fetchSSHKeyPair();
}, []);
const handleClickOnCopy = () => {
if (SSHKeyPair) {
copy(SSHKeyPair);
toast.show(createMessage(COPIED_SSH_KEY), { kind: "success" });
} else {
toast.show(createMessage(NO_COPIED_SSH_KEY), { kind: "error" });
}
};
if (!errorData) {
return null;
}
if (
errorData &&
errorData.response.responseMeta.error.code === "AE-GIT-4044"
) {
return (
<StyledCallout
kind="error"
links={[
{
children: createMessage(COPY_SSH_KEY),
onClick: handleClickOnCopy,
startIcon: "copy-control",
isDisabled: fetchingSSHKeyPair,
},
]}
>
<Text renderAs="p">{createMessage(ERROR_SSH_RECONNECT_MESSAGE)}</Text>
<NumberedList>
<li>{createMessage(ERROR_SSH_RECONNECT_OPTION1)}</li>
<li>{createMessage(ERROR_SSH_RECONNECT_OPTION2)}</li>
</NumberedList>
</StyledCallout>
);
}
return (
<StyledCallout kind="error">
{errorData.error?.message || "There was an unexpected error"}
</StyledCallout>
);
}
export default ReconnectSSHError;

View File

@ -1,32 +1,3 @@
import {
createMessage,
GIT_CONNECTION,
DEPLOY,
MERGE,
CONNECT_TO_GIT,
DEPLOY_YOUR_APPLICATION,
MERGE_CHANGES,
} from "@appsmith/constants/messages";
import { GitSyncModalTab } from "entities/GitSync";
export const MENU_ITEMS_MAP = {
[GitSyncModalTab.GIT_CONNECTION]: {
key: GitSyncModalTab.GIT_CONNECTION,
title: createMessage(GIT_CONNECTION),
modalTitle: createMessage(CONNECT_TO_GIT),
},
[GitSyncModalTab.DEPLOY]: {
key: GitSyncModalTab.DEPLOY,
title: createMessage(DEPLOY),
modalTitle: createMessage(DEPLOY_YOUR_APPLICATION),
},
[GitSyncModalTab.MERGE]: {
key: GitSyncModalTab.MERGE,
title: createMessage(MERGE),
modalTitle: createMessage(MERGE_CHANGES),
},
};
export enum AUTH_TYPE {
SSH = "SSH",
HTTPS = "HTTPS",

View File

@ -2,38 +2,43 @@ import { useDispatch } from "react-redux";
import { useCallback, useState } from "react";
import type { ConnectToGitPayload } from "api/GitSyncAPI";
import { connectToGitInit } from "actions/gitSyncActions";
import noop from "lodash/noop";
export const useGitConnect = () => {
const dispatch = useDispatch();
const [errResponse, setErrResponse] = useState();
const [isConnectingToGit, setIsConnectingToGit] = useState(false);
const onGitConnectSuccess = useCallback(() => {
setIsConnectingToGit(false);
}, [setIsConnectingToGit]);
const onGitConnectFailure = useCallback(() => {
setIsConnectingToGit(false);
}, [setIsConnectingToGit]);
const connectToGit = useCallback(
(payload: ConnectToGitPayload) => {
(
payload: ConnectToGitPayload,
{ onErrorCallback = noop, onSuccessCallback = noop } = {},
) => {
setIsConnectingToGit(true);
setErrResponse(undefined);
// Here after the ssh key pair generation, we fetch the application data again and on success of it
dispatch(
connectToGitInit({
payload,
onSuccessCallback: onGitConnectSuccess,
onErrorCallback: onGitConnectFailure,
onSuccessCallback: (data) => {
onSuccessCallback(data);
setIsConnectingToGit(false);
},
onErrorCallback: (err, response) => {
onErrorCallback(err, response);
setErrResponse(response);
setIsConnectingToGit(false);
},
}),
);
},
[onGitConnectSuccess, onGitConnectFailure, setIsConnectingToGit],
[setIsConnectingToGit],
);
return {
isConnectingToGit,
connectToGit,
onGitConnectFailure,
connectErrorResponse: errResponse,
};
};

View File

@ -3,8 +3,9 @@ import {
getSSHKeyDeployDocUrl,
getSshKeyPair,
} from "selectors/gitSyncSelectors";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { generateSSHKeyPair, getSSHKeyPair } from "actions/gitSyncActions";
import noop from "lodash/noop";
export const useSSHKeyPair = () => {
// As SSHKeyPair fetching and generation is only done only for GitConnection part,
@ -21,44 +22,50 @@ export const useSSHKeyPair = () => {
const [failedGeneratingSSHKey, setFailedGeneratingSSHKey] = useState(false);
useEffect(() => {
// on change of sshKeyPair if it is defined, then stop the loading state.
if (SSHKeyPair) {
if (generatingSSHKey) setIsGeneratingSSHKey(false);
if (fetchingSSHKeyPair) setIsFetchingSSHKeyPair(false);
}
}, [SSHKeyPair]);
const fetchSSHKeyPair = useCallback(() => {
setIsFetchingSSHKeyPair(true);
dispatch(
getSSHKeyPair({
onErrorCallback: () => {
setIsFetchingSSHKeyPair(false);
},
}),
);
}, [setIsFetchingSSHKeyPair]);
const onGenerateSSHKeyFailure = useCallback(() => {
setIsGeneratingSSHKey(false);
setFailedGeneratingSSHKey(true);
}, [setIsGeneratingSSHKey]);
const fetchSSHKeyPair = useCallback(
({ onSuccessCallback = noop, onErrorCallback = noop } = {}) => {
setIsFetchingSSHKeyPair(true);
dispatch(
getSSHKeyPair({
onErrorCallback: (e) => {
onErrorCallback(e);
setIsFetchingSSHKeyPair(false);
},
onSuccessCallback: (data) => {
onSuccessCallback(data);
setIsFetchingSSHKeyPair(false);
},
}),
);
},
[setIsFetchingSSHKeyPair],
);
const generateSSHKey = useCallback(
(keyType = "ECDSA") => {
(
keyType = "ECDSA",
{ onSuccessCallback = noop, onErrorCallback = noop } = {},
) => {
// if (currentApplication?.id) {
setIsGeneratingSSHKey(true);
setFailedGeneratingSSHKey(false);
dispatch(
generateSSHKeyPair({
onErrorCallback: onGenerateSSHKeyFailure,
onErrorCallback: (e) => {
onErrorCallback(e);
setIsGeneratingSSHKey(false);
setFailedGeneratingSSHKey(true);
},
onSuccessCallback: (data) => {
onSuccessCallback(data);
setIsGeneratingSSHKey(false);
},
payload: { keyType },
}),
);
},
[onGenerateSSHKeyFailure, setIsGeneratingSSHKey],
[setIsGeneratingSSHKey],
);
return {

View File

@ -24,7 +24,7 @@ const initialState: GitSyncReducerState = {
localGitConfig: { authorEmail: "", authorName: "" },
isFetchingLocalGitConfig: false,
isFetchingGitConfig: false,
isFetchingGlobalGitConfig: false,
isMerging: false,
tempRemoteUrl: "",
@ -124,7 +124,7 @@ const gitSyncReducer = createReducer(initialState, {
state: GitSyncReducerState,
) => ({
...state,
isFetchingGitConfig: true,
isFetchingGlobalGitConfig: true,
connectError: null,
commitAndPushError: null,
pullError: null,
@ -135,7 +135,7 @@ const gitSyncReducer = createReducer(initialState, {
state: GitSyncReducerState,
) => ({
...state,
isFetchingGitConfig: true,
isFetchingGlobalGitConfig: true,
connectError: null,
commitAndPushError: null,
pullError: null,
@ -148,7 +148,7 @@ const gitSyncReducer = createReducer(initialState, {
) => ({
...state,
globalGitConfig: action.payload,
isFetchingGitConfig: false,
isFetchingGlobalGitConfig: false,
}),
[ReduxActionTypes.UPDATE_GLOBAL_GIT_CONFIG_SUCCESS]: (
state: GitSyncReducerState,
@ -156,19 +156,19 @@ const gitSyncReducer = createReducer(initialState, {
) => ({
...state,
globalGitConfig: action.payload,
isFetchingGitConfig: false,
isFetchingGlobalGitConfig: false,
}),
[ReduxActionErrorTypes.UPDATE_GLOBAL_GIT_CONFIG_ERROR]: (
state: GitSyncReducerState,
) => ({
...state,
isFetchingGitConfig: false,
isFetchingGlobalGitConfig: false,
}),
[ReduxActionErrorTypes.FETCH_GLOBAL_GIT_CONFIG_ERROR]: (
state: GitSyncReducerState,
) => ({
...state,
isFetchingGitConfig: false,
isFetchingGlobalGitConfig: false,
}),
[ReduxActionTypes.FETCH_BRANCHES_INIT]: (state: GitSyncReducerState) => ({
...state,
@ -644,7 +644,7 @@ export type GitSyncReducerState = GitBranchDeleteState & {
isCommitSuccessful: boolean;
fetchingBranches: boolean;
isFetchingGitConfig: boolean;
isFetchingGlobalGitConfig: boolean;
isFetchingLocalGitConfig: boolean;
isFetchingGitStatus: boolean;

View File

@ -234,7 +234,7 @@ function* connectToGitSaga(action: ConnectToGitReduxAction) {
}
} catch (error) {
if (action.onErrorCallback) {
action.onErrorCallback(error as string);
action.onErrorCallback(error as Error, response);
}
const isRepoLimitReachedError: boolean = yield call(
@ -571,7 +571,12 @@ function* fetchGitStatusSaga(action: ReduxAction<GitStatusParams>) {
}
}
function* fetchGitRemoteStatusSaga() {
interface FetchRemoteStatusSagaAction extends ReduxAction<undefined> {
onSuccessCallback?: (data: any) => void;
onErrorCallback?: (error: Error, response?: any) => void;
}
function* fetchGitRemoteStatusSaga(action: FetchRemoteStatusSagaAction) {
let response: ApiResponse | undefined;
try {
const applicationId: string = yield select(getCurrentApplicationId);
@ -591,8 +596,11 @@ function* fetchGitRemoteStatusSaga() {
// @ts-expect-error: response is of type unknown
yield put(fetchGitRemoteStatusSuccess(response?.data));
}
if (typeof action?.onSuccessCallback === "function") {
action.onSuccessCallback(response?.data);
}
} catch (error) {
const payload = { error, show: true };
const payload = { error, show: !action?.onErrorCallback };
if ((error as Error)?.message?.includes("Auth fail")) {
payload.error = new Error(createMessage(ERROR_GIT_AUTH_FAIL));
} else if ((error as Error)?.message?.includes("Invalid remote: origin")) {
@ -604,6 +612,10 @@ function* fetchGitRemoteStatusSaga() {
payload,
});
if (typeof action?.onErrorCallback === "function") {
action.onErrorCallback(error as Error, response);
}
// non api error
if (!response || response?.responseMeta?.success) {
throw error;
@ -861,7 +873,7 @@ function* importAppFromGitSaga(action: ConnectToGitReduxAction) {
}
} catch (error) {
if (action.onErrorCallback) {
action.onErrorCallback(error as string);
action.onErrorCallback(error as Error, response);
}
const isRepoLimitReachedError: boolean = yield call(

View File

@ -56,16 +56,11 @@ export const getIsGlobalConfigDefined = createSelector(
);
export const getIsFetchingGlobalGitConfig = (state: AppState) =>
state.ui.gitSync.isFetchingGitConfig;
state.ui.gitSync.isFetchingGlobalGitConfig;
export const getIsFetchingLocalGitConfig = (state: AppState) =>
state.ui.gitSync.isFetchingLocalGitConfig;
export const getIsGitStatusLiteEnabled = createSelector(
selectFeatureFlags,
(flags) => !!flags?.release_git_status_lite_enabled,
);
export const getGitStatus = (state: AppState) => state.ui.gitSync.gitStatus;
export const getGitRemoteStatus = (state: AppState) =>
@ -208,3 +203,14 @@ export const getBranchSwitchingDetails = (state: AppState) => ({
isSwitchingBranch: state.ui.gitSync.isSwitchingBranch,
switchingToBranch: state.ui.gitSync.switchingToBranch,
});
// feature flag selectors
export const getIsGitStatusLiteEnabled = createSelector(
selectFeatureFlags,
(flags) => !!flags?.release_git_status_lite_enabled,
);
export const getIsGitConnectV2Enabled = createSelector(
selectFeatureFlags,
(flags) => !!flags?.release_git_connect_v2_enabled,
);