feat: git delete branch (#12681)

This commit is contained in:
f0c1s 2022-04-13 15:33:23 +05:30 committed by GitHub
parent 73482000a6
commit 736c2e024e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 968 additions and 312 deletions

View File

@ -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",

View File

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

View File

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

View File

@ -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",

View File

@ -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.";

View File

@ -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}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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%");
});
});

View File

@ -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"}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
};

View File

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

View File

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

View 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,
};
};

View 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];
}

View File

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

View File

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

View File

@ -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("_");
};

View File

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

View File

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

View File

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

View File

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

View File

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