diff --git a/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx b/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx new file mode 100644 index 000000000000..69f0d44f263c --- /dev/null +++ b/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import AutocommitStatusbar from "./AutocommitStatusbar"; +import "@testing-library/jest-dom"; + +// Mock timers using Jest +jest.useFakeTimers(); + +// Mock the Statusbar component from '@appsmith/ads-old' +jest.mock("@appsmith/ads-old", () => ({ + Statusbar: ({ percentage }: { percentage: number }) => ( +
{percentage}%
+ ), +})); + +const TOTAL_DURATION_MS = 4000; +const STEPS = 9; +const INTERVAL_MS = TOTAL_DURATION_MS / STEPS; + +describe("AutocommitStatusbar Component", () => { + afterEach(() => { + jest.clearAllTimers(); + }); + + it("should render with initial percentage 0 when completed is false", () => { + render(); + const statusbar = screen.getByTestId("statusbar"); + + expect(statusbar).toBeInTheDocument(); + expect(statusbar).toHaveTextContent("0%"); + }); + + it("should increment percentage over time when completed is false", () => { + render(); + const statusbar = screen.getByTestId("statusbar"); + + // Initial percentage + expect(statusbar).toHaveTextContent("0%"); + + // Advance timer by one interval + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(statusbar).toHaveTextContent("10%"); + + // Advance timer by another interval + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(statusbar).toHaveTextContent("20%"); + + // Continue until percentage reaches 90% + act(() => { + jest.advanceTimersByTime((4 * 1000 * 7) / 9); + }); + expect(statusbar).toHaveTextContent("90%"); + }); + + it("should not increment percentage beyond 90 when completed is false", () => { + render(); + const statusbar = screen.getByTestId("statusbar"); + + // Advance time beyond the total interval duration + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(statusbar).toHaveTextContent("90%"); + + // Advance time further to ensure percentage doesn't exceed 90% + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(statusbar).toHaveTextContent("90%"); + }); + + it("should set percentage to 100 when completed is true", () => { + render(); + const statusbar = screen.getByTestId("statusbar"); + + expect(statusbar).toHaveTextContent("100%"); + }); + + it("should call onHide after 1 second when completed is true", () => { + const onHide = jest.fn(); + + render(); + expect(onHide).not.toHaveBeenCalled(); + + // Advance timer by 1 second + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(onHide).toHaveBeenCalledTimes(1); + }); + + it("should clean up intervals and timeouts on unmount", () => { + const onHide = jest.fn(); + + const { unmount } = render( + , + ); + + // Start the interval + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + + // Unmount the component + unmount(); + + // Advance time to see if any timers are still running + act(() => { + jest.advanceTimersByTime(10000); + }); + expect(onHide).not.toHaveBeenCalled(); + }); + + it("should handle transition from false to true for completed prop", () => { + const onHide = jest.fn(); + const { rerender } = render( + , + ); + const statusbar = screen.getByTestId("statusbar"); + + // Advance timer to increase percentage + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(statusbar).toHaveTextContent("10%"); + + // Update the completed prop to true + rerender(); + expect(statusbar).toHaveTextContent("100%"); + + // Ensure onHide is called after 1 second + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(onHide).toHaveBeenCalledTimes(1); + }); + + it("should not reset percentage when completed changes from true to false", () => { + const { rerender } = render(); + const statusbar = screen.getByTestId("statusbar"); + + expect(statusbar).toHaveTextContent("100%"); + + // Change completed to false + rerender(); + expect(statusbar).toHaveTextContent("100%"); + + // Advance timer to check if percentage increments beyond 100% + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(statusbar).toHaveTextContent("100%"); + }); +}); diff --git a/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx b/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx new file mode 100644 index 000000000000..88a3eb460734 --- /dev/null +++ b/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Statusbar } from "@appsmith/ads-old"; +import styled from "styled-components"; +import { + AUTOCOMMIT_IN_PROGRESS_MESSAGE, + createMessage, +} from "ee/constants/messages"; + +interface AutocommitStatusbarProps { + completed: boolean; + onHide?: () => void; +} + +const PROGRESSBAR_WIDTH = 150; +const TOTAL_DURATION_MS = 4000; // in ms +const MAX_PROGRESS_PERCENTAGE = 90; +const PROGRESS_INCREMENT = 10; +const STEPS = 9; +const INTERVAL_MS = TOTAL_DURATION_MS / STEPS; + +const StatusbarWrapper = styled.div` + > div { + display: flex; + height: initial; + align-items: center; + } + + > div > div { + margin-top: 0px; + width: ${PROGRESSBAR_WIDTH}px; + margin-right: var(--ads-v2-spaces-4); + } + + > div > p { + margin-top: 0; + } +`; + +export default function AutocommitStatusbar({ + completed, + onHide, +}: AutocommitStatusbarProps) { + const intervalRef = useRef(null); + const timeoutRef = useRef(null); + const [percentage, setPercentage] = useState(0); + + // Effect for incrementing percentage when not completed + useEffect( + function incrementPercentage() { + if (!completed) { + intervalRef.current = setInterval(() => { + setPercentage((prevPercentage) => { + if (prevPercentage < MAX_PROGRESS_PERCENTAGE) { + return prevPercentage + PROGRESS_INCREMENT; + } else { + // Clear the interval when percentage reaches 90% + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + return prevPercentage; + } + }); + }, INTERVAL_MS); + } + + // Cleanup function to clear the interval + return () => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, + [completed], + ); // Removed 'percentage' from dependencies + + // Effect for setting percentage to 100% when completed + useEffect( + function finishPercentage() { + if (completed) { + setPercentage(100); + } + }, + [completed], + ); + + // Effect for calling onHide after 1 second when completed + useEffect( + function onCompleteCallback() { + if (completed && onHide) { + timeoutRef.current = setTimeout(() => { + onHide(); + }, 1000); + } + + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, + [completed, onHide], + ); + + return ( + + + + ); +} diff --git a/app/client/src/git/components/QuickActions/ConnectButton.test.tsx b/app/client/src/git/components/QuickActions/ConnectButton.test.tsx new file mode 100644 index 000000000000..3b017c0c018f --- /dev/null +++ b/app/client/src/git/components/QuickActions/ConnectButton.test.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import ConnectButton from "./ConnectButton"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import { GitSyncModalTab } from "entities/GitSync"; +import "@testing-library/jest-dom"; +import { theme } from "constants/DefaultTheme"; +import { ThemeProvider } from "styled-components"; + +// Mock the AnalyticsUtil +jest.mock("ee/utils/AnalyticsUtil", () => ({ + logEvent: jest.fn(), +})); + +// Mock the components from '@appsmith/ads' +jest.mock("@appsmith/ads", () => ({ + ...jest.requireActual("@appsmith/ads"), + Icon: ({ name }: Record) => ( +
{name}
+ ), + Tooltip: ({ children, content, isDisabled }: Record) => ( +
+ {children} + {!isDisabled &&
{content}
} +
+ ), +})); + +describe("ConnectButton Component", () => { + const openGitSyncModalMock = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render correctly when isConnectPermitted is true", () => { + render( + + + , + ); + + // Check that the button is rendered and enabled + const button = screen.getByRole("button"); + + expect(button).toBeInTheDocument(); + expect(button).toBeEnabled(); + + // Tooltip should be disabled + const tooltipContent = screen.queryByTestId("tooltip-content"); + + expect(tooltipContent).not.toBeInTheDocument(); + + // Icon should be rendered + const icon = screen.getByTestId("icon"); + + expect(icon).toBeInTheDocument(); + expect(icon).toHaveTextContent("git-commit"); + }); + + it("should handle click when isConnectPermitted is true", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Connect Git (Beta)" }); + + fireEvent.click(button); + + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith( + "GS_CONNECT_GIT_CLICK", + { + source: "BOTTOM_BAR_GIT_CONNECT_BUTTON", + }, + ); + + expect(openGitSyncModalMock).toHaveBeenCalledWith({ + tab: GitSyncModalTab.GIT_CONNECTION, + }); + }); + + it("should render correctly when isConnectPermitted is false", () => { + render( + + + , + ); + + // Check that the button is rendered and disabled + const button = screen.getByRole("button", { name: "Connect Git (Beta)" }); + + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + + // Tooltip should be enabled and display correct content + const tooltipContent = screen.getByTestId("tooltip-content"); + + expect(tooltipContent).toBeInTheDocument(); + expect(tooltipContent).toHaveTextContent( + "Please contact your workspace admin to connect your app to a git repo", + ); + + // Icon should be rendered + const icon = screen.getByTestId("icon"); + + expect(icon).toBeInTheDocument(); + expect(icon).toHaveTextContent("git-commit"); + }); + + it("should not handle click when isConnectPermitted is false", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Connect Git (Beta)" }); + + fireEvent.click(button); + + expect(AnalyticsUtil.logEvent).not.toHaveBeenCalled(); + expect(openGitSyncModalMock).not.toHaveBeenCalled(); + }); + + it("should display correct tooltip content when isConnectPermitted is true", () => { + render( + + + , + ); + + // Tooltip should be disabled, so content should not be visible + const tooltipContent = screen.queryByTestId("tooltip-content"); + + expect(tooltipContent).not.toBeInTheDocument(); + }); +}); diff --git a/app/client/src/git/components/QuickActions/ConnectButton.tsx b/app/client/src/git/components/QuickActions/ConnectButton.tsx new file mode 100644 index 000000000000..426b2a05ec85 --- /dev/null +++ b/app/client/src/git/components/QuickActions/ConnectButton.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useMemo } from "react"; +import { GitSyncModalTab } from "entities/GitSync"; +import styled from "styled-components"; +import { + COMING_SOON, + CONNECT_GIT_BETA, + CONTACT_ADMIN_FOR_GIT, + createMessage, + NOT_LIVE_FOR_YOU_YET, +} from "ee/constants/messages"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import { Button, Icon, Tooltip } from "@appsmith/ads"; + +interface ConnectButtonProps { + isConnectPermitted: boolean; + openGitSyncModal: (options: { tab: GitSyncModalTab }) => void; +} + +const CenterDiv = styled.div` + text-align: center; +`; + +const Container = styled.div` + height: 100%; + display: flex; + align-items: center; + margin-left: 0; + cursor: pointer; +`; + +const StyledIcon = styled(Icon)` + cursor: default; + margin-right: ${(props) => props.theme.spaces[3]}px; +`; + +const OuterContainer = styled.div` + padding: 4px 16px; + height: 100%; +`; + +function ConnectButton({ + isConnectPermitted, + openGitSyncModal, +}: ConnectButtonProps) { + const isTooltipEnabled = !isConnectPermitted; + const tooltipContent = useMemo(() => { + if (!isConnectPermitted) { + return {createMessage(CONTACT_ADMIN_FOR_GIT)}; + } + + return ( + <> +
{createMessage(NOT_LIVE_FOR_YOU_YET)}
+
{createMessage(COMING_SOON)}
+ + ); + }, [isConnectPermitted]); + + const handleClickOnGitConnect = useCallback(() => { + AnalyticsUtil.logEvent("GS_CONNECT_GIT_CLICK", { + source: "BOTTOM_BAR_GIT_CONNECT_BUTTON", + }); + + openGitSyncModal({ + tab: GitSyncModalTab.GIT_CONNECTION, + }); + }, [openGitSyncModal]); + + return ( + + + + + + + + + ); +} + +export default ConnectButton; diff --git a/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx b/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx new file mode 100644 index 000000000000..6bd94a586f56 --- /dev/null +++ b/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import QuickActionButton from "./QuickActionButton"; +import "@testing-library/jest-dom"; +import { theme } from "constants/DefaultTheme"; +import { ThemeProvider } from "styled-components"; + +jest.mock("pages/common/SpinnerLoader", () => { + return function SpinnerLoader() { + return
Loading...
; + }; +}); + +jest.mock("@appsmith/ads", () => ({ + ...jest.requireActual("@appsmith/ads"), + Tooltip: ({ children, content }: Record) => ( +
+
{content}
+ {children} +
+ ), +})); + +describe("QuickActionButton", () => { + const defaultProps = { + icon: "plus", + onClick: jest.fn(), + tooltipText: "default action", + className: "t--test-btn", + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render without crashing", () => { + const { container } = render( + + + , + ); + const btn = container.getElementsByClassName("t--test-btn")[0]; + + expect(btn).toBeInTheDocument(); + }); + + it("should call onClick when button is clicked", () => { + const { container } = render( + + + , + ); + const btn = container.getElementsByClassName("t--test-btn")[0]; + + fireEvent.click(btn); + expect(defaultProps.onClick).toHaveBeenCalledTimes(1); + }); + + it("should not call onClick when button is disabled", () => { + const { container } = render( + + + , + ); + const btn = container.getElementsByClassName("t--test-btn")[0]; + + fireEvent.click(btn); + expect(defaultProps.onClick).not.toHaveBeenCalled(); + }); + + it("should display the tooltip with capitalized text", () => { + render( + + + , + ); + expect(screen.getByTestId("tooltip-content")).toHaveTextContent( + "Default action", + ); + }); + + it("should display the spinner when loading is true", () => { + render( + + + , + ); + expect(screen.getByTestId("spinner-loader")).toBeInTheDocument(); + expect(screen.queryByTestId("t--test-btn")).not.toBeInTheDocument(); + }); + + it("should display the count badge when count is greater than 0", () => { + render( + + + , + ); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + it("should not display the count badge when count is 0", () => { + render( + + + , + ); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + }); +}); diff --git a/app/client/src/git/components/QuickActions/QuickActionButton.tsx b/app/client/src/git/components/QuickActions/QuickActionButton.tsx new file mode 100644 index 000000000000..42d7721ab297 --- /dev/null +++ b/app/client/src/git/components/QuickActions/QuickActionButton.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import styled from "styled-components"; +import { capitalizeFirstLetter } from "./helpers"; +import SpinnerLoader from "pages/common/SpinnerLoader"; +import { Button, Tooltip, Text } from "@appsmith/ads"; +import { getTypographyByKey } from "@appsmith/ads-old"; + +interface QuickActionButtonProps { + className?: string; + count?: number; + disabled?: boolean; + icon: string; + loading?: boolean; + onClick: () => void; + tooltipText: string; +} + +const SpinnerContainer = styled.div` + padding: 0 10px; +`; + +const QuickActionButtonContainer = styled.button<{ disabled?: boolean }>` + margin: 0 ${(props) => props.theme.spaces[1]}px; + display: block; + position: relative; + overflow: visible; + cursor: ${({ disabled = false }) => (disabled ? "not-allowed" : "pointer")}; + opacity: ${({ disabled = false }) => (disabled ? 0.6 : 1)}; +`; + +const StyledCountText = styled(Text)` + align-items: center; + background-color: var(--ads-v2-color-bg-brand-secondary-emphasis-plus); + color: var(--ads-v2-color-white); + display: flex; + justify-content: center; + position: absolute; + height: var(--ads-v2-spaces-5); + top: ${(props) => -1 * props.theme.spaces[3]}px; + left: ${(props) => props.theme.spaces[10]}px; + border-radius: ${(props) => props.theme.spaces[3]}px; + ${getTypographyByKey("p3")}; + z-index: 1; + padding: ${(props) => props.theme.spaces[1]}px + ${(props) => props.theme.spaces[2]}px; +`; + +function QuickActionButton({ + className = "", + count = 0, + disabled = false, + icon, + loading = false, + onClick, + tooltipText, +}: QuickActionButtonProps) { + const content = capitalizeFirstLetter(tooltipText); + + return ( + + {loading ? ( + + + + ) : ( + +
+
+
+ )} +
+ ); +} + +export default QuickActionButton; diff --git a/app/client/src/git/components/QuickActions/helper.test.ts b/app/client/src/git/components/QuickActions/helper.test.ts new file mode 100644 index 000000000000..a7b3a6d30204 --- /dev/null +++ b/app/client/src/git/components/QuickActions/helper.test.ts @@ -0,0 +1,191 @@ +import { getPullBtnStatus, capitalizeFirstLetter } from "./helpers"; + +describe("getPullBtnStatus", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return disabled with message "No commits to pull" when behindCount is 0', () => { + const gitStatus: Record = { + behindCount: 0, + isClean: true, + }; + const pullFailed = false; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: + "No commits to pull. This branch is in sync with the remote repository", + }); + }); + + it('should return disabled with message "Cannot pull with local uncommitted changes" when isClean is false and isProtected is false', () => { + const gitStatus: Record = { + behindCount: 5, + isClean: false, + }; + const pullFailed = false; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: + "You have uncommitted changes. Please commit or discard before pulling the remote changes.", + }); + }); + + it('should return enabled with message "Pull changes" when isClean is false, isProtected is true, and behindCount > 0', () => { + const gitStatus: Record = { + behindCount: 3, + isClean: false, + }; + const pullFailed = false; + const isProtected = true; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: false, + message: "Pull changes", + }); + }); + + it('should return message "Conflicts found" when pullFailed is true', () => { + const gitStatus: Record = { + behindCount: 2, + isClean: true, + }; + const pullFailed = true; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: false, + message: "Conflicts found. Please resolve them and pull again.", + }); + }); + + it('should return enabled with message "Pull changes" when behindCount > 0 and no other conditions met', () => { + const gitStatus: Record = { + behindCount: 1, + isClean: true, + }; + const pullFailed = false; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: false, + message: "Pull changes", + }); + }); + + it('should return disabled with message "No commits to pull" when behindCount is 0 regardless of isClean and isProtected', () => { + const gitStatus: Record = { + behindCount: 0, + isClean: false, + }; + const pullFailed = false; + const isProtected = true; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: + "No commits to pull. This branch is in sync with the remote repository", + }); + }); + + it("should prioritize pullFailed over other conditions", () => { + const gitStatus: Record = { + behindCount: 0, + isClean: true, + }; + const pullFailed = true; + const isProtected = false; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: "Conflicts found. Please resolve them and pull again.", + }); + }); + + it("should handle edge case when isClean is false, isProtected is true, behindCount is 0", () => { + const gitStatus: Record = { + behindCount: 0, + isClean: false, + }; + const pullFailed = false; + const isProtected = true; + + const result = getPullBtnStatus(gitStatus, pullFailed, isProtected); + + expect(result).toEqual({ + disabled: true, + message: + "No commits to pull. This branch is in sync with the remote repository", + }); + }); +}); + +describe("capitalizeFirstLetter", () => { + it("should capitalize the first letter of a lowercase word", () => { + const result = capitalizeFirstLetter("hello"); + + expect(result).toBe("Hello"); + }); + + it("should capitalize the first letter of an uppercase word", () => { + const result = capitalizeFirstLetter("WORLD"); + + expect(result).toBe("World"); + }); + + it("should handle empty string", () => { + const result = capitalizeFirstLetter(""); + + expect(result).toBe(""); + }); + + it("should handle single character", () => { + const result = capitalizeFirstLetter("a"); + + expect(result).toBe("A"); + }); + + it("should handle strings with spaces", () => { + const result = capitalizeFirstLetter("multiple words here"); + + expect(result).toBe("Multiple words here"); + }); + + it("should handle undefined input", () => { + // The function provides a default value of " " when input is undefined + // So we expect the output to be a single space with capitalized first letter + const result = capitalizeFirstLetter(); + + expect(result).toBe(" "); + }); + + it("should handle strings with special characters", () => { + const result = capitalizeFirstLetter("123abc"); + + expect(result).toBe("123abc"); + }); + + it("should not modify strings where the first character is not a letter", () => { + const result = capitalizeFirstLetter("!test"); + + expect(result).toBe("!test"); + }); +}); diff --git a/app/client/src/git/components/QuickActions/helpers.ts b/app/client/src/git/components/QuickActions/helpers.ts new file mode 100644 index 000000000000..109197264899 --- /dev/null +++ b/app/client/src/git/components/QuickActions/helpers.ts @@ -0,0 +1,45 @@ +import { + CANNOT_PULL_WITH_LOCAL_UNCOMMITTED_CHANGES, + CONFLICTS_FOUND, + createMessage, + NO_COMMITS_TO_PULL, + PULL_CHANGES, +} from "ee/constants/messages"; +import type { GitStatus } from "../../types"; + +export const getPullBtnStatus = ( + gitStatus: GitStatus, + pullFailed: boolean, + isProtected: boolean, +) => { + const { behindCount, isClean } = gitStatus; + let message = createMessage(NO_COMMITS_TO_PULL); + let disabled = behindCount === 0; + + if (!isClean && !isProtected) { + disabled = true; + message = createMessage(CANNOT_PULL_WITH_LOCAL_UNCOMMITTED_CHANGES); + // TODO: Remove this when gitStatus typings are finalized + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + } else if (!isClean && isProtected && behindCount > 0) { + disabled = false; + message = createMessage(PULL_CHANGES); + } else if (pullFailed) { + message = createMessage(CONFLICTS_FOUND); + // TODO: Remove this when gitStatus typings are finalized + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + } else if (behindCount > 0) { + message = createMessage(PULL_CHANGES); + } + + return { + disabled, + message, + }; +}; + +export const capitalizeFirstLetter = (string = " ") => { + return string.charAt(0).toUpperCase() + string.toLowerCase().slice(1); +}; diff --git a/app/client/src/git/components/QuickActions/index.test.tsx b/app/client/src/git/components/QuickActions/index.test.tsx new file mode 100644 index 000000000000..b920e6b51fde --- /dev/null +++ b/app/client/src/git/components/QuickActions/index.test.tsx @@ -0,0 +1,334 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import QuickActions from "."; +import { GitSettingsTab } from "git/enums"; +import { GitSyncModalTab } from "entities/GitSync"; +import { theme } from "constants/DefaultTheme"; +import { ThemeProvider } from "styled-components"; +import "@testing-library/jest-dom/extend-expect"; + +jest.mock("ee/utils/AnalyticsUtil", () => ({ + logEvent: jest.fn(), +})); + +jest.mock("./ConnectButton", () => () => ( +
ConnectButton
+)); + +jest.mock("./AutocommitStatusbar", () => () => ( +
AutocommitStatusbar
+)); + +describe("QuickActions Component", () => { + const defaultProps = { + isGitConnected: false, + gitStatus: { + behindCount: 0, + isClean: true, + }, + pullFailed: false, + isProtectedMode: false, + isDiscardInProgress: false, + isPollingAutocommit: false, + isPullInProgress: false, + isFetchingGitStatus: false, + changesToCommit: 0, + gitMetadata: {}, + isAutocommitEnabled: false, + isConnectPermitted: true, + openGitSyncModal: jest.fn(), + openGitSettingsModal: jest.fn(), + discardChanges: jest.fn(), + pull: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render ConnectButton when isGitConnected is false", () => { + render( + + + , + ); + expect(screen.getByTestId("connect-button")).toBeInTheDocument(); + }); + + it("should render QuickActionButtons when isGitConnected is true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + + expect( + container.getElementsByClassName("t--bottom-bar-commit").length, + ).toBe(1); + expect(container.getElementsByClassName("t--bottom-bar-pull").length).toBe( + 1, + ); + expect(container.getElementsByClassName("t--bottom-bar-merge").length).toBe( + 1, + ); + expect( + container.getElementsByClassName("t--bottom-git-settings").length, + ).toBe(1); + }); + + it("should render AutocommitStatusbar when isAutocommitEnabled and isPollingAutocommit are true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + gitMetadata: { + autoCommitConfig: { + enabled: true, + }, + }, + isPollingAutocommit: true, + }; + + const { container } = render( + + + , + ); + + expect(screen.getByTestId("autocommit-statusbar")).toBeInTheDocument(); + expect( + container.getElementsByClassName("t--bottom-bar-commit").length, + ).toBe(0); + }); + + it("should call onCommitClick when commit button is clicked", () => { + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + const commitButton = container.getElementsByClassName( + "t--bottom-bar-commit", + )[0]; + + fireEvent.click(commitButton); + expect(props.openGitSyncModal).toHaveBeenCalledWith({ + tab: GitSyncModalTab.DEPLOY, + }); + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith( + "GS_DEPLOY_GIT_MODAL_TRIGGERED", + { + source: "BOTTOM_BAR_GIT_COMMIT_BUTTON", + }, + ); + }); + + it("should call onPullClick when pull button is clicked", () => { + const props = { + ...defaultProps, + isGitConnected: true, + isDiscardInProgress: false, + isPullInProgress: false, + isFetchingGitStatus: false, + pullDisabled: false, + gitStatus: { + behindCount: 1, + isClean: false, + }, + isProtectedMode: true, + }; + + const { container } = render( + + + , + ); + const pullButton = + container.getElementsByClassName("t--bottom-bar-pull")[0]; + + fireEvent.click(pullButton); + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith("GS_PULL_GIT_CLICK", { + source: "BOTTOM_BAR_GIT_PULL_BUTTON", + }); + }); + + it("should call onMerge when merge button is clicked", () => { + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + const mergeButton = container.getElementsByClassName( + "t--bottom-bar-merge", + )[0]; + + fireEvent.click(mergeButton); + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith( + "GS_MERGE_GIT_MODAL_TRIGGERED", + { + source: "BOTTOM_BAR_GIT_MERGE_BUTTON", + }, + ); + expect(props.openGitSyncModal).toHaveBeenCalledWith({ + tab: GitSyncModalTab.MERGE, + isDeploying: true, + }); + }); + + it("should call onSettingsClick when settings button is clicked", () => { + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + const settingsButton = container.getElementsByClassName( + "t--bottom-git-settings", + )[0]; + + fireEvent.click(settingsButton); + expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith("GS_SETTING_CLICK", { + source: "BOTTOM_BAR_GIT_SETTING_BUTTON", + }); + expect(props.openGitSettingsModal).toHaveBeenCalledWith({ + tab: GitSettingsTab.General, + }); + }); + + it("should disable commit button when isProtectedMode is true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + isProtectedMode: true, + }; + + const { container } = render( + + + , + ); + const commitButton = container.getElementsByClassName( + "t--bottom-bar-commit", + )[0]; + + expect(commitButton).toBeDisabled(); + }); + + it("should show loading state on pull button when showPullLoadingState is true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + isPullInProgress: true, + }; + + const { container } = render( + + + , + ); + + const pullButton = + container.getElementsByClassName("t--bottom-bar-pull")[0]; + const pullLoading = pullButton.getElementsByClassName( + "t--loader-quick-git-action", + )[0]; + + expect(pullLoading).toBeInTheDocument(); + }); + + it("should display changesToCommit count on commit button", () => { + const props = { + ...defaultProps, + isGitConnected: true, + changesToCommit: 5, + }; + + render( + + + , + ); + const countElement = screen.getByTestId("t--bottom-bar-count"); + + expect(countElement).toHaveTextContent("5"); + }); + + it("should not display count on commit button when isProtectedMode is true", () => { + const props = { + ...defaultProps, + isGitConnected: true, + isProtectedMode: true, + changesToCommit: 5, + }; + + render( + + + , + ); + expect(screen.queryByTestId("t--bottom-bar-count")).not.toBeInTheDocument(); + }); + + it("should disable pull button when pullDisabled is true", () => { + const mockGetPullBtnStatus = jest.requireMock("./helpers").getPullBtnStatus; + + mockGetPullBtnStatus.mockReturnValue({ + disabled: true, + message: "Pull Disabled", + }); + + const props = { + ...defaultProps, + isGitConnected: true, + }; + + const { container } = render( + + + , + ); + const pullButton = + container.getElementsByClassName("t--bottom-bar-pull")[0]; + + expect(pullButton).toBeDisabled(); + }); + + it("should show behindCount on pull button", () => { + const props = { + ...defaultProps, + isGitConnected: true, + gitStatus: { + behindCount: 3, + isClean: true, + }, + }; + + render( + + + , + ); + const countElement = screen.getByTestId("t--bottom-bar-count"); + + expect(countElement).toHaveTextContent("3"); + }); +}); diff --git a/app/client/src/git/components/QuickActions/index.tsx b/app/client/src/git/components/QuickActions/index.tsx new file mode 100644 index 000000000000..cab336ca1e52 --- /dev/null +++ b/app/client/src/git/components/QuickActions/index.tsx @@ -0,0 +1,183 @@ +import React, { useCallback } from "react"; +import styled from "styled-components"; + +import { + COMMIT_CHANGES, + createMessage, + GIT_SETTINGS, + MERGE, +} from "ee/constants/messages"; + +import { GitSyncModalTab } from "entities/GitSync"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import type { GitMetadata, GitStatus } from "../../types"; +import { getPullBtnStatus } from "./helpers"; +import { GitSettingsTab } from "../../enums"; +import ConnectButton from "./ConnectButton"; +import QuickActionButton from "./QuickActionButton"; +import AutocommitStatusbar from "./AutocommitStatusbar"; + +interface QuickActionsProps { + isGitConnected: boolean; + gitStatus: GitStatus; + pullFailed: boolean; + isProtectedMode: boolean; + isDiscardInProgress: boolean; + isPollingAutocommit: boolean; + isPullInProgress: boolean; + isFetchingGitStatus: boolean; + changesToCommit: number; + gitMetadata: GitMetadata; + isAutocommitEnabled: boolean; + isConnectPermitted: boolean; + openGitSyncModal: (options: { + tab: GitSyncModalTab; + isDeploying?: boolean; + }) => void; + openGitSettingsModal: (options: { tab: GitSettingsTab }) => void; + discardChanges: () => void; + pull: (options: { triggeredFromBottomBar: boolean }) => void; +} + +const Container = styled.div` + height: 100%; + display: flex; + align-items: center; +`; + +function QuickActions({ + changesToCommit, + discardChanges, + gitMetadata, + gitStatus, + isConnectPermitted, + isDiscardInProgress, + isFetchingGitStatus, + isGitConnected, + isPollingAutocommit, + isProtectedMode, + isPullInProgress, + openGitSettingsModal, + openGitSyncModal, + pull, + pullFailed, +}: QuickActionsProps) { + const { disabled: pullDisabled, message: pullTooltipMessage } = + getPullBtnStatus(gitStatus, !!pullFailed, isProtectedMode); + + const showPullLoadingState = + isDiscardInProgress || isPullInProgress || isFetchingGitStatus; + + // TODO - Update once the gitMetadata typing is added + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const isAutocommitEnabled: boolean = gitMetadata?.autoCommitConfig?.enabled; + const onCommitClick = useCallback(() => { + if (!isFetchingGitStatus && !isProtectedMode) { + openGitSyncModal({ + tab: GitSyncModalTab.DEPLOY, + }); + + AnalyticsUtil.logEvent("GS_DEPLOY_GIT_MODAL_TRIGGERED", { + source: "BOTTOM_BAR_GIT_COMMIT_BUTTON", + }); + } + }, [isFetchingGitStatus, isProtectedMode, openGitSyncModal]); + + const onPullClick = useCallback(() => { + if (!showPullLoadingState && !pullDisabled) { + AnalyticsUtil.logEvent("GS_PULL_GIT_CLICK", { + source: "BOTTOM_BAR_GIT_PULL_BUTTON", + }); + + if (isProtectedMode) { + discardChanges(); + } else { + pull({ triggeredFromBottomBar: true }); + } + } + }, [ + discardChanges, + isProtectedMode, + pull, + pullDisabled, + showPullLoadingState, + ]); + + const onMerge = useCallback(() => { + AnalyticsUtil.logEvent("GS_MERGE_GIT_MODAL_TRIGGERED", { + source: "BOTTOM_BAR_GIT_MERGE_BUTTON", + }); + openGitSyncModal({ + tab: GitSyncModalTab.MERGE, + isDeploying: true, + }); + }, [openGitSyncModal]); + + const onSettingsClick = useCallback(() => { + openGitSettingsModal({ + tab: GitSettingsTab.General, + }); + AnalyticsUtil.logEvent("GS_SETTING_CLICK", { + source: "BOTTOM_BAR_GIT_SETTING_BUTTON", + }); + }, [openGitSettingsModal]); + + return isGitConnected ? ( + + {/* */} + {isAutocommitEnabled && isPollingAutocommit ? ( + + ) : ( + <> + + + + + + )} + + ) : ( + + ); +} + +export default QuickActions;