diff --git a/app/client/package.json b/app/client/package.json index 4e06578109..7df21d0938 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -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", diff --git a/app/client/src/actions/gitSyncActions.ts b/app/client/src/actions/gitSyncActions.ts index 4bae0aefa9..0e1ae070b1 100644 --- a/app/client/src/actions/gitSyncActions.ts +++ b/app/client/src/actions/gitSyncActions.ts @@ -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, +}); diff --git a/app/client/src/api/GitSyncAPI.tsx b/app/client/src/api/GitSyncAPI.tsx index a69ede8699..d145ccc4dd 100644 --- a/app/client/src/api/GitSyncAPI.tsx +++ b/app/client/src/api/GitSyncAPI.tsx @@ -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 { + return Api.delete(GitSyncAPI.baseURL + "/branch/" + applicationId, { + branchName, + }); + } } export default GitSyncAPI; diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 8d688a9ca2..974b1dfeb9 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -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", diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index a9b05b4907..08073b2d33 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -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."; diff --git a/app/client/src/components/ads/Menu.tsx b/app/client/src/components/ads/Menu.tsx index 22dc79e3fa..50065b7ee7 100644 --- a/app/client/src/components/ads/Menu.tsx +++ b/app/client/src/components/ads/Menu.tsx @@ -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} diff --git a/app/client/src/pages/Editor/gitSync/Tabs/GitConnection.tsx b/app/client/src/pages/Editor/gitSync/Tabs/GitConnection.tsx index 675430faa7..f30c96a497 100644 --- a/app/client/src/pages/Editor/gitSync/Tabs/GitConnection.tsx +++ b/app/client/src/pages/Editor/gitSync/Tabs/GitConnection.tsx @@ -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; diff --git a/app/client/src/pages/Editor/gitSync/components/BetaTag.test.tsx b/app/client/src/pages/Editor/gitSync/components/BetaTag.test.tsx new file mode 100644 index 0000000000..a40105baa5 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/BetaTag.test.tsx @@ -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(); + 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"); + }); +}); diff --git a/app/client/src/pages/Editor/gitSync/components/BetaTag.tsx b/app/client/src/pages/Editor/gitSync/components/BetaTag.tsx index 56837b6dad..858c8daca6 100644 --- a/app/client/src/pages/Editor/gitSync/components/BetaTag.tsx +++ b/app/client/src/pages/Editor/gitSync/components/BetaTag.tsx @@ -15,7 +15,7 @@ const StyledTag = styled.div` `; function BetaTag() { - return BETA; + return BETA; } export default BetaTag; diff --git a/app/client/src/pages/Editor/gitSync/components/BranchList.test.tsx b/app/client/src/pages/Editor/gitSync/components/BranchList.test.tsx deleted file mode 100644 index 3dfc28a450..0000000000 --- a/app/client/src/pages/Editor/gitSync/components/BranchList.test.tsx +++ /dev/null @@ -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", - "abcdef", - "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]); - }); - }); -}); diff --git a/app/client/src/pages/Editor/gitSync/components/BranchList.tsx b/app/client/src/pages/Editor/gitSync/components/BranchList.tsx index e61a3ea47f..0b95551a31 100644 --- a/app/client/src/pages/Editor/gitSync/components/BranchList.tsx +++ b/app/client/src/pages/Editor/gitSync/components/BranchList.tsx @@ -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 ( -
-
- ); -} - 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} /> - {`Create Branch: ${branch} `} - {`from \`${currentBranch}\``} +
{`Create Branch: ${branch} `}
+
{`from \`${currentBranch}\``}
-
- {isCreatingNewBranch && } -
+ {isCreatingNewBranch && } ); } -function BranchListItem({ - active, - branch, - className, - hovered, - isDefault, - onClick, - shouldScrollIntoView, -}: any) { - const itemRef = React.useRef(null); - const textRef = React.useRef(null); - useEffect(() => { - if (itemRef.current && shouldScrollIntoView) - scrollIntoView(itemRef.current, { - scrollMode: "if-needed", - block: "nearest", - inline: "nearest", - }); - }, [shouldScrollIntoView]); - - return ( - - - - {branch} - - - {isDefault && } - - ); -} - -function LoadingRow() { +export function LoadingRow() { return (
@@ -235,7 +158,7 @@ function LoadingRow() { ); } -function BranchesLoading() { +export function BranchesLoading() { return ( <> @@ -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, searchText: string) => { - const [filteredBranches, setFilteredBranches] = useState>([]); - useEffect(() => { - setFilteredBranches( - branches.reduce((res: Array, 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, - 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 (
{ - 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} /> )} - - {filteredBranches.map((branch: string, index: number) => ( - <> - {getIsStartingWithRemoteBranches( - filteredBranches[index - 1], - branch, - ) && } - switchBranch(branch)} - shouldScrollIntoView={getIsActiveItem( - isCreateNewBranchInputValid, - activeHoverIndex, - index, - )} - /> - - ))} + {localBranchList} + {remoteBranchList} )} diff --git a/app/client/src/pages/Editor/gitSync/components/BranchListItem.tsx b/app/client/src/pages/Editor/gitSync/components/BranchListItem.tsx new file mode 100644 index 0000000000..61149599a2 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/BranchListItem.tsx @@ -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(null); + const textRef = React.useRef(null); + const [hover] = useHover(itemRef); + + useEffect(() => { + if (itemRef.current && shouldScrollIntoView) { + scrollIntoView(itemRef.current, { + scrollMode: "if-needed", + block: "nearest", + inline: "nearest", + }); + } + }, [shouldScrollIntoView]); + + return ( + + + + {branch} + {isDefault && } + + + {hover && } + + ); +} diff --git a/app/client/src/pages/Editor/gitSync/components/BranchListItemContainer.tsx b/app/client/src/pages/Editor/gitSync/components/BranchListItemContainer.tsx new file mode 100644 index 0000000000..0bd24e24cf --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/BranchListItemContainer.tsx @@ -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; + } +`; diff --git a/app/client/src/pages/Editor/gitSync/components/BranchMoreMenu.tsx b/app/client/src/pages/Editor/gitSync/components/BranchMoreMenu.tsx new file mode 100644 index 0000000000..5e9cac7e0d --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/BranchMoreMenu.tsx @@ -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, +) { + 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 ( + saneDelete()} + selected + text={createMessage(DELETE)} + /> + ); +} + +function MenuButton( + setOpen: (value: ((prevState: boolean) => boolean) | boolean) => void, + open: boolean, +) { + return ( + { + 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 ( + + {buttons} + + ); +} diff --git a/app/client/src/pages/Editor/gitSync/components/DangerMenuItem.tsx b/app/client/src/pages/Editor/gitSync/components/DangerMenuItem.tsx new file mode 100644 index 0000000000..99808a2533 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/DangerMenuItem.tsx @@ -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; diff --git a/app/client/src/pages/Editor/gitSync/components/DefaultTag.test.tsx b/app/client/src/pages/Editor/gitSync/components/DefaultTag.test.tsx new file mode 100644 index 0000000000..0030ee3eec --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/DefaultTag.test.tsx @@ -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(); + 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%"); + }); +}); diff --git a/app/client/src/pages/Editor/gitSync/components/DefaultTag.tsx b/app/client/src/pages/Editor/gitSync/components/DefaultTag.tsx new file mode 100644 index 0000000000..1507949195 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/DefaultTag.tsx @@ -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 ( + + ); +} diff --git a/app/client/src/pages/Editor/gitSync/components/DeployedKeyUI.tsx b/app/client/src/pages/Editor/gitSync/components/DeployedKeyUI.tsx index f24b345119..46d9f08fc6 100644 --- a/app/client/src/pages/Editor/gitSync/components/DeployedKeyUI.tsx +++ b/app/client/src/pages/Editor/gitSync/components/DeployedKeyUI.tsx @@ -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; diff --git a/app/client/src/pages/Editor/gitSync/components/LocalBranchList.test.tsx b/app/client/src/pages/Editor/gitSync/components/LocalBranchList.test.tsx new file mode 100644 index 0000000000..301811fbfa --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/LocalBranchList.test.tsx @@ -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(); + }); +}); diff --git a/app/client/src/pages/Editor/gitSync/components/LocalBranchList.tsx b/app/client/src/pages/Editor/gitSync/components/LocalBranchList.tsx new file mode 100644 index 0000000000..7da6b0b7d9 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/LocalBranchList.tsx @@ -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 ( +
+ {localBranches?.length > 0 && ( + + )} + {localBranches + .map((branch: string, index: number) => ({ + branch, + isActive: getIsActiveItem( + isCreateNewBranchInputValid, + activeHoverIndex, + index, + ), + })) + .map(({ branch, isActive }) => ( + switchBranch(branch)} + selected={isActive} + shouldScrollIntoView={isActive} + /> + ))} +
+ ); +} diff --git a/app/client/src/pages/Editor/gitSync/components/RemoteBranchList.test.tsx b/app/client/src/pages/Editor/gitSync/components/RemoteBranchList.test.tsx new file mode 100644 index 0000000000..0bee818975 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/RemoteBranchList.test.tsx @@ -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(); + }); +}); diff --git a/app/client/src/pages/Editor/gitSync/components/RemoteBranchList.tsx b/app/client/src/pages/Editor/gitSync/components/RemoteBranchList.tsx new file mode 100644 index 0000000000..486b4761f0 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/RemoteBranchList.tsx @@ -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 ( +
+ {remoteBranches?.length > 0 && ( + + )} + {remoteBranches.map((branch: string) => ( + switchBranch(branch)} + /> + ))} +
+ ); +} diff --git a/app/client/src/pages/Editor/gitSync/components/RemoteBranchListItem.tsx b/app/client/src/pages/Editor/gitSync/components/RemoteBranchListItem.tsx new file mode 100644 index 0000000000..b553ebb4a8 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/components/RemoteBranchListItem.tsx @@ -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(null); + return ( + + + + {branch} + + + + ); +} diff --git a/app/client/src/pages/Editor/gitSync/hooks/index.ts b/app/client/src/pages/Editor/gitSync/hooks/index.ts new file mode 100644 index 0000000000..09f30e772f --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/hooks/index.ts @@ -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, +}; diff --git a/app/client/src/pages/Editor/gitSync/hooks/useActiveHoverIndex.ts b/app/client/src/pages/Editor/gitSync/hooks/useActiveHoverIndex.ts new file mode 100644 index 0000000000..4440da31a6 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/hooks/useActiveHoverIndex.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +export const useActiveHoverIndex = ( + currentBranch: string | undefined, + filteredBranches: Array, + 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 }; +}; diff --git a/app/client/src/pages/Editor/gitSync/hooks/useFilteredBranches.ts b/app/client/src/pages/Editor/gitSync/hooks/useFilteredBranches.ts new file mode 100644 index 0000000000..e2827561df --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/hooks/useFilteredBranches.ts @@ -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, + searchText: string, +) => { + searchText = searchText.toLowerCase(); + const [filteredBranches, setFilteredBranches] = useState>([]); + 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; +}; diff --git a/app/client/src/pages/Editor/gitSync/hooks/useGitConnect.ts b/app/client/src/pages/Editor/gitSync/hooks/useGitConnect.ts new file mode 100644 index 0000000000..67ded71ee1 --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/hooks/useGitConnect.ts @@ -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, + }; +}; diff --git a/app/client/src/pages/Editor/gitSync/hooks/useHover.ts b/app/client/src/pages/Editor/gitSync/hooks/useHover.ts new file mode 100644 index 0000000000..277086629f --- /dev/null +++ b/app/client/src/pages/Editor/gitSync/hooks/useHover.ts @@ -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]; +} diff --git a/app/client/src/pages/Editor/gitSync/hooks.ts b/app/client/src/pages/Editor/gitSync/hooks/useSSHKeyPair.ts similarity index 64% rename from app/client/src/pages/Editor/gitSync/hooks.ts rename to app/client/src/pages/Editor/gitSync/hooks/useSSHKeyPair.ts index 8c5a87179e..41281bf632 100644 --- a/app/client/src/pages/Editor/gitSync/hooks.ts +++ b/app/client/src/pages/Editor/gitSync/hooks/useSSHKeyPair.ts @@ -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, - }; -}; diff --git a/app/client/src/pages/Editor/gitSync/utils.test.ts b/app/client/src/pages/Editor/gitSync/utils.test.ts index f52612d290..edd8f8f5aa 100644 --- a/app/client/src/pages/Editor/gitSync/utils.test.ts +++ b/app/client/src/pages/Editor/gitSync/utils.test.ts @@ -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", + "abcdef", + "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]); + }); + }); + }); }); diff --git a/app/client/src/pages/Editor/gitSync/utils.ts b/app/client/src/pages/Editor/gitSync/utils.ts index aee6761883..0938bb6d45 100644 --- a/app/client/src/pages/Editor/gitSync/utils.ts +++ b/app/client/src/pages/Editor/gitSync/utils.ts @@ -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("_"); +}; diff --git a/app/client/src/reducers/uiReducers/gitSyncReducer.ts b/app/client/src/reducers/uiReducers/gitSyncReducer.ts index 3ba2774cf3..4ede356fb9 100644 --- a/app/client/src/reducers/uiReducers/gitSyncReducer.ts +++ b/app/client/src/reducers/uiReducers/gitSyncReducer.ts @@ -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, + ) => ({ + ...state, + deleteBranch: action.payload, + }), + [ReduxActionErrorTypes.DELETE_BRANCH_ERROR]: ( + state: GitSyncReducerState, + action: ReduxAction, + ) => ({ + ...state, + deleteBranchError: action.payload, + }), + [ReduxActionErrorTypes.DELETE_BRANCH_WARNING]: ( + state: GitSyncReducerState, + action: ReduxAction, + ) => ({ + ...state, + deleteBranchWarning: action.payload, + }), + [ReduxActionTypes.DELETING_BRANCH]: ( + state: GitSyncReducerState, + action: ReduxAction, + ) => ({ + ...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; diff --git a/app/client/src/sagas/GitSyncSagas.ts b/app/client/src/sagas/GitSyncSagas.ts index 0b84117ef7..2f0451de2d 100644 --- a/app/client/src/sagas/GitSyncSagas.ts +++ b/app/client/src/sagas/GitSyncSagas.ts @@ -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) { + 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), ]); } diff --git a/app/client/src/selectors/gitSyncSelectors.tsx b/app/client/src/selectors/gitSyncSelectors.tsx index 22c5ffa256..cf55c2d1bd 100644 --- a/app/client/src/selectors/gitSyncSelectors.tsx +++ b/app/client/src/selectors/gitSyncSelectors.tsx @@ -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; diff --git a/app/client/src/utils/AnalyticsUtil.tsx b/app/client/src/utils/AnalyticsUtil.tsx index 96f69fa0d4..2f4201c62a 100644 --- a/app/client/src/utils/AnalyticsUtil.tsx +++ b/app/client/src/utils/AnalyticsUtil.tsx @@ -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" diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 579e4e279b..72ef0863a7 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -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"