feat: git delete branch (#12681)
This commit is contained in:
parent
73482000a6
commit
736c2e024e
|
|
@ -278,6 +278,7 @@
|
|||
"eslint-plugin-sort-destructure-keys": "^1.3.5",
|
||||
"factory.ts": "^0.5.1",
|
||||
"jest-canvas-mock": "^2.3.1",
|
||||
"jest-styled-components": "^7.0.8",
|
||||
"mocha": "^7.1.0",
|
||||
"mocha-junit-reporter": "^1.23.3",
|
||||
"mochawesome": "^7.1.2",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { ConnectToGitPayload } from "api/GitSyncAPI";
|
||||
import {
|
||||
ReduxActionErrorTypes,
|
||||
ReduxActionTypes,
|
||||
ReduxActionWithCallbacks,
|
||||
ReduxActionErrorTypes,
|
||||
} from "@appsmith/constants/ReduxActionConstants";
|
||||
import { GitSyncModalTab, GitConfig, MergeStatus } from "entities/GitSync";
|
||||
import { ConnectToGitPayload } from "api/GitSyncAPI";
|
||||
import { GitConfig, GitSyncModalTab, MergeStatus } from "entities/GitSync";
|
||||
import { GitApplicationMetadata } from "api/ApplicationApi";
|
||||
import { GitStatusData } from "reducers/uiReducers/gitSyncReducer";
|
||||
import { ResponseMeta } from "api/ApiResponses";
|
||||
|
|
@ -375,3 +375,28 @@ export const importAppViaGitError = (error: any) => ({
|
|||
export const resetSSHKeys = () => ({
|
||||
type: ReduxActionTypes.RESET_SSH_KEY_PAIR,
|
||||
});
|
||||
|
||||
export const deleteBranchInit = (payload: any) => ({
|
||||
type: ReduxActionTypes.DELETE_BRANCH_INIT,
|
||||
payload,
|
||||
});
|
||||
|
||||
export const deleteBranchSuccess = (payload: any) => ({
|
||||
type: ReduxActionTypes.DELETE_BRANCH_SUCCESS,
|
||||
payload,
|
||||
});
|
||||
|
||||
export const deleteBranchError = (payload: any) => ({
|
||||
type: ReduxActionErrorTypes.DELETE_BRANCH_ERROR,
|
||||
payload,
|
||||
});
|
||||
|
||||
export const deleteBranchWarning = (payload: any) => ({
|
||||
type: ReduxActionErrorTypes.DELETE_BRANCH_WARNING,
|
||||
payload,
|
||||
});
|
||||
|
||||
export const deletingBranch = (payload: any) => ({
|
||||
type: ReduxActionTypes.DELETING_BRANCH,
|
||||
payload,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,6 +149,15 @@ class GitSyncAPI extends Api {
|
|||
: ApplicationApi.baseURL + "/ssh-keypair/" + applicationId;
|
||||
return isImporting ? Api.get(url) : Api.post(url);
|
||||
}
|
||||
|
||||
static deleteBranch(
|
||||
applicationId: string,
|
||||
branchName: string,
|
||||
): AxiosPromise<ApiResponse> {
|
||||
return Api.delete(GitSyncAPI.baseURL + "/branch/" + applicationId, {
|
||||
branchName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default GitSyncAPI;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export const ReduxSagaChannels = {
|
|||
};
|
||||
|
||||
export const ReduxActionTypes = {
|
||||
DELETE_BRANCH_INIT: "DELETE_BRANCH_INIT",
|
||||
DELETING_BRANCH: "DELETING_BRANCH",
|
||||
DELETE_BRANCH_SUCCESS: "DELETE_BRANCH_SUCCESS",
|
||||
SHOW_RECONNECT_DATASOURCE_MODAL: "SHOW_RECONNECT_DATASOURCE_MODAL",
|
||||
RESET_UNCONCONFIGURED_DATASOURCES_LIST_DURING_IMPORT:
|
||||
"RESET_UNCONCONFIGURED_DATASOURCES_LIST_DURING_IMPORT",
|
||||
|
|
@ -700,6 +703,8 @@ export const ReduxActionTypes = {
|
|||
export type ReduxActionType = typeof ReduxActionTypes[keyof typeof ReduxActionTypes];
|
||||
|
||||
export const ReduxActionErrorTypes = {
|
||||
DELETE_BRANCH_WARNING: "DELETE_BRANCH_WARNING",
|
||||
DELETE_BRANCH_ERROR: "DELETE_BRANCH_ERROR",
|
||||
GIT_PULL_ERROR: "GIT_PULL_ERROR",
|
||||
FETCH_MERGE_STATUS_ERROR: "FETCH_MERGE_STATUS_ERROR",
|
||||
MERGE_BRANCH_ERROR: "MERGE_BRANCH_ERROR",
|
||||
|
|
|
|||
|
|
@ -743,6 +743,21 @@ export const CHANGES_USER_AND_MIGRATION = () =>
|
|||
"Appsmith update and user changes since last commit";
|
||||
// GIT DEPLOY end
|
||||
|
||||
// GIT DELETE BRANCH begin
|
||||
export const DELETE = () => "Delete";
|
||||
export const LOCAL_BRANCHES = () => "Local branches";
|
||||
export const REMOTE_BRANCHES = () => "Remote branches";
|
||||
|
||||
export const DELETE_BRANCH_SUCCESS = (branchName: string) =>
|
||||
`Successfully deleted branch: ${branchName}`;
|
||||
|
||||
// warnings
|
||||
export const DELETE_BRANCH_WARNING_CHECKED_OUT = (currentBranchName: string) =>
|
||||
`Cannot delete checked out branch. Please check out other branch before deleting ${currentBranchName}.`;
|
||||
export const DELETE_BRANCH_WARNING_DEFAULT = (defaultBranchName: string) =>
|
||||
`Cannot delete default branch: ${defaultBranchName}`;
|
||||
// GIT DELETE BRANCH end
|
||||
|
||||
// GIT ERRORS begin
|
||||
export const ERROR_GIT_AUTH_FAIL = () =>
|
||||
"Please make sure that regenerated SSH key is added and has write access to the repo.";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export type MenuProps = CommonComponentProps & {
|
|||
canEscapeKeyClose?: boolean;
|
||||
canOutsideClickClose?: boolean;
|
||||
menuItemWrapperWidth?: string;
|
||||
|
||||
/**
|
||||
* (optional) dontUsePortal {boolean}
|
||||
* For Popover usePortal=true by default.
|
||||
* All existing Menu usages don't need to change if we signal usePortal=false via dontUsePortal=true.
|
||||
*/
|
||||
dontUsePortal?: boolean;
|
||||
};
|
||||
|
||||
const MenuWrapper = styled.div<{ width?: string }>`
|
||||
|
|
@ -45,6 +52,7 @@ function Menu(props: MenuProps) {
|
|||
onOpening={props.onOpening}
|
||||
portalClassName={props.className}
|
||||
position={props.position || Position.BOTTOM}
|
||||
usePortal={!props.dontUsePortal}
|
||||
>
|
||||
{props.target}
|
||||
<MenuWrapper width={props.menuItemWrapperWidth}>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import UserGitProfileSettings from "../components/UserGitProfileSettings";
|
|||
import { AUTH_TYPE_OPTIONS } from "../constants";
|
||||
import { Colors } from "constants/Colors";
|
||||
import Button, { Category, Size } from "components/ads/Button";
|
||||
import { useGitConnect, useSSHKeyPair } from "../hooks";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
|
|
@ -71,6 +70,7 @@ import TooltipComponent from "components/ads/Tooltip";
|
|||
import Icon, { IconSize } from "components/ads/Icon";
|
||||
import AnalyticsUtil from "utils/AnalyticsUtil";
|
||||
import { isValidGitRemoteUrl } from "../utils";
|
||||
import { useGitConnect, useSSHKeyPair } from "../hooks";
|
||||
|
||||
export const UrlOptionContainer = styled.div`
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import BetaTag from "./BetaTag";
|
||||
import { render, screen } from "test/testUtils";
|
||||
import React from "react";
|
||||
import "jest-styled-components";
|
||||
|
||||
describe("BetaTag", () => {
|
||||
it("renders properly", async () => {
|
||||
render(<BetaTag />);
|
||||
const actual = await screen.queryByTestId("t--beta-tag");
|
||||
|
||||
// renders
|
||||
expect(actual).not.toBeNull();
|
||||
|
||||
// contains BETA text
|
||||
expect(actual?.innerHTML.includes("BETA")).toBeTruthy();
|
||||
|
||||
// styles
|
||||
expect(actual).toHaveStyleRule("height", "16px");
|
||||
expect(actual).toHaveStyleRule("width", "48px");
|
||||
expect(actual).toHaveStyleRule("display", "flex");
|
||||
expect(actual).toHaveStyleRule("justify-content", "center");
|
||||
expect(actual).toHaveStyleRule("align-items", "center");
|
||||
expect(actual).toHaveStyleRule("color", "#191919");
|
||||
expect(actual).toHaveStyleRule("border", "1px solid #191919");
|
||||
});
|
||||
});
|
||||
|
|
@ -15,7 +15,7 @@ const StyledTag = styled.div`
|
|||
`;
|
||||
|
||||
function BetaTag() {
|
||||
return <StyledTag>BETA</StyledTag>;
|
||||
return <StyledTag data-testid="t--beta-tag">BETA</StyledTag>;
|
||||
}
|
||||
|
||||
export default BetaTag;
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import "@testing-library/jest-dom";
|
||||
import { removeSpecialChars } from "./BranchList";
|
||||
|
||||
describe("Remove special characters from Branch name", () => {
|
||||
it("it should replace special characters except / and - with _", () => {
|
||||
const inputs = [
|
||||
"abc_def",
|
||||
"abc-def",
|
||||
"abc*def",
|
||||
"abc/def",
|
||||
"abc&def",
|
||||
"abc%def",
|
||||
"abc#def",
|
||||
"abc@def",
|
||||
"abc!def",
|
||||
"abc,def",
|
||||
"abc<def",
|
||||
"abc>def",
|
||||
"abc?def",
|
||||
"abc.def",
|
||||
"abc;def",
|
||||
"abc(def",
|
||||
];
|
||||
|
||||
const expected = [
|
||||
"abc_def",
|
||||
"abc-def",
|
||||
"abc_def",
|
||||
"abc/def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
];
|
||||
|
||||
inputs.forEach((input, index) => {
|
||||
const result = removeSpecialChars(input);
|
||||
expect(result).toStrictEqual(expected[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -12,10 +12,10 @@ import {
|
|||
} from "actions/gitSyncActions";
|
||||
import {
|
||||
getCurrentGitBranch,
|
||||
getDefaultGitBranchName,
|
||||
getFetchingBranches,
|
||||
getGitBranches,
|
||||
getGitBranchNames,
|
||||
getDefaultGitBranchName,
|
||||
} from "selectors/gitSyncSelectors";
|
||||
|
||||
import Skeleton from "components/utils/Skeleton";
|
||||
|
|
@ -29,22 +29,22 @@ import {
|
|||
SWITCH_BRANCHES,
|
||||
SYNC_BRANCHES,
|
||||
} from "@appsmith/constants/messages";
|
||||
|
||||
import { Branch } from "entities/GitSync";
|
||||
import Button, { Category, Size } from "components/ads/Button";
|
||||
import { Space } from "./StyledComponents";
|
||||
import Icon, { IconSize, IconWrapper } from "components/ads/Icon";
|
||||
import { get } from "lodash";
|
||||
import Tooltip from "components/ads/Tooltip";
|
||||
import { Position } from "@blueprintjs/core";
|
||||
import Spinner from "components/ads/Spinner";
|
||||
import Text, { TextType } from "components/ads/Text";
|
||||
import { Classes } from "components/ads/common";
|
||||
import { isEllipsisActive } from "utils/helpers";
|
||||
import { getIsStartingWithRemoteBranches } from "pages/Editor/gitSync/utils";
|
||||
|
||||
import SegmentHeader from "components/ads/ListSegmentHeader";
|
||||
import {
|
||||
isLocalBranch,
|
||||
isRemoteBranch,
|
||||
removeSpecialChars,
|
||||
} from "pages/Editor/gitSync/utils";
|
||||
import AnalyticsUtil from "utils/AnalyticsUtil";
|
||||
import { useActiveHoverIndex, useFilteredBranches } from "../hooks";
|
||||
import { BranchListItemContainer } from "./BranchListItemContainer";
|
||||
import { RemoteBranchList } from "./RemoteBranchList";
|
||||
import { LocalBranchList } from "./LocalBranchList";
|
||||
|
||||
const ListContainer = styled.div`
|
||||
flex: 1;
|
||||
|
|
@ -66,74 +66,43 @@ const BranchDropdownContainer = styled.div`
|
|||
min-height: 0;
|
||||
`;
|
||||
|
||||
const BranchListItemContainer = styled.div<{
|
||||
hovered?: boolean;
|
||||
active?: boolean;
|
||||
isDefault?: boolean;
|
||||
}>`
|
||||
padding: ${(props) =>
|
||||
`${props.theme.spaces[4]}px ${props.theme.spaces[5]}px`};
|
||||
${(props) => getTypographyByKey(props, "p1")};
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: ${Colors.Gallery};
|
||||
}
|
||||
width: 100%;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background-color: ${(props) =>
|
||||
props.hovered || props.active ? Colors.GREY_3 : ""};
|
||||
|
||||
display: ${(props) => (props.isDefault ? "flex" : "block")};
|
||||
.${Classes.TEXT} {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
// used for skeletons
|
||||
const textInputHeight = 38;
|
||||
const textHeight = 18;
|
||||
|
||||
function DefaultTag() {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
category={Category.tertiary}
|
||||
disabled
|
||||
size={Size.xxs}
|
||||
text={"DEFAULT"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CreateNewBranchContainer = styled.div`
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
& ${IconWrapper} {
|
||||
display: inline;
|
||||
}
|
||||
& span {
|
||||
display: inline;
|
||||
|
||||
& div {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
& .large-text {
|
||||
${(props) => getTypographyByKey(props, "p1")};
|
||||
color: ${Colors.BLACK};
|
||||
}
|
||||
|
||||
& .small-text {
|
||||
${(props) => getTypographyByKey(props, "p3")};
|
||||
color: ${Colors.GREY_7};
|
||||
}
|
||||
`;
|
||||
|
||||
const SpinnerContainer = styled.div`
|
||||
align-self: center;
|
||||
width: 12px;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
`;
|
||||
|
||||
function CreateNewBranch({
|
||||
branch,
|
||||
className,
|
||||
|
|
@ -171,61 +140,15 @@ function CreateNewBranch({
|
|||
size={IconSize.XXXL}
|
||||
/>
|
||||
<CreateNewBranchContainer className={className} ref={itemRef}>
|
||||
<span className="large-text">{`Create Branch: ${branch} `}</span>
|
||||
<span className="small-text">{`from \`${currentBranch}\``}</span>
|
||||
<div className="large-text">{`Create Branch: ${branch} `}</div>
|
||||
<div className="small-text">{`from \`${currentBranch}\``}</div>
|
||||
</CreateNewBranchContainer>
|
||||
<div style={{ alignSelf: "center", width: 12 }}>
|
||||
{isCreatingNewBranch && <Spinner />}
|
||||
</div>
|
||||
<SpinnerContainer>{isCreatingNewBranch && <Spinner />}</SpinnerContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchListItem({
|
||||
active,
|
||||
branch,
|
||||
className,
|
||||
hovered,
|
||||
isDefault,
|
||||
onClick,
|
||||
shouldScrollIntoView,
|
||||
}: any) {
|
||||
const itemRef = React.useRef<HTMLDivElement>(null);
|
||||
const textRef = React.useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
if (itemRef.current && shouldScrollIntoView)
|
||||
scrollIntoView(itemRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}, [shouldScrollIntoView]);
|
||||
|
||||
return (
|
||||
<BranchListItemContainer
|
||||
active={active}
|
||||
className={className}
|
||||
hovered={hovered}
|
||||
isDefault={isDefault}
|
||||
onClick={onClick}
|
||||
ref={itemRef}
|
||||
>
|
||||
<Tooltip
|
||||
boundary="window"
|
||||
content={branch}
|
||||
disabled={!isEllipsisActive(textRef.current)}
|
||||
position={Position.TOP}
|
||||
>
|
||||
<Text ref={textRef} type={TextType.P1}>
|
||||
{branch}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{isDefault && <DefaultTag />}
|
||||
</BranchListItemContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow() {
|
||||
export function LoadingRow() {
|
||||
return (
|
||||
<BranchListItemContainer>
|
||||
<div style={{ height: textHeight, width: "100%" }}>
|
||||
|
|
@ -235,7 +158,7 @@ function LoadingRow() {
|
|||
);
|
||||
}
|
||||
|
||||
function BranchesLoading() {
|
||||
export function BranchesLoading() {
|
||||
return (
|
||||
<>
|
||||
<LoadingRow />
|
||||
|
|
@ -245,81 +168,7 @@ function BranchesLoading() {
|
|||
);
|
||||
}
|
||||
|
||||
export const removeSpecialChars = (value: string) => {
|
||||
const separatorRegex = /(?![/-])\W+/;
|
||||
return value.split(separatorRegex).join("_");
|
||||
};
|
||||
|
||||
// filter the branches according to the search text
|
||||
// also pushes the default branch to the top
|
||||
const useFilteredBranches = (branches: Array<Branch>, searchText: string) => {
|
||||
const [filteredBranches, setFilteredBranches] = useState<Array<string>>([]);
|
||||
useEffect(() => {
|
||||
setFilteredBranches(
|
||||
branches.reduce((res: Array<string>, curr: Branch) => {
|
||||
let shouldPush = false;
|
||||
if (searchText) {
|
||||
shouldPush =
|
||||
curr.branchName?.toLowerCase().indexOf(searchText.toLowerCase()) !==
|
||||
-1;
|
||||
} else {
|
||||
shouldPush = true;
|
||||
}
|
||||
|
||||
if (shouldPush) {
|
||||
if (curr.default) {
|
||||
res.unshift(curr.branchName);
|
||||
} else {
|
||||
res.push(curr.branchName);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}, []),
|
||||
);
|
||||
}, [branches, searchText]);
|
||||
return filteredBranches;
|
||||
};
|
||||
|
||||
const useActiveHoverIndex = (
|
||||
currentBranch: string | undefined,
|
||||
filteredBranches: Array<string>,
|
||||
isCreateNewBranchInputValid: boolean,
|
||||
) => {
|
||||
const effectiveLength = isCreateNewBranchInputValid
|
||||
? filteredBranches.length
|
||||
: filteredBranches.length - 1;
|
||||
|
||||
const [activeHoverIndex, setActiveHoverIndexInState] = useState(0);
|
||||
const setActiveHoverIndex = (index: number) => {
|
||||
if (index < 0) setActiveHoverIndexInState(effectiveLength);
|
||||
else if (index > effectiveLength) setActiveHoverIndexInState(0);
|
||||
else setActiveHoverIndexInState(index);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const activeBranchIdx = filteredBranches.indexOf(currentBranch || "");
|
||||
if (activeBranchIdx !== -1) {
|
||||
setActiveHoverIndex(
|
||||
isCreateNewBranchInputValid ? activeBranchIdx + 1 : activeBranchIdx,
|
||||
);
|
||||
} else {
|
||||
setActiveHoverIndex(0);
|
||||
}
|
||||
}, [currentBranch, filteredBranches, isCreateNewBranchInputValid]);
|
||||
|
||||
return { activeHoverIndex, setActiveHoverIndex };
|
||||
};
|
||||
|
||||
const getIsActiveItem = (
|
||||
isCreateNewBranchInputValid: boolean,
|
||||
activeHoverIndex: number,
|
||||
index: number,
|
||||
) =>
|
||||
(isCreateNewBranchInputValid ? activeHoverIndex - 1 : activeHoverIndex) ===
|
||||
index;
|
||||
|
||||
function Header({
|
||||
export function Header({
|
||||
closePopup,
|
||||
fetchBranches,
|
||||
}: {
|
||||
|
|
@ -409,6 +258,12 @@ export default function BranchList(props: {
|
|||
|
||||
const filteredBranches = useFilteredBranches(branches, searchText);
|
||||
|
||||
const localBranches = filteredBranches.filter((b: string) =>
|
||||
isLocalBranch(b),
|
||||
);
|
||||
const remoteBranches = filteredBranches.filter((b: string) =>
|
||||
isRemoteBranch(b),
|
||||
);
|
||||
const { activeHoverIndex, setActiveHoverIndex } = useActiveHoverIndex(
|
||||
currentBranch,
|
||||
filteredBranches,
|
||||
|
|
@ -439,7 +294,10 @@ export default function BranchList(props: {
|
|||
);
|
||||
};
|
||||
|
||||
const switchBranch = (branch: string) => {
|
||||
const switchBranch = (branch: string): void => {
|
||||
AnalyticsUtil.logEvent("GS_SWITCH_BRANCH", {
|
||||
source: "BRANCH_LIST_POPUP_FROM_BOTTOM_BAR",
|
||||
});
|
||||
dispatch(switchGitBranchInit(branch));
|
||||
};
|
||||
|
||||
|
|
@ -459,6 +317,15 @@ export default function BranchList(props: {
|
|||
if (typeof props.setIsPopupOpen === "function") props.setIsPopupOpen(false);
|
||||
};
|
||||
|
||||
const remoteBranchList = RemoteBranchList(remoteBranches, switchBranch);
|
||||
const localBranchList = LocalBranchList(
|
||||
localBranches,
|
||||
currentBranch,
|
||||
isCreateNewBranchInputValid,
|
||||
activeHoverIndex,
|
||||
defaultBranch,
|
||||
switchBranch,
|
||||
);
|
||||
return (
|
||||
<BranchListHotkeys
|
||||
handleDownKey={handleDownKey}
|
||||
|
|
@ -469,8 +336,9 @@ export default function BranchList(props: {
|
|||
<BranchDropdownContainer>
|
||||
<Header
|
||||
closePopup={() => {
|
||||
if (typeof props.setIsPopupOpen === "function")
|
||||
if (typeof props.setIsPopupOpen === "function") {
|
||||
props.setIsPopupOpen(false);
|
||||
}
|
||||
}}
|
||||
fetchBranches={pruneAndFetchBranches}
|
||||
/>
|
||||
|
|
@ -506,33 +374,8 @@ export default function BranchList(props: {
|
|||
shouldScrollIntoView={activeHoverIndex === 0}
|
||||
/>
|
||||
)}
|
||||
<SegmentHeader hideStyledHr title={"Local branches"} />
|
||||
{filteredBranches.map((branch: string, index: number) => (
|
||||
<>
|
||||
{getIsStartingWithRemoteBranches(
|
||||
filteredBranches[index - 1],
|
||||
branch,
|
||||
) && <SegmentHeader hideStyledHr title={"Remote branches"} />}
|
||||
<BranchListItem
|
||||
active={currentBranch === branch}
|
||||
branch={branch}
|
||||
className="t--branch-item"
|
||||
hovered={getIsActiveItem(
|
||||
isCreateNewBranchInputValid,
|
||||
activeHoverIndex,
|
||||
index,
|
||||
)}
|
||||
isDefault={branch === defaultBranch}
|
||||
key={branch}
|
||||
onClick={() => switchBranch(branch)}
|
||||
shouldScrollIntoView={getIsActiveItem(
|
||||
isCreateNewBranchInputValid,
|
||||
activeHoverIndex,
|
||||
index,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{localBranchList}
|
||||
{remoteBranchList}
|
||||
</ListContainer>
|
||||
)}
|
||||
</BranchDropdownContainer>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import React, { useEffect } from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import { BranchListItemContainer } from "./BranchListItemContainer";
|
||||
import Tooltip from "components/ads/Tooltip";
|
||||
import { isEllipsisActive } from "utils/helpers";
|
||||
import { Position } from "@blueprintjs/core";
|
||||
import Text, { TextType } from "components/ads/Text";
|
||||
import DefaultTag from "./DefaultTag";
|
||||
import { useHover } from "../hooks";
|
||||
import BranchMoreMenu from "./BranchMoreMenu";
|
||||
|
||||
export function BranchListItem({
|
||||
active,
|
||||
branch,
|
||||
className,
|
||||
isDefault,
|
||||
onClick,
|
||||
selected,
|
||||
shouldScrollIntoView,
|
||||
}: any) {
|
||||
const itemRef = React.useRef<HTMLDivElement>(null);
|
||||
const textRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [hover] = useHover(itemRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (itemRef.current && shouldScrollIntoView) {
|
||||
scrollIntoView(itemRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}
|
||||
}, [shouldScrollIntoView]);
|
||||
|
||||
return (
|
||||
<BranchListItemContainer
|
||||
active={active}
|
||||
className={className}
|
||||
isDefault={isDefault}
|
||||
ref={itemRef}
|
||||
selected={selected}
|
||||
>
|
||||
<Tooltip
|
||||
boundary="window"
|
||||
content={branch}
|
||||
disabled={!isEllipsisActive(textRef.current)}
|
||||
position={Position.TOP}
|
||||
>
|
||||
<Text onClick={onClick} ref={textRef} type={TextType.P1}>
|
||||
{branch}
|
||||
{isDefault && <DefaultTag />}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{hover && <BranchMoreMenu branchName={branch} />}
|
||||
</BranchListItemContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import styled from "styled-components";
|
||||
import { getTypographyByKey } from "constants/DefaultTheme";
|
||||
import { Colors } from "constants/Colors";
|
||||
import { Classes } from "components/ads";
|
||||
|
||||
export const BranchListItemContainer = styled.div<{
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
isDefault?: boolean;
|
||||
}>`
|
||||
padding: ${(props) =>
|
||||
`${props.theme.spaces[5]}px ${props.theme.spaces[5]}px`};
|
||||
margin: ${(props) => `${props.theme.spaces[1]} 0`};
|
||||
${(props) => getTypographyByKey(props, "p1")};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${Colors.Gallery};
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background-color: ${(props) =>
|
||||
props.selected || props.active ? Colors.GREY_3 : ""};
|
||||
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 9fr 1fr;
|
||||
|
||||
& .bp3-popover-wrapper {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.${Classes.TEXT} {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
& .bp3-overlay .bp3-popover.bp3-minimal .cs-text {
|
||||
width: fit-content;
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import React, { useState } from "react";
|
||||
import { Colors } from "constants/Colors";
|
||||
import AnalyticsUtil from "utils/AnalyticsUtil";
|
||||
import { IconSize, Toaster, Variant } from "components/ads";
|
||||
import Icon from "components/ads/Icon";
|
||||
import Menu from "components/ads/Menu";
|
||||
import { deleteBranchInit } from "actions/gitSyncActions";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
createMessage,
|
||||
DELETE,
|
||||
DELETE_BRANCH_WARNING_CHECKED_OUT,
|
||||
DELETE_BRANCH_WARNING_DEFAULT,
|
||||
} from "@appsmith/constants/messages";
|
||||
import DangerMenuItem from "./DangerMenuItem";
|
||||
import { Dispatch } from "redux";
|
||||
import { GitApplicationMetadata } from "api/ApplicationApi";
|
||||
import { getCurrentAppGitMetaData } from "selectors/applicationSelectors";
|
||||
|
||||
interface Props {
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
function DeleteButton(
|
||||
branchToDelete: string,
|
||||
gitMetaData: GitApplicationMetadata,
|
||||
dispatch: Dispatch<any>,
|
||||
) {
|
||||
const currentBranch = gitMetaData?.branchName || "";
|
||||
const defaultBranchName = gitMetaData?.defaultBranchName || "master";
|
||||
|
||||
function saneDelete() {
|
||||
if (defaultBranchName === branchToDelete) {
|
||||
Toaster.show({
|
||||
text: createMessage(DELETE_BRANCH_WARNING_DEFAULT, branchToDelete),
|
||||
variant: Variant.danger,
|
||||
});
|
||||
} else if (currentBranch === branchToDelete) {
|
||||
Toaster.show({
|
||||
text: createMessage(DELETE_BRANCH_WARNING_CHECKED_OUT, branchToDelete),
|
||||
variant: Variant.danger,
|
||||
});
|
||||
} else {
|
||||
dispatch(deleteBranchInit({ branchToDelete: branchToDelete }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DangerMenuItem
|
||||
className="git-branch-more-menu-item danger"
|
||||
data-testid="t--branch-more-menu-delete"
|
||||
icon="delete"
|
||||
key={"delete-branch-button"}
|
||||
onSelect={() => saneDelete()}
|
||||
selected
|
||||
text={createMessage(DELETE)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuButton(
|
||||
setOpen: (value: ((prevState: boolean) => boolean) | boolean) => void,
|
||||
open: boolean,
|
||||
) {
|
||||
return (
|
||||
<Icon
|
||||
fillColor={Colors.DARK_GRAY}
|
||||
hoverFillColor={Colors.GRAY_900}
|
||||
name="more-2-fill"
|
||||
onClick={() => {
|
||||
AnalyticsUtil.logEvent("GS_BRANCH_MORE_MENU_OPEN", {
|
||||
source: "GS_OPEN_BRANCH_LIST_POPUP",
|
||||
});
|
||||
setOpen(!open);
|
||||
}}
|
||||
size={IconSize.XXXXL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BranchMoreMenu({ branchName }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const buttons = [
|
||||
DeleteButton(branchName, useSelector(getCurrentAppGitMetaData), dispatch),
|
||||
];
|
||||
const menuButton = MenuButton(setOpen, open);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className="git-branch-more-menu"
|
||||
data-testid="t--git-branch-more-menu"
|
||||
dontUsePortal
|
||||
isOpen={open}
|
||||
menuItemWrapperWidth={"fit-content"}
|
||||
position="bottom"
|
||||
target={menuButton}
|
||||
>
|
||||
{buttons}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import styled from "styled-components";
|
||||
import MenuItem from "components/ads/MenuItem";
|
||||
import { Colors } from "constants/Colors";
|
||||
|
||||
const DangerMenuItem = styled(MenuItem)`
|
||||
&&,
|
||||
&& .cs-text {
|
||||
color: ${Colors.DANGER_SOLID};
|
||||
}
|
||||
|
||||
&&,
|
||||
&&:hover {
|
||||
svg,
|
||||
svg path {
|
||||
fill: ${Colors.DANGER_SOLID};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default DangerMenuItem;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import DefaultTag from "./DefaultTag";
|
||||
import { render, screen } from "test/testUtils";
|
||||
import React from "react";
|
||||
import "jest-styled-components";
|
||||
|
||||
describe("DefaultTag", () => {
|
||||
it("renders properly", async () => {
|
||||
render(<DefaultTag />);
|
||||
const actual = await screen.queryByTestId("t--default-tag");
|
||||
|
||||
// renders
|
||||
expect(actual).not.toBeNull();
|
||||
|
||||
// contains DEFAULT text
|
||||
expect(actual?.innerHTML.includes("DEFAULT")).toBeTruthy();
|
||||
|
||||
// styles
|
||||
expect(actual).toHaveStyleRule("display", "inline-block");
|
||||
expect(actual).toHaveStyleRule("padding", "3px 7px");
|
||||
expect(actual).toHaveStyleRule("position", "absolute");
|
||||
expect(actual).toHaveStyleRule("right", "16%");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import Button, { Category, Size } from "components/ads/Button";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
display: inline-block;
|
||||
padding: 3px 7px;
|
||||
position: absolute;
|
||||
right: 16%;
|
||||
`;
|
||||
|
||||
export default function DefaultTag() {
|
||||
return (
|
||||
<StyledButton
|
||||
category={Category.tertiary}
|
||||
data-testid="t--default-tag"
|
||||
disabled
|
||||
size={Size.xxs}
|
||||
text={"DEFAULT"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,13 +22,13 @@ import Menu from "components/ads/Menu";
|
|||
import { Position } from "@blueprintjs/core";
|
||||
import MenuItem from "components/ads/MenuItem";
|
||||
import Button, { Category, Size } from "components/ads/Button";
|
||||
import { useSSHKeyPair } from "pages/Editor/gitSync/hooks";
|
||||
import {
|
||||
NotificationBanner,
|
||||
NotificationVariant,
|
||||
} from "components/ads/NotificationBanner";
|
||||
import { Toaster } from "components/ads/Toast";
|
||||
import { Variant } from "components/ads/common";
|
||||
import { useSSHKeyPair } from "../hooks/useSSHKeyPair";
|
||||
|
||||
const TooltipWrapper = styled.div`
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { render, screen } from "test/testUtils";
|
||||
import "jest-styled-components";
|
||||
import { LocalBranchList } from "./LocalBranchList";
|
||||
|
||||
describe("LocalBranchList", function() {
|
||||
it("renders nothing when param:remoteBranches is an empty array", async () => {
|
||||
render(LocalBranchList([], "", false, -1, "", () => undefined));
|
||||
|
||||
const renderedList = screen.queryByTestId(
|
||||
"t--git-local-branch-list-container",
|
||||
);
|
||||
expect(renderedList?.innerHTML).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import SegmentHeader from "components/ads/ListSegmentHeader";
|
||||
import { BranchListItem } from "./BranchListItem";
|
||||
import { getIsActiveItem } from "../utils";
|
||||
import React from "react";
|
||||
import { createMessage, LOCAL_BRANCHES } from "@appsmith/constants/messages";
|
||||
|
||||
/**
|
||||
* LocalBranchList: returns a list of local branches
|
||||
* @param localBranches {string[]} branches that don't start with origin/
|
||||
* @param currentBranch {string | undefined} current checked out branch in backend
|
||||
* @param isCreateNewBranchInputValid {boolean}
|
||||
* @param activeHoverIndex {number} used to figure out which list item is being selected
|
||||
* @param defaultBranch {string | undefined} this is used to put DEFAULT tag on "master" branch, which is the default branch name in the backend
|
||||
* @param switchBranch {(branch: string) => never} dispatches ReduxActionTypes.SWITCH_GIT_BRANCH_INIT
|
||||
*/
|
||||
export function LocalBranchList(
|
||||
localBranches: string[],
|
||||
currentBranch: string | undefined,
|
||||
isCreateNewBranchInputValid: boolean,
|
||||
activeHoverIndex: number,
|
||||
defaultBranch: string | undefined,
|
||||
switchBranch: (branch: string) => void,
|
||||
) {
|
||||
return (
|
||||
<div data-testid="t--git-local-branch-list-container">
|
||||
{localBranches?.length > 0 && (
|
||||
<SegmentHeader hideStyledHr title={createMessage(LOCAL_BRANCHES)} />
|
||||
)}
|
||||
{localBranches
|
||||
.map((branch: string, index: number) => ({
|
||||
branch,
|
||||
isActive: getIsActiveItem(
|
||||
isCreateNewBranchInputValid,
|
||||
activeHoverIndex,
|
||||
index,
|
||||
),
|
||||
}))
|
||||
.map(({ branch, isActive }) => (
|
||||
<BranchListItem
|
||||
active={currentBranch === branch}
|
||||
branch={branch}
|
||||
className="t--branch-item"
|
||||
isDefault={branch === defaultBranch}
|
||||
key={branch}
|
||||
onClick={() => switchBranch(branch)}
|
||||
selected={isActive}
|
||||
shouldScrollIntoView={isActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { render, screen } from "test/testUtils";
|
||||
import "jest-styled-components";
|
||||
|
||||
import { RemoteBranchList } from "./RemoteBranchList";
|
||||
|
||||
describe("RemoteBranchList", function() {
|
||||
it("renders nothing when param:remoteBranches is an empty array", async () => {
|
||||
render(RemoteBranchList([], () => undefined));
|
||||
|
||||
const renderedList = screen.queryByTestId(
|
||||
"t--git-remote-branch-list-container",
|
||||
);
|
||||
expect(renderedList?.innerHTML).toBeFalsy();
|
||||
});
|
||||
|
||||
it("renders one branch list item when param:remoteBranches contains only one string", async () => {
|
||||
render(RemoteBranchList(["origin/one"], () => undefined));
|
||||
|
||||
const renderedList = screen.queryByTestId(
|
||||
"t--git-remote-branch-list-container",
|
||||
);
|
||||
expect(renderedList).not.toBeNull();
|
||||
expect(renderedList?.innerHTML.includes("Remote branches")).toBeTruthy();
|
||||
expect(renderedList?.children.length).toEqual(2);
|
||||
|
||||
// contains styled segment header
|
||||
const header = await screen.queryByTestId("t--styled-segment-header");
|
||||
expect(header).not.toBeNull();
|
||||
expect(header?.innerHTML.includes("Remote branches")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import SegmentHeader from "components/ads/ListSegmentHeader";
|
||||
import { RemoteBranchListItem } from "./RemoteBranchListItem";
|
||||
import React from "react";
|
||||
import { createMessage, REMOTE_BRANCHES } from "@appsmith/constants/messages";
|
||||
|
||||
/**
|
||||
* RemoteBranchList: returns a list of remote branches
|
||||
* @param remoteBranches {string[]} array of remote branch names
|
||||
* @param switchBranch {(branch: string) => void} dispatches ReduxActionTypes.SWITCH_GIT_BRANCH_INIT
|
||||
*/
|
||||
export function RemoteBranchList(
|
||||
remoteBranches: string[],
|
||||
switchBranch: (branch: string) => void,
|
||||
) {
|
||||
return (
|
||||
<div data-testid="t--git-remote-branch-list-container">
|
||||
{remoteBranches?.length > 0 && (
|
||||
<SegmentHeader hideStyledHr title={createMessage(REMOTE_BRANCHES)} />
|
||||
)}
|
||||
{remoteBranches.map((branch: string) => (
|
||||
<RemoteBranchListItem
|
||||
branch={branch}
|
||||
className="t--branch-item"
|
||||
key={branch}
|
||||
onClick={() => switchBranch(branch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import React from "react";
|
||||
import Tooltip from "components/ads/Tooltip";
|
||||
import { isEllipsisActive } from "utils/helpers";
|
||||
import { Position } from "@blueprintjs/core";
|
||||
import Text, { TextType } from "components/ads/Text";
|
||||
import { BranchListItemContainer } from "./BranchListItemContainer";
|
||||
|
||||
export function RemoteBranchListItem({ branch, className, onClick }: any) {
|
||||
const textRef = React.useRef<HTMLSpanElement>(null);
|
||||
return (
|
||||
<BranchListItemContainer
|
||||
active={false}
|
||||
className={className}
|
||||
isDefault={false}
|
||||
onClick={onClick}
|
||||
ref={null}
|
||||
selected={false}
|
||||
>
|
||||
<Tooltip
|
||||
boundary="window"
|
||||
content={branch}
|
||||
disabled={!isEllipsisActive(textRef.current)}
|
||||
position={Position.TOP}
|
||||
>
|
||||
<Text ref={textRef} type={TextType.P1}>
|
||||
{branch}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</BranchListItemContainer>
|
||||
);
|
||||
}
|
||||
13
app/client/src/pages/Editor/gitSync/hooks/index.ts
Normal file
13
app/client/src/pages/Editor/gitSync/hooks/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import useHover from "./useHover";
|
||||
import { useActiveHoverIndex } from "./useActiveHoverIndex";
|
||||
import { useFilteredBranches } from "./useFilteredBranches";
|
||||
import { useSSHKeyPair } from "./useSSHKeyPair";
|
||||
import { useGitConnect } from "./useGitConnect";
|
||||
|
||||
export {
|
||||
useActiveHoverIndex,
|
||||
useFilteredBranches,
|
||||
useGitConnect,
|
||||
useSSHKeyPair,
|
||||
useHover,
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useActiveHoverIndex = (
|
||||
currentBranch: string | undefined,
|
||||
filteredBranches: Array<string>,
|
||||
isCreateNewBranchInputValid: boolean,
|
||||
) => {
|
||||
const effectiveLength = isCreateNewBranchInputValid
|
||||
? filteredBranches.length
|
||||
: filteredBranches.length - 1;
|
||||
|
||||
const [activeHoverIndex, setActiveHoverIndexInState] = useState(0);
|
||||
const setActiveHoverIndex = (index: number) => {
|
||||
if (index < 0) setActiveHoverIndexInState(effectiveLength);
|
||||
else if (index > effectiveLength) setActiveHoverIndexInState(0);
|
||||
else setActiveHoverIndexInState(index);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const activeBranchIdx = filteredBranches.indexOf(currentBranch || "");
|
||||
if (activeBranchIdx !== -1) {
|
||||
setActiveHoverIndex(
|
||||
isCreateNewBranchInputValid ? activeBranchIdx + 1 : activeBranchIdx,
|
||||
);
|
||||
} else {
|
||||
setActiveHoverIndex(0);
|
||||
}
|
||||
}, [currentBranch, filteredBranches, isCreateNewBranchInputValid]);
|
||||
|
||||
return { activeHoverIndex, setActiveHoverIndex };
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { Branch } from "entities/GitSync";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* useFilteredBranches: returns list of branchName: string
|
||||
* If param searchText is provided, then filters list based on input text.
|
||||
* If not, then all of the list is returned.
|
||||
* It both cases, the default branch is pushed to the top
|
||||
* @param branches {Branch[]}
|
||||
* @param searchText {string}
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const useFilteredBranches = (
|
||||
branches: Array<Branch>,
|
||||
searchText: string,
|
||||
) => {
|
||||
searchText = searchText.toLowerCase();
|
||||
const [filteredBranches, setFilteredBranches] = useState<Array<string>>([]);
|
||||
useEffect(() => {
|
||||
const matched = branches.filter((b: Branch) =>
|
||||
searchText ? b.branchName.toLowerCase().includes(searchText) : true,
|
||||
);
|
||||
const branchNames = [
|
||||
...matched.filter((b: Branch) => b.default),
|
||||
...matched.filter((b: Branch) => !b.default),
|
||||
].map((b: Branch) => b.branchName);
|
||||
|
||||
setFilteredBranches(branchNames);
|
||||
}, [branches, searchText]);
|
||||
return filteredBranches;
|
||||
};
|
||||
38
app/client/src/pages/Editor/gitSync/hooks/useGitConnect.ts
Normal file
38
app/client/src/pages/Editor/gitSync/hooks/useGitConnect.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useDispatch } from "react-redux";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ConnectToGitPayload } from "api/GitSyncAPI";
|
||||
import { connectToGitInit } from "actions/gitSyncActions";
|
||||
|
||||
export const useGitConnect = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isConnectingToGit, setIsConnectingToGit] = useState(false);
|
||||
|
||||
const onGitConnectSuccess = useCallback(() => {
|
||||
setIsConnectingToGit(false);
|
||||
}, [setIsConnectingToGit]);
|
||||
|
||||
const onGitConnectFailure = useCallback(() => {
|
||||
setIsConnectingToGit(false);
|
||||
}, [setIsConnectingToGit]);
|
||||
|
||||
const connectToGit = useCallback(
|
||||
(payload: ConnectToGitPayload) => {
|
||||
setIsConnectingToGit(true);
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[onGitConnectSuccess, onGitConnectFailure, setIsConnectingToGit],
|
||||
);
|
||||
|
||||
return {
|
||||
isConnectingToGit,
|
||||
connectToGit,
|
||||
};
|
||||
};
|
||||
20
app/client/src/pages/Editor/gitSync/hooks/useHover.ts
Normal file
20
app/client/src/pages/Editor/gitSync/hooks/useHover.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useHover(ref: any) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const onMouseEnter = () => setHover(true);
|
||||
const onMouseLeave = () => setHover(false);
|
||||
|
||||
useEffect(() => {
|
||||
const target = ref.current;
|
||||
if (target) {
|
||||
target.addEventListener("mouseenter", onMouseEnter);
|
||||
target.addEventListener("mouseleave", onMouseLeave);
|
||||
return () => {
|
||||
target.removeEventListener("mouseenter", onMouseEnter);
|
||||
target.removeEventListener("mouseleave", onMouseLeave);
|
||||
};
|
||||
}
|
||||
});
|
||||
return [hover];
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { generateSSHKeyPair, getSSHKeyPair } from "actions/gitSyncActions";
|
||||
import { connectToGitInit } from "actions/gitSyncActions";
|
||||
import { ConnectToGitPayload } from "api/GitSyncAPI";
|
||||
import {
|
||||
getSSHKeyDeployDocUrl,
|
||||
getSshKeyPair,
|
||||
} from "selectors/gitSyncSelectors";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { generateSSHKeyPair, getSSHKeyPair } from "actions/gitSyncActions";
|
||||
|
||||
export const useSSHKeyPair = () => {
|
||||
// As SSHKeyPair fetching and generation is only done only for GitConnection part,
|
||||
|
|
@ -70,37 +68,3 @@ export const useSSHKeyPair = () => {
|
|||
fetchingSSHKeyPair,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGitConnect = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isConnectingToGit, setIsConnectingToGit] = useState(false);
|
||||
|
||||
const onGitConnectSuccess = useCallback(() => {
|
||||
setIsConnectingToGit(false);
|
||||
}, [setIsConnectingToGit]);
|
||||
|
||||
const onGitConnectFailure = useCallback(() => {
|
||||
setIsConnectingToGit(false);
|
||||
}, [setIsConnectingToGit]);
|
||||
|
||||
const connectToGit = useCallback(
|
||||
(payload: ConnectToGitPayload) => {
|
||||
setIsConnectingToGit(true);
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[onGitConnectSuccess, onGitConnectFailure, setIsConnectingToGit],
|
||||
);
|
||||
|
||||
return {
|
||||
isConnectingToGit,
|
||||
connectToGit,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
import { getIsStartingWithRemoteBranches, isValidGitRemoteUrl } from "./utils";
|
||||
import {
|
||||
getIsStartingWithRemoteBranches,
|
||||
isLocalBranch,
|
||||
isRemoteBranch,
|
||||
isValidGitRemoteUrl,
|
||||
removeSpecialChars,
|
||||
} from "./utils";
|
||||
|
||||
const validUrls = [
|
||||
"git@github.com:user/project.git",
|
||||
|
|
@ -58,6 +64,27 @@ describe("gitSync utils", () => {
|
|||
const expected = true;
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns false if param:local starts with origin/", () => {
|
||||
const actual = getIsStartingWithRemoteBranches(
|
||||
"origin/a",
|
||||
"origin/whateverelse",
|
||||
);
|
||||
const expected = false;
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns empty string if param:local is empty string", () => {
|
||||
const actual = getIsStartingWithRemoteBranches("a", "");
|
||||
const expected = "";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns empty string if param:remote is empty string", () => {
|
||||
const actual = getIsStartingWithRemoteBranches("", "");
|
||||
const expected = "";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidGitRemoteUrl returns true for valid urls", () => {
|
||||
|
|
@ -79,4 +106,101 @@ describe("gitSync utils", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRemoteBranch", () => {
|
||||
it("returns true for branches that start with origin/", () => {
|
||||
const branches = ["origin/", "origin/_", "origin/a", "origin/origin"];
|
||||
const actual = branches.every(isRemoteBranch);
|
||||
const expected = true;
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns false for branches that don't start with origin/", () => {
|
||||
const branches = [
|
||||
"origin",
|
||||
"original/",
|
||||
"oriign/_",
|
||||
"main/",
|
||||
"upstream/origin",
|
||||
"develop/",
|
||||
"release/",
|
||||
"master/",
|
||||
];
|
||||
const actual = branches.every(isRemoteBranch);
|
||||
const expected = false;
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalBranch", () => {
|
||||
it("returns false for branches that start with origin/", () => {
|
||||
const branches = ["origin/", "origin/_", "origin/a", "origin/origin"];
|
||||
const actual = branches.every(isLocalBranch);
|
||||
const expected = false;
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns true for branches that don't start with origin/", () => {
|
||||
const branches = [
|
||||
"origin",
|
||||
"original/",
|
||||
"oriign/_",
|
||||
"main/",
|
||||
"upstream/origin",
|
||||
"develop/",
|
||||
"release/",
|
||||
"master/",
|
||||
];
|
||||
const actual = branches.every(isLocalBranch);
|
||||
const expected = true;
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSpecialCharacters", () => {
|
||||
it("replaces special characters except / and - with _", () => {
|
||||
const inputs = [
|
||||
"abc_def",
|
||||
"abc-def",
|
||||
"abc*def",
|
||||
"abc/def",
|
||||
"abc&def",
|
||||
"abc%def",
|
||||
"abc#def",
|
||||
"abc@def",
|
||||
"abc!def",
|
||||
"abc,def",
|
||||
"abc<def",
|
||||
"abc>def",
|
||||
"abc?def",
|
||||
"abc.def",
|
||||
"abc;def",
|
||||
"abc(def",
|
||||
];
|
||||
|
||||
const expected = [
|
||||
"abc_def",
|
||||
"abc-def",
|
||||
"abc_def",
|
||||
"abc/def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
"abc_def",
|
||||
];
|
||||
|
||||
inputs.forEach((input, index) => {
|
||||
const result = removeSpecialChars(input);
|
||||
expect(result).toStrictEqual(expected[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,5 +16,43 @@ const GIT_REMOTE_URL_PATTERN = /^((git|ssh)|(git@[\w\.]+))(:(\/\/)?)([\w\.@\:\/\
|
|||
|
||||
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);
|
||||
|
||||
/**
|
||||
* isRemoteBranch: returns true if a branch name starts with origin/
|
||||
* @param name {string} branch name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isRemoteBranch = (name: string): boolean =>
|
||||
name.startsWith("origin/");
|
||||
|
||||
/**
|
||||
* isLocalBranch: returns true if a branch name doesn't start with origin/
|
||||
* @param name {string} branch name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isLocalBranch = (name: string): boolean => !isRemoteBranch(name);
|
||||
|
||||
export const getIsActiveItem = (
|
||||
isCreateNewBranchInputValid: boolean,
|
||||
activeHoverIndex: number,
|
||||
index: number,
|
||||
) =>
|
||||
(isCreateNewBranchInputValid ? activeHoverIndex - 1 : activeHoverIndex) ===
|
||||
index;
|
||||
|
||||
/**
|
||||
* removeSpecialChars: removes non-word ([^A-Za-z0-9_]) characters except / and - from input string
|
||||
* @param input {string} string containing non-word characters e.g. name of the branch
|
||||
* @returns {string}
|
||||
*/
|
||||
export const removeSpecialChars = (input: string): string => {
|
||||
const separatorRegex = /(?![/-])\W+/;
|
||||
return input.split(separatorRegex).join("_");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
ReduxActionErrorTypes,
|
||||
ReduxActionTypes,
|
||||
} from "@appsmith/constants/ReduxActionConstants";
|
||||
import { GitSyncModalTab, GitConfig, MergeStatus } from "entities/GitSync";
|
||||
import { GitConfig, GitSyncModalTab, MergeStatus } from "entities/GitSync";
|
||||
import { GetSSHKeyResponseData } from "actions/gitSyncActions";
|
||||
|
||||
const initialState: GitSyncReducerState = {
|
||||
|
|
@ -52,8 +52,7 @@ const gitSyncReducer = createReducer(initialState, {
|
|||
commitAndPushError: null,
|
||||
pullError: null,
|
||||
mergeError: null,
|
||||
// reset conflicts when the modal is opened
|
||||
pullFailed: false,
|
||||
pullFailed: false, // reset conflicts when the modal is opened
|
||||
gitImportError: null,
|
||||
};
|
||||
},
|
||||
|
|
@ -439,6 +438,34 @@ const gitSyncReducer = createReducer(initialState, {
|
|||
globalGitConfig: state.globalGitConfig,
|
||||
localGitConfig: state.localGitConfig,
|
||||
}),
|
||||
[ReduxActionTypes.DELETE_BRANCH_SUCCESS]: (
|
||||
state: GitSyncReducerState,
|
||||
action: ReduxAction<any>,
|
||||
) => ({
|
||||
...state,
|
||||
deleteBranch: action.payload,
|
||||
}),
|
||||
[ReduxActionErrorTypes.DELETE_BRANCH_ERROR]: (
|
||||
state: GitSyncReducerState,
|
||||
action: ReduxAction<any>,
|
||||
) => ({
|
||||
...state,
|
||||
deleteBranchError: action.payload,
|
||||
}),
|
||||
[ReduxActionErrorTypes.DELETE_BRANCH_WARNING]: (
|
||||
state: GitSyncReducerState,
|
||||
action: ReduxAction<string>,
|
||||
) => ({
|
||||
...state,
|
||||
deleteBranchWarning: action.payload,
|
||||
}),
|
||||
[ReduxActionTypes.DELETING_BRANCH]: (
|
||||
state: GitSyncReducerState,
|
||||
action: ReduxAction<any>,
|
||||
) => ({
|
||||
...state,
|
||||
deletingBranch: action.payload,
|
||||
}),
|
||||
});
|
||||
|
||||
export type GitStatusData = {
|
||||
|
|
@ -467,7 +494,14 @@ export type GitErrorType = {
|
|||
logToSentry?: boolean;
|
||||
};
|
||||
|
||||
export type GitSyncReducerState = {
|
||||
export type GitBranchDeleteState = {
|
||||
deleteBranch?: any;
|
||||
deleteBranchError?: any;
|
||||
deleteBranchWarning?: any;
|
||||
deletingBranch?: boolean;
|
||||
};
|
||||
|
||||
export type GitSyncReducerState = GitBranchDeleteState & {
|
||||
isGitSyncModalOpen: boolean;
|
||||
isCommitting?: boolean;
|
||||
isCommitSuccessful: boolean;
|
||||
|
|
|
|||
|
|
@ -17,9 +17,14 @@ import {
|
|||
} from "selectors/editorSelectors";
|
||||
import { validateResponse } from "./ErrorSagas";
|
||||
import {
|
||||
commitToRepoSuccess,
|
||||
ConnectToGitReduxAction,
|
||||
GenerateSSHKeyPairReduxAction,
|
||||
GetSSHKeyPairReduxAction,
|
||||
commitToRepoSuccess,
|
||||
connectToGitSuccess,
|
||||
deleteBranchError,
|
||||
deleteBranchSuccess,
|
||||
deletingBranch,
|
||||
fetchBranchesInit,
|
||||
fetchBranchesSuccess,
|
||||
fetchGitStatusInit,
|
||||
|
|
@ -30,10 +35,8 @@ import {
|
|||
fetchLocalGitConfigSuccess,
|
||||
fetchMergeStatusFailure,
|
||||
fetchMergeStatusSuccess,
|
||||
GenerateSSHKeyPairReduxAction,
|
||||
generateSSHKeyPairSuccess,
|
||||
getSSHKeyPairError,
|
||||
GetSSHKeyPairReduxAction,
|
||||
getSSHKeyPairSuccess,
|
||||
gitPullSuccess,
|
||||
importAppViaGitSuccess,
|
||||
|
|
@ -59,6 +62,7 @@ import {
|
|||
} from "selectors/applicationSelectors";
|
||||
import {
|
||||
createMessage,
|
||||
DELETE_BRANCH_SUCCESS,
|
||||
ERROR_GIT_AUTH_FAIL,
|
||||
ERROR_GIT_INVALID_REMOTE,
|
||||
GIT_USER_UPDATED_SUCCESSFULLY,
|
||||
|
|
@ -781,6 +785,32 @@ export function* generateSSHKeyPairSaga(action: GenerateSSHKeyPairReduxAction) {
|
|||
}
|
||||
}
|
||||
|
||||
export function* deleteBranch({ payload }: ReduxAction<any>) {
|
||||
yield put(deletingBranch(payload));
|
||||
const { branchToDelete } = payload;
|
||||
let response: ApiResponse | undefined;
|
||||
try {
|
||||
const applicationId: string = yield select(getCurrentApplicationId);
|
||||
|
||||
response = yield GitSyncAPI.deleteBranch(applicationId, branchToDelete);
|
||||
const isValidResponse: boolean = yield validateResponse(
|
||||
response,
|
||||
false,
|
||||
getLogToSentryFromResponse(response),
|
||||
);
|
||||
if (isValidResponse) {
|
||||
Toaster.show({
|
||||
text: createMessage(DELETE_BRANCH_SUCCESS, branchToDelete),
|
||||
variant: Variant.success,
|
||||
});
|
||||
yield put(deleteBranchSuccess(response?.data));
|
||||
yield put(fetchBranchesInit({ pruneBranches: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
yield put(deleteBranchError(error));
|
||||
}
|
||||
}
|
||||
|
||||
export default function* gitSyncSagas() {
|
||||
yield all([
|
||||
takeLatest(ReduxActionTypes.COMMIT_TO_GIT_REPO_INIT, commitToGitRepoSaga),
|
||||
|
|
@ -819,5 +849,6 @@ export default function* gitSyncSagas() {
|
|||
generateSSHKeyPairSaga,
|
||||
),
|
||||
takeLatest(ReduxActionTypes.FETCH_SSH_KEY_PAIR_INIT, getSSHKeyPairSaga),
|
||||
takeLatest(ReduxActionTypes.DELETE_BRANCH_INIT, deleteBranch),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,14 @@ export const getIsGitConnected = createSelector(
|
|||
getCurrentAppGitMetaData,
|
||||
(gitMetaData) => !!(gitMetaData && gitMetaData.remoteUrl),
|
||||
);
|
||||
export const getGitBranches = (state: AppState) => state.ui.gitSync.branches;
|
||||
|
||||
/**
|
||||
* getGitBranches: returns list of git branches in redux store
|
||||
* @param state {AppState}
|
||||
* @return Branch[]
|
||||
*/
|
||||
export const getGitBranches = (state: AppState): Branch[] =>
|
||||
state.ui.gitSync.branches;
|
||||
|
||||
export const getGitBranchNames = createSelector(getGitBranches, (branches) =>
|
||||
branches.map((branchObj) => branchObj.branchName),
|
||||
|
|
@ -101,7 +108,7 @@ export const getDefaultGitBranchName = createSelector(
|
|||
export const getFetchingBranches = (state: AppState) =>
|
||||
state.ui.gitSync.fetchingBranches;
|
||||
|
||||
export const getCurrentGitBranch = (state: AppState) => {
|
||||
export const getCurrentGitBranch = (state: AppState): string | undefined => {
|
||||
const { gitApplicationMetadata } = getCurrentApplication(state) || {};
|
||||
return gitApplicationMetadata?.branchName;
|
||||
};
|
||||
|
|
@ -170,3 +177,6 @@ export const getSshKeyPair = (state: AppState) => state.ui.gitSync.SSHKeyPair;
|
|||
|
||||
export const getIsImportingApplicationViaGit = (state: AppState) =>
|
||||
state.ui.gitSync.isImportingApplicationViaGit;
|
||||
|
||||
export const getDeleteBranchWarning = (state: AppState) =>
|
||||
state.ui.gitSync.deleteBranchWarning;
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ export type EventName =
|
|||
| "SIGNPOSTING_PUBLISH_CLICK"
|
||||
| "SIGNPOSTING_BUILD_APP_CLICK"
|
||||
| "SIGNPOSTING_WELCOME_TOUR_CLICK"
|
||||
| "GS_BRANCH_MORE_MENU_OPEN"
|
||||
| "GS_OPEN_BRANCH_LIST_POPUP"
|
||||
| "GS_CREATE_NEW_BRANCH"
|
||||
| "GS_SYNC_BRANCHES"
|
||||
|
|
@ -197,6 +198,7 @@ export type EventName =
|
|||
| "GS_CONTACT_SALES_CLICK"
|
||||
| "GS_REGENERATE_SSH_KEY_CONFIRM_CLICK"
|
||||
| "GS_REGENERATE_SSH_KEY_MORE_CLICK"
|
||||
| "GS_SWITCH_BRANCH"
|
||||
| "REFLOW_BETA_FLAG"
|
||||
| "CONNECT_GIT_CLICK"
|
||||
| "REPO_URL_EDIT"
|
||||
|
|
|
|||
|
|
@ -10392,6 +10392,13 @@ jest-snapshot@^26.6.0, jest-snapshot@^26.6.1:
|
|||
pretty-format "^26.6.1"
|
||||
semver "^7.3.2"
|
||||
|
||||
jest-styled-components@^7.0.8:
|
||||
version "7.0.8"
|
||||
resolved "https://registry.yarnpkg.com/jest-styled-components/-/jest-styled-components-7.0.8.tgz#9ea3b43f002de060b4638fde3b422d14b3e3ec9f"
|
||||
integrity sha512-0KE54d0yIzKcvtOv8eikyjG3rFRtKYUyQovaoha3nondtZzXYGB3bhsvYgEegU08Iry0ndWx2+g9f5ZzD4I+0Q==
|
||||
dependencies:
|
||||
css "^3.0.0"
|
||||
|
||||
jest-util@^26.1.0:
|
||||
version "26.6.2"
|
||||
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user