Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/+8592.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a delete branch modal with the option to also delete the branch from Git
3 changes: 0 additions & 3 deletions frontend/app/.betterer.results
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ exports[`fix ts error`] = {
"src/entities/branches/ui/branch-selector.tsx:2882426840": [
[5, 28, 40, "tsc: Cannot find module \'@/shared/api/graphql/generated/graphql\' or its corresponding type declarations.", "3357561780"]
],
"src/entities/branches/ui/branches-table/branches-toolbar.tsx:888562084": [
[102, 26, 20, "tsc: Object is possibly \'undefined\'.", "3437489383"]
],
"src/entities/branches/ui/branches-table/get-branch-table-columns.tsx:2005835156": [
[44, 44, 6, "tsc: Type \'unknown\' is not assignable to type \'BranchStatus\'.", "2141063537"]
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { graphql, type VariablesOf } from "gql.tada";
import graphqlClient from "@/shared/api/graphql/graphqlClientApollo";

const BRANCH_DELETE = graphql(`
mutation BRANCH_DELETE($name: String) {
BranchDelete(data: { name: $name }) {
mutation BRANCH_DELETE($name: String, $deleteFromGit: Boolean) {
BranchDelete(data: { name: $name, delete_from_git: $deleteFromGit }) {
ok
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { deleteBranchFromApi } from "@/entities/branches/api/delete-branch-from-

export type DeleteBranchesFromApiParams = {
names: string[];
deleteFromGit?: boolean;
};

export type DeleteBranchesFromApiResult = {
Expand All @@ -13,7 +14,7 @@ export async function deleteBranchesFromApi(
params: DeleteBranchesFromApiParams
): Promise<DeleteBranchesFromApiResult> {
const results = await Promise.allSettled(
params.names.map((name) => deleteBranchFromApi({ name }))
params.names.map((name) => deleteBranchFromApi({ name, deleteFromGit: params.deleteFromGit }))
);

return params.names.reduce<DeleteBranchesFromApiResult>(
Expand Down
5 changes: 3 additions & 2 deletions frontend/app/src/entities/branches/domain/delete-branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { deleteBranchFromApi } from "@/entities/branches/api/delete-branch-from-

export type DeleteBranchParams = {
name: string;
deleteFromGit?: boolean;
};

export type DeleteBranch = (params: DeleteBranchParams) => Promise<string | null>;

export const deleteBranch: DeleteBranch = async ({ name }) => {
const { data, errors } = await deleteBranchFromApi({ name });
export const deleteBranch: DeleteBranch = async ({ name, deleteFromGit }) => {
const { data, errors } = await deleteBranchFromApi({ name, deleteFromGit });

if (errors) {
throw new Error(errors.map((e) => e.message).join("; "));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { deleteBranchesFromApi } from "@/entities/branches/api/delete-branches-f

export type DeleteBranchesParams = {
names: string[];
deleteFromGit?: boolean;
};

export type DeleteBranchesResult = {
Expand All @@ -11,6 +12,6 @@ export type DeleteBranchesResult = {

export type DeleteBranches = (params: DeleteBranchesParams) => Promise<DeleteBranchesResult>;

export const deleteBranches: DeleteBranches = async ({ names }) => {
return deleteBranchesFromApi({ names });
export const deleteBranches: DeleteBranches = async ({ names, deleteFromGit }) => {
return deleteBranchesFromApi({ names, deleteFromGit });
};
18 changes: 8 additions & 10 deletions frontend/app/src/entities/branches/ui/branch-delete-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { useState } from "react";
import { useNavigate } from "react-router";

import { constructPath, getCurrentQsp } from "@/shared/api/rest/fetch";
import { ModalDelete } from "@/shared/components/modals/modal-delete";
import { Button } from "@/shared/components/ui/button";
import { QSP } from "@/shared/config/qsp";

import { useAuth } from "@/entities/authentication/ui/useAuth";
import type { BranchDetail } from "@/entities/branches/domain/branch.mappers";
import { DELETE_BRANCH_SCOPE, ModalDeleteBranch } from "@/entities/branches/ui/modal-delete-branch";
import { useDeleteBranchMutation } from "@/entities/branches/ui/queries/delete-branch.mutation";

type BranchDeleteButtonProps = {
Expand All @@ -30,15 +30,13 @@ export const BranchDeleteButton = ({ branch }: BranchDeleteButtonProps) => {
<Icon icon="mdi:delete-outline" className="ml-2 text-base" aria-hidden="true" />
</Button>

<ModalDelete
title="Delete"
description={
<>
Are you sure you want to remove the branch <b>`{branch.name}`</b>?
</>
}
onDelete={async () => {
await deleteBranch({ name: branch.name });
<ModalDeleteBranch
branches={[branch]}
onDelete={async (scope) => {
await deleteBranch({
name: branch.name,
deleteFromGit: scope === DELETE_BRANCH_SCOPE.LOCAL_AND_REMOTE,
});

const queryStringParams = getCurrentQsp();
const isDeletedBranchSelected = queryStringParams.get(QSP.BRANCH) === branch.name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { useNavigate } from "react-router";
import { toast } from "react-toastify";

import { constructPath, getCurrentQsp } from "@/shared/api/rest/fetch";
import { ModalDelete } from "@/shared/components/modals/modal-delete";
import { ALERT_TYPES, Alert } from "@/shared/components/ui/alert";
import { QSP } from "@/shared/config/qsp";
import { classNames } from "@/shared/utils/common";

import type { BranchListItem } from "@/entities/branches/domain/branch.mappers";
import { DELETE_BRANCH_SCOPE, ModalDeleteBranch } from "@/entities/branches/ui/modal-delete-branch";
import { useDeleteBranchesMutation } from "@/entities/branches/ui/queries/delete-branches.mutation";
import { ToolbarButton } from "@/entities/nodes/object/ui/object-table/toolbar/toolbar-button";
import { ToolbarDivider } from "@/entities/nodes/object/ui/object-table/toolbar/toolbar-divider";
Expand All @@ -27,11 +27,11 @@ export function BranchesToolbar({ selectedBranches, onClose }: BranchesToolbarPr

const deletableBranches = selectedBranches.filter((branch) => !branch.is_default);

const handleDelete = async () => {
const handleDelete = async (deleteFromGit: boolean) => {
const branchNames = deletableBranches.map((branch) => branch.name);

try {
const result = await deleteBranches({ names: branchNames });
const result = await deleteBranches({ names: branchNames, deleteFromGit });

if (result.failed.length > 0) {
toast(
Expand Down Expand Up @@ -94,23 +94,11 @@ export function BranchesToolbar({ selectedBranches, onClose }: BranchesToolbarPr
</ToolbarButton>
</div>

<ModalDelete
title="Delete"
description={
deletableBranches.length === 1 ? (
<>
Are you sure you want to remove the branch
<br /> <b>`{deletableBranches[0].name}`</b>?
</>
) : (
<>
Are you sure you want to remove {deletableBranches.length} branches?
<br />
<b>{deletableBranches.map((b) => b.name).join(", ")}</b>
</>
)
}
onDelete={handleDelete}
<ModalDeleteBranch
branches={deletableBranches}
onDelete={async (scope) => {
await handleDelete(scope === DELETE_BRANCH_SCOPE.LOCAL_AND_REMOTE);
}}
isOpen={showDeleteModal}
onOpenChange={setShowDeleteModal}
isLoading={isDeleting}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useState } from "react";
import { Link } from "react-router";

import { constructPath } from "@/shared/api/rest/fetch";
import { ModalDelete } from "@/shared/components/modals/modal-delete";
import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
Expand All @@ -15,6 +14,7 @@ import { Tooltip } from "@/shared/components/ui/tooltip";

import { useAuth } from "@/entities/authentication/ui/useAuth";
import type { BranchListItem } from "@/entities/branches/domain/branch.mappers";
import { DELETE_BRANCH_SCOPE, ModalDeleteBranch } from "@/entities/branches/ui/modal-delete-branch";
import { useDeleteBranchMutation } from "@/entities/branches/ui/queries/delete-branch.mutation";
import { StickyRightCell } from "@/entities/nodes/object/ui/object-table/cells/style";

Expand Down Expand Up @@ -71,16 +71,13 @@ export function BranchActionsCell({ branch }: BranchActionsCellProps) {
</DropdownMenu>
</StickyRightCell>

<ModalDelete
title="Delete"
description={
<>
Are you sure you want to remove the branch
<br /> <b>`{branch.name}`</b>?
</>
}
onDelete={async () => {
await deleteBranch({ name: branch.name });
<ModalDeleteBranch
branches={[branch]}
onDelete={async (scope) => {
await deleteBranch({
name: branch.name,
deleteFromGit: scope === DELETE_BRANCH_SCOPE.LOCAL_AND_REMOTE,
});
setShowDeleteModal(false);
}}
isOpen={showDeleteModal}
Expand Down
151 changes: 151 additions & 0 deletions frontend/app/src/entities/branches/ui/modal-delete-branch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, test, vi } from "vitest";

import { useObjectsCount } from "@/entities/nodes/object/ui/queries/get-objects-count.query";

import { render } from "../../../../tests/components/render";
import { DELETE_BRANCH_SCOPE, ModalDeleteBranch } from "./modal-delete-branch";

vi.mock("@/entities/nodes/object/ui/queries/get-objects-count.query");

describe("ModalDeleteBranch", () => {
const useObjectsCountMock = vi.mocked(useObjectsCount);
const defaultProps = {
isOpen: true,
onOpenChange: vi.fn(),
onDelete: vi.fn(),
isLoading: false,
};

test("shows scope choice when branch has sync_with_git and repositories exist", async () => {
// GIVEN
useObjectsCountMock.mockReturnValue({ data: 1, isLoading: false } as ReturnType<
typeof useObjectsCount
>);
const branches = [{ name: "feature-1", sync_with_git: true }];

// WHEN
const component = await render(<ModalDeleteBranch {...defaultProps} branches={branches} />);

// THEN
await expect
.element(component.getByRole("radiogroup", { name: "Deletion scope" }))
.toBeVisible();
});

test("does not show scope choice when branch has no sync_with_git", async () => {
// GIVEN
useObjectsCountMock.mockReturnValue({ data: 0, isLoading: false } as ReturnType<
typeof useObjectsCount
>);
const branches = [{ name: "feature-1", sync_with_git: false }];

// WHEN
const component = await render(<ModalDeleteBranch {...defaultProps} branches={branches} />);

// THEN
expect(component.getByRole("radiogroup", { name: "Deletion scope" }).query()).toBeNull();
});

test("defaults to LOCAL scope when modal opens", async () => {
// GIVEN
useObjectsCountMock.mockReturnValue({ data: 1, isLoading: false } as ReturnType<
typeof useObjectsCount
>);
const branches = [{ name: "feature-1", sync_with_git: true }];

// WHEN
const component = await render(<ModalDeleteBranch {...defaultProps} branches={branches} />);

// THEN
const localRadio = component.getByRole("radio", { name: /Local only/i });
await expect.element(localRadio).toBeChecked();
});

test("calls onDelete with LOCAL scope when clicking Delete with default selection", async () => {
// GIVEN
useObjectsCountMock.mockReturnValue({ data: 1, isLoading: false } as ReturnType<
typeof useObjectsCount
>);
const onDelete = vi.fn();
const branches = [{ name: "feature-1", sync_with_git: true }];

// WHEN
const component = await render(
<ModalDeleteBranch {...defaultProps} branches={branches} onDelete={onDelete} />
);
await component.getByTestId("modal-delete-confirm").click();

// THEN
expect(onDelete).toHaveBeenCalledWith(DELETE_BRANCH_SCOPE.LOCAL);
});

test("calls onDelete with LOCAL_AND_REMOTE scope after selecting that option", async () => {
// GIVEN
useObjectsCountMock.mockReturnValue({ data: 1, isLoading: false } as ReturnType<
typeof useObjectsCount
>);
const onDelete = vi.fn();
const branches = [{ name: "feature-1", sync_with_git: true }];

// WHEN
const component = await render(
<ModalDeleteBranch {...defaultProps} branches={branches} onDelete={onDelete} />
);
await component.getByText("Local and remote").click();
await component.getByTestId("modal-delete-confirm").click();

// THEN
expect(onDelete).toHaveBeenCalledWith(DELETE_BRANCH_SCOPE.LOCAL_AND_REMOTE);
});

test("shows scope choice for mixed branches and handles both scope selections", async () => {
// GIVEN
useObjectsCountMock.mockReturnValue({ data: 1, isLoading: false } as ReturnType<
typeof useObjectsCount
>);
const onDelete = vi.fn();
const branches = [
{ name: "feature-1", sync_with_git: true },
{ name: "feature-2", sync_with_git: false },
];

// WHEN
const component = await render(
<ModalDeleteBranch {...defaultProps} branches={branches} onDelete={onDelete} />
);

// THEN - radiogroup is visible and default is LOCAL
await expect
.element(component.getByRole("radiogroup", { name: "Deletion scope" }))
.toBeVisible();
await expect.element(component.getByRole("radio", { name: /Local only/i })).toBeChecked();

// Confirm with default LOCAL selection
await component.getByTestId("modal-delete-confirm").click();
expect(onDelete).toHaveBeenCalledWith(DELETE_BRANCH_SCOPE.LOCAL);

// Select "Local and remote" and confirm
onDelete.mockClear();
await component.getByText("Local and remote").click();
await component.getByTestId("modal-delete-confirm").click();
expect(onDelete).toHaveBeenCalledWith(DELETE_BRANCH_SCOPE.LOCAL_AND_REMOTE);
});

test("calls onDelete with LOCAL scope directly when showScopeChoice is false", async () => {
// GIVEN
useObjectsCountMock.mockReturnValue({ data: 0, isLoading: false } as ReturnType<
typeof useObjectsCount
>);
const onDelete = vi.fn();
const branches = [{ name: "feature-1", sync_with_git: false }];

// WHEN
const component = await render(
<ModalDeleteBranch {...defaultProps} branches={branches} onDelete={onDelete} />
);
await component.getByTestId("modal-delete-confirm").click();

// THEN
expect(onDelete).toHaveBeenCalledWith(DELETE_BRANCH_SCOPE.LOCAL);
});
Comment thread
pa-lem marked this conversation as resolved.
});
Loading
Loading