diff --git a/app/client/src/git/ce/constants/messages.tsx b/app/client/src/git/ce/constants/messages.tsx new file mode 100644 index 000000000000..0978898423ff --- /dev/null +++ b/app/client/src/git/ce/constants/messages.tsx @@ -0,0 +1,18 @@ +export const OPS_MODAL = { + TAB_RELEASE: "RELEASE", +}; + +export const TAB_RELEASE = { + TITLE: "Release version", + RELEASE_BTN: "Release", +}; + +export const RELEASE_VERSION_RADIO_GROUP = { + TITLE: "Version", + LAST_RELEASED: "Last released", +}; + +export const RELEASE_NOTES_INPUT = { + TITLE: "Release notes", + PLACEHOLDER: "Your release notes here", +}; diff --git a/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.test.tsx b/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.test.tsx new file mode 100644 index 000000000000..f5cfcf517c65 --- /dev/null +++ b/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.test.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import LatestCommitInfoView from "./LatestCommitInfoView"; +import "@testing-library/jest-dom"; + +describe("LatestCommitInfoView", () => { + it("renders correctly with all props", () => { + const { getByText } = render( + , + ); + + expect(getByText("Initial commit")).toBeInTheDocument(); + expect(getByText("John Doe committed 2025-03-01")).toBeInTheDocument(); + expect(getByText("abc123")).toBeInTheDocument(); + }); + + it("renders correctly with null authorName", () => { + const { getByText } = render( + , + ); + + expect(getByText("Initial commit")).toBeInTheDocument(); + expect(getByText("- committed 2025-03-01")).toBeInTheDocument(); + expect(getByText("abc123")).toBeInTheDocument(); + }); + + it("renders correctly with null committedAt", () => { + const { getByText } = render( + , + ); + + expect(getByText("Initial commit")).toBeInTheDocument(); + expect(getByText("John Doe committed -")).toBeInTheDocument(); + expect(getByText("abc123")).toBeInTheDocument(); + }); + + it("renders correctly with null hash", () => { + const { getByText } = render( + , + ); + + expect(getByText("Initial commit")).toBeInTheDocument(); + expect(getByText("John Doe committed 2025-03-01")).toBeInTheDocument(); + expect(getByText("-")).toBeInTheDocument(); + }); + + it("renders correctly with null message", () => { + const { getByText } = render( + , + ); + + expect(getByText("John Doe committed 2025-03-01")).toBeInTheDocument(); + expect(getByText("abc123")).toBeInTheDocument(); + }); + + it("renders correctly with all null props", () => { + const { getByText } = render( + , + ); + + expect(getByText("- committed -")).toBeInTheDocument(); + expect(getByText("-")).toBeInTheDocument(); + }); +}); diff --git a/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.tsx b/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.tsx new file mode 100644 index 000000000000..fc6fb1ce671f --- /dev/null +++ b/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.tsx @@ -0,0 +1,41 @@ +import { Flex, Icon, Text } from "@appsmith/ads"; +import React from "react"; +import styled from "styled-components"; + +const Container = styled(Flex)` + border-radius: 4px; + background-color: var(--ads-v2-color-gray-0); +`; + +interface LatestCommitInfoViewProps { + authorName: string | null; + committedAt: string | null; + hash: string | null; + message: string | null; +} + +function LatestCommitInfoView({ + authorName = null, + committedAt = null, + hash = null, + message = null, +}: LatestCommitInfoViewProps) { + return ( + + + {message} + + {authorName ?? "-"} committed {committedAt ?? "-"} + + + + + + {hash ?? "-"} + + + + ); +} + +export default LatestCommitInfoView; diff --git a/app/client/src/git/components/LatestCommitInfo/index.tsx b/app/client/src/git/components/LatestCommitInfo/index.tsx new file mode 100644 index 000000000000..ce04d0695e36 --- /dev/null +++ b/app/client/src/git/components/LatestCommitInfo/index.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import LatestCommitInfoView from "./LatestCommitInfoView"; +import useLatestCommit from "git/hooks/useLatestCommit"; + +function LatestCommitInfo() { + const { latestCommit } = useLatestCommit(); + + return ( + + ); +} + +export default LatestCommitInfo; diff --git a/app/client/src/git/components/OpsModal/TabRelease.tsx b/app/client/src/git/components/OpsModal/TabRelease.tsx new file mode 100644 index 000000000000..b1ce504ac26e --- /dev/null +++ b/app/client/src/git/components/OpsModal/TabRelease.tsx @@ -0,0 +1,60 @@ +import { Button, ModalBody, ModalFooter, Text } from "@appsmith/ads"; +import LatestCommitInfo from "git/components/LatestCommitInfo"; +import ReleaseNotesInput from "git/components/ReleaseNotesInput"; +import ReleaseVersionRadioGroup from "git/components/ReleaseVersionRadioGroup"; +import { TAB_RELEASE } from "git/ee/constants/messages"; +import React, { useCallback, useState } from "react"; +import styled from "styled-components"; + +const Container = styled.div` + min-height: 360px; + overflow: unset; + padding-bottom: 4px; +`; + +const TabTitle = styled(Text)` + margin-bottom: 12px; + color: var(--ads-v2-color-fg-emphasis); +`; + +const StyledModalFooter = styled(ModalFooter)` + min-height: 52px; +`; + +function TabRelease() { + const [releaseVersion, setReleaseVersion] = useState(null); + const [releaseNotes, setReleaseNotes] = useState(null); + + const isReleaseDisabled = !releaseVersion || !releaseNotes; + + const handleClickOnRelease = useCallback(() => {}, []); + + return ( + <> + + + + {TAB_RELEASE.TITLE} + + + + + + + + + + + ); +} + +export default TabRelease; diff --git a/app/client/src/git/components/ReleaseNotesInput/index.tsx b/app/client/src/git/components/ReleaseNotesInput/index.tsx new file mode 100644 index 000000000000..8b12fc931f63 --- /dev/null +++ b/app/client/src/git/components/ReleaseNotesInput/index.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Flex, Input } from "@appsmith/ads"; +import { RELEASE_NOTES_INPUT } from "git/ee/constants/messages"; +import { noop } from "lodash"; + +interface ReleaseNotesInputProps { + onTextChange: (text: string | null) => void; + text: string | null; +} + +function ReleaseNotesInput({ + onTextChange = noop, + text = null, +}: ReleaseNotesInputProps) { + return ( + + + + ); +} + +export default ReleaseNotesInput; diff --git a/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.test.tsx b/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.test.tsx new file mode 100644 index 000000000000..4aaa89cb9343 --- /dev/null +++ b/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.test.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import ReleaseVersionRadioGroupView from "./ReleaseVersionRadioGroupView"; +import "@testing-library/jest-dom"; + +describe("ReleaseVersionRadioGroupView", () => { + const mockOnVersionChange = jest.fn(); + + const renderComponent = (props = {}) => { + return render( + , + ); + }; + + beforeEach(() => { + mockOnVersionChange.mockClear(); + }); + + it("should render correctly with initial props", () => { + const { getByRole, getByTestId } = renderComponent(); + + expect(getByTestId("t--git-release-version-title").textContent).toBe( + "Version", + ); + expect(getByTestId("t--git-release-next-version").textContent).toBe( + "1.0.1", + ); + expect(getByTestId("t--git-release-released-at").textContent).toBe( + "Last released: 1.0.0 (2023-01-01)", + ); + expect(getByRole("radio", { name: /patch/i })).toBeChecked(); + }); + + it("should change version when a different radio button is selected", () => { + const { getByRole, getByTestId } = renderComponent(); + + fireEvent.click(getByRole("radio", { name: /minor/i })); + expect(getByTestId("t--git-release-next-version").textContent).toBe( + "1.1.0", + ); + fireEvent.click(getByRole("radio", { name: /major/i })); + expect(getByTestId("t--git-release-next-version").textContent).toBe( + "2.0.0", + ); + }); + + it("should call onVersionChange with the correct version", () => { + const { getByRole } = renderComponent(); + + expect(mockOnVersionChange).toHaveBeenCalledWith("1.0.1"); // initial call with patch version + fireEvent.click(getByRole("radio", { name: /minor/i })); + expect(mockOnVersionChange).toHaveBeenCalledWith("1.1.0"); + fireEvent.click(getByRole("radio", { name: /major/i })); + expect(mockOnVersionChange).toHaveBeenCalledWith("2.0.0"); + }); + + it("should handle null values for currentVersion and releasedAt", () => { + const { getByTestId } = renderComponent({ + currentVersion: null, + releasedAt: null, + }); + + expect(getByTestId("t--git-release-released-at").textContent).toBe( + "Last released: - (-)", + ); + }); +}); diff --git a/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.tsx b/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.tsx new file mode 100644 index 000000000000..3f05c811e19d --- /dev/null +++ b/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.tsx @@ -0,0 +1,73 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Flex, Radio, RadioGroup, Tag, Text } from "@appsmith/ads"; +import { RELEASE_VERSION_RADIO_GROUP } from "git/ee/constants/messages"; +import { inc } from "semver"; +import noop from "lodash/noop"; + +type ReleaseType = "major" | "minor" | "patch" | null; + +interface ReleaseVersionRadioGroupViewProps { + currentVersion: string | null; + onVersionChange: (value: string | null) => void; + releasedAt: string | null; +} + +function ReleaseVersionRadioGroupView({ + currentVersion = null, + onVersionChange = noop, + releasedAt = null, +}: ReleaseVersionRadioGroupViewProps) { + const [releaseType, setReleaseType] = useState("patch"); + + const nextVersion = useMemo(() => { + if (!currentVersion || !releaseType) return null; + + return inc(currentVersion, releaseType); + }, [currentVersion, releaseType]); + + useEffect( + function releaseVersionChangeEffect() { + onVersionChange(nextVersion); + }, + [nextVersion, onVersionChange], + ); + + const handleRadioChange = useCallback((value: string) => { + setReleaseType(value as ReleaseType); + }, []); + + return ( + + + {RELEASE_VERSION_RADIO_GROUP.TITLE} + + + + + {nextVersion ?? "-"} + + + + Major + Minor + Patch + + + + {RELEASE_VERSION_RADIO_GROUP.LAST_RELEASED}: {currentVersion ?? "-"} ( + {releasedAt ?? "-"}) + + + ); +} + +export default ReleaseVersionRadioGroupView; diff --git a/app/client/src/git/components/ReleaseVersionRadioGroup/index.tsx b/app/client/src/git/components/ReleaseVersionRadioGroup/index.tsx new file mode 100644 index 000000000000..a117dea1dc34 --- /dev/null +++ b/app/client/src/git/components/ReleaseVersionRadioGroup/index.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import ReleaseVersionRadioGroupView from "./ReleaseVersionRadioGroupView"; +import noop from "lodash/noop"; +import useLatestCommit from "git/hooks/useLatestCommit"; + +interface ReleaseVersionRadioGroupProps { + onVersionChange: (version: string | null) => void; +} + +function ReleaseVersionRadioGroup({ + onVersionChange = noop, +}: ReleaseVersionRadioGroupProps) { + const { latestCommit } = useLatestCommit(); + + return ( + + ); +} + +export default ReleaseVersionRadioGroup; diff --git a/app/client/src/git/constants/enums.ts b/app/client/src/git/constants/enums.ts index af2fb31513a6..b979b66645ed 100644 --- a/app/client/src/git/constants/enums.ts +++ b/app/client/src/git/constants/enums.ts @@ -7,6 +7,7 @@ export enum GitArtifactType { export enum GitOpsTab { Deploy = "Deploy", Merge = "Merge", + Release = "Release", } export enum GitSettingsTab { diff --git a/app/client/src/git/ee/constants/messages.tsx b/app/client/src/git/ee/constants/messages.tsx new file mode 100644 index 000000000000..ca3e37386344 --- /dev/null +++ b/app/client/src/git/ee/constants/messages.tsx @@ -0,0 +1 @@ +export * from "../../ce/constants/messages"; diff --git a/app/client/src/git/hooks/useLatestCommit.ts b/app/client/src/git/hooks/useLatestCommit.ts new file mode 100644 index 000000000000..2d96ece707de --- /dev/null +++ b/app/client/src/git/hooks/useLatestCommit.ts @@ -0,0 +1,29 @@ +import { useGitContext } from "git/components/GitContextProvider"; +import useArtifactSelector from "./useArtifactSelector"; +import { selectLatestCommitState } from "git/store/selectors/gitArtifactSelectors"; +import { useDispatch } from "react-redux"; +import { gitArtifactActions } from "git/store/gitArtifactSlice"; +import { useCallback } from "react"; + +export default function useLatestCommit() { + const dispatch = useDispatch(); + const { artifact, artifactDef } = useGitContext(); + const artifactId = artifact?.id; + + const latestCommitState = useArtifactSelector(selectLatestCommitState); + + const fetchLatestCommit = useCallback(() => { + if (artifactDef && artifactId) { + dispatch( + gitArtifactActions.fetchLatestCommitInit({ artifactDef, artifactId }), + ); + } + }, [artifactDef, artifactId, dispatch]); + + return { + latestCommit: latestCommitState?.value ?? null, + isLatestCommitLoading: latestCommitState?.loading ?? false, + latestCommitError: latestCommitState?.error ?? null, + fetchLatestCommit, + }; +} diff --git a/app/client/src/git/requests/fetchLatestCommitRequest.ts b/app/client/src/git/requests/fetchLatestCommitRequest.ts new file mode 100644 index 000000000000..e1c2fb150650 --- /dev/null +++ b/app/client/src/git/requests/fetchLatestCommitRequest.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { AxiosPromise } from "axios"; +import type { GitArtifactType } from "git/constants/enums"; +import type { FetchLatestCommitResponse } from "./fetchLatestCommitRequest.types"; +import Api from "api/Api"; +import { GIT_BASE_URL } from "./constants"; + +export default async function fetchLatestCommitRequest( + artifactType: GitArtifactType, + branchedArtifactId: string, +): AxiosPromise { + return Api.get( + `${GIT_BASE_URL}/${artifactType}/${branchedArtifactId}/commit/latest`, + ); +} diff --git a/app/client/src/git/requests/fetchLatestCommitRequest.types.ts b/app/client/src/git/requests/fetchLatestCommitRequest.types.ts new file mode 100644 index 000000000000..3a21cc31edcc --- /dev/null +++ b/app/client/src/git/requests/fetchLatestCommitRequest.types.ts @@ -0,0 +1,13 @@ +import type { ApiResponse } from "api/types"; + +export interface FetchLatestCommitResponseData { + authorName: string; + committedAt: string; + hash: string; + message: string; + releaseTagName: string; + releasedAt: string; +} + +export type FetchLatestCommitResponse = + ApiResponse; diff --git a/app/client/src/git/store/actions/fetchLatestCommitActions.ts b/app/client/src/git/store/actions/fetchLatestCommitActions.ts new file mode 100644 index 000000000000..dfb842d657de --- /dev/null +++ b/app/client/src/git/store/actions/fetchLatestCommitActions.ts @@ -0,0 +1,38 @@ +import type { + GitArtifactErrorPayloadAction, + GitAsyncSuccessPayload, +} from "../types"; +import { createArtifactAction } from "../helpers/createArtifactAction"; +import type { FetchLatestCommitResponseData } from "git/requests/fetchLatestCommitRequest.types"; + +export interface FetchLatestCommitInitPayload { + artifactId: string; +} + +export const fetchLatestCommitInitAction = + createArtifactAction((state) => { + state.apiResponses.latestCommit.loading = true; + state.apiResponses.latestCommit.error = null; + + return state; + }); + +export const fetchLatestCommitSuccessAction = createArtifactAction< + GitAsyncSuccessPayload +>((state, action) => { + state.apiResponses.latestCommit.loading = false; + state.apiResponses.latestCommit.value = action.payload.responseData; + + return state; +}); + +export const fetchLatestCommitErrorAction = createArtifactAction( + (state, action: GitArtifactErrorPayloadAction) => { + const { error } = action.payload; + + state.apiResponses.latestCommit.loading = false; + state.apiResponses.latestCommit.error = error; + + return state; + }, +); diff --git a/app/client/src/git/store/gitArtifactSlice.ts b/app/client/src/git/store/gitArtifactSlice.ts index 25e295366c05..f5bdcac8801f 100644 --- a/app/client/src/git/store/gitArtifactSlice.ts +++ b/app/client/src/git/store/gitArtifactSlice.ts @@ -141,6 +141,11 @@ import { resetCurrentBranchAction, updateCurrentBranchAction, } from "./actions/currentBranchActions"; +import { + fetchLatestCommitErrorAction, + fetchLatestCommitInitAction, + fetchLatestCommitSuccessAction, +} from "./actions/fetchLatestCommitActions"; const initialState: GitArtifactRootReduxState = {}; @@ -205,6 +210,9 @@ export const gitArtifactSlice = createSlice({ pullError: pullErrorAction, toggleOpsModal: toggleOpsModalAction, toggleConflictErrorModal: toggleConflictErrorModalAction, + fetchLatestCommitInit: fetchLatestCommitInitAction, + fetchLatestCommitSuccess: fetchLatestCommitSuccessAction, + fetchLatestCommitError: fetchLatestCommitErrorAction, // branches fetchBranchesInit: fetchBranchesInitAction, diff --git a/app/client/src/git/store/helpers/initialState.ts b/app/client/src/git/store/helpers/initialState.ts index 0528d5945cc6..1781a28342f7 100644 --- a/app/client/src/git/store/helpers/initialState.ts +++ b/app/client/src/git/store/helpers/initialState.ts @@ -52,6 +52,11 @@ const gitArtifactInitialAPIResponses: GitArtifactAPIResponsesReduxState = { loading: false, error: null, }, + latestCommit: { + value: null, + loading: false, + error: null, + }, pull: { loading: false, error: null, diff --git a/app/client/src/git/store/selectors/gitArtifactSelectors.ts b/app/client/src/git/store/selectors/gitArtifactSelectors.ts index eb8f7aab0c49..6df75a63b210 100644 --- a/app/client/src/git/store/selectors/gitArtifactSelectors.ts +++ b/app/client/src/git/store/selectors/gitArtifactSelectors.ts @@ -88,6 +88,11 @@ export const selectCommitState = ( artifactDef: GitArtifactDef, ) => selectGitArtifact(state, artifactDef)?.apiResponses?.commit; +export const selectLatestCommitState = ( + state: GitRootState, + artifactDef: GitArtifactDef, +) => selectGitArtifact(state, artifactDef)?.apiResponses?.latestCommit; + export const selectDiscardState = ( state: GitRootState, artifactDef: GitArtifactDef, diff --git a/app/client/src/git/store/types.ts b/app/client/src/git/store/types.ts index e6d1231679fd..e62b7ec47990 100644 --- a/app/client/src/git/store/types.ts +++ b/app/client/src/git/store/types.ts @@ -19,6 +19,7 @@ import type { import type { FetchGlobalSSHKeyResponseData } from "git/requests/fetchGlobalSSHKeyRequest.types"; import type { FetchRefsResponseData } from "git/requests/fetchRefsRequest.types"; import type { GitArtifactDef } from "git/types"; +import type { FetchLatestCommitResponseData } from "git/requests/fetchLatestCommitRequest.types"; export interface GitApiError extends ApiResponseError { errorType?: string; @@ -41,6 +42,7 @@ export interface GitArtifactAPIResponsesReduxState connect: GitAsyncStateWithoutValue; status: GitAsyncState; commit: GitAsyncStateWithoutValue; + latestCommit: GitAsyncState; pull: GitAsyncStateWithoutValue; discard: GitAsyncStateWithoutValue; mergeStatus: GitAsyncState;