feat(ui): confirm saved view deletion (#3461)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-07 16:14:58 +01:00
committed by GitHub
parent bcdb0ce5e9
commit 5ee5161166
51 changed files with 2942 additions and 232 deletions
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SavedPreset } from "./saved-preset-model";
import { PresetsSidebar } from "./presets-sidebar";
@@ -15,7 +15,7 @@ const savedPreset: SavedPreset = {
isDefault: false,
};
function expectDeleteDoesNotSelect() {
async function expectDeleteDoesNotSelect() {
const onSelect = vi.fn();
const onDeleteSaved = vi.fn();
render(
@@ -35,7 +35,17 @@ function expectDeleteDoesNotSelect() {
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
expect(onDeleteSaved).toHaveBeenCalledWith(savedPreset.id);
expect(onDeleteSaved).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
const confirmation = screen.getByRole("group", { name: "Delete Kandev PRs?" });
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
expect(onDeleteSaved).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs" }));
await waitFor(() => expect(onDeleteSaved).toHaveBeenCalledWith(savedPreset.id));
expect(onDeleteSaved).toHaveBeenCalledOnce();
expect(onSelect).not.toHaveBeenCalled();
}
@@ -8,6 +8,11 @@ import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "
import type { SavedPreset } from "./saved-preset-model";
import { useTranslation } from "react-i18next";
import { SavedQueryDefaultButton } from "@/components/integrations/saved-query-default-button";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
useSavedTaskViewDeleteConfirmation,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
export type SidebarSelection = {
kind: "pr" | "issue";
@@ -157,6 +162,7 @@ function SavedSection({
defaultMutationPendingId: string | null;
}) {
const { t } = useTranslation();
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
const defaultMutationPending = defaultMutationPendingId !== null;
return (
<>
@@ -166,43 +172,19 @@ function SavedSection({
{t("github:noSavedQueriesYet")}
</div>
)}
{saved.map((s) => {
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
const accessibleDeleteLabel = defaultMutationPending
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
: deleteLabel;
return (
<PresetItem
key={s.id}
label={s.label}
Icon={IconBookmark}
active={selected.source === "saved" && selected.id === s.id}
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
trailing={
<div className="flex shrink-0 items-center">
<SavedQueryDefaultButton
label={s.label}
isDefault={s.isDefault}
disabled={defaultMutationPending}
pending={defaultMutationPendingId === s.id}
testId={`github-saved-query-default-${s.id}`}
onToggle={() => void onToggleSavedDefault(s)}
/>
<button
type="button"
disabled={defaultMutationPending}
onClick={() => onDelete(s.id)}
className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-50"
title={accessibleDeleteLabel}
aria-label={accessibleDeleteLabel}
>
<IconX className="h-4 w-4" />
</button>
</div>
}
/>
);
})}
{saved.map((preset) => (
<GitHubSavedPresetEntry
key={preset.id}
preset={preset}
active={selected.source === "saved" && selected.id === preset.id}
deletion={deletion}
defaultMutationPending={defaultMutationPending}
defaultMutationPendingForPreset={defaultMutationPendingId === preset.id}
onSelect={() => onSelect({ kind, source: "saved", id: preset.id })}
onDelete={onDelete}
onToggleDefault={() => void onToggleSavedDefault(preset)}
/>
))}
<button
type="button"
onClick={onSaveCurrent}
@@ -222,6 +204,88 @@ function SavedSection({
);
}
type GitHubSavedDeletion = {
target: SavedTaskViewDeleteTarget | null;
anchorRef: React.RefObject<HTMLButtonElement | null>;
close: () => void;
registerAnchor: (id: string, element: HTMLButtonElement | null) => void;
request: (target: SavedTaskViewDeleteTarget) => void;
};
function GitHubSavedPresetEntry({
preset,
active,
deletion,
defaultMutationPending,
defaultMutationPendingForPreset,
onSelect,
onDelete,
onToggleDefault,
}: {
preset: SavedPreset;
active: boolean;
deletion: GitHubSavedDeletion;
defaultMutationPending: boolean;
defaultMutationPendingForPreset: boolean;
onSelect: () => void;
onDelete: (id: string) => void;
onToggleDefault: () => void;
}) {
const { t } = useTranslation();
if (deletion.target?.id === preset.id) {
return (
<div className="mx-1 min-w-0 p-1">
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="inline"
open
anchorRef={deletion.anchorRef}
confirmDisabled={defaultMutationPending}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onConfirm={onDelete}
/>
</div>
);
}
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: preset.label });
const accessibleDeleteLabel = defaultMutationPending
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
: deleteLabel;
return (
<PresetItem
label={preset.label}
Icon={IconBookmark}
active={active}
onClick={onSelect}
trailing={
<div className="flex shrink-0 items-center">
<SavedQueryDefaultButton
label={preset.label}
isDefault={preset.isDefault}
disabled={defaultMutationPending}
pending={defaultMutationPendingForPreset}
testId={`github-saved-query-default-${preset.id}`}
onToggle={onToggleDefault}
/>
<button
ref={(element) => deletion.registerAnchor(preset.id, element)}
type="button"
disabled={defaultMutationPending}
onClick={() => deletion.request({ id: preset.id, label: preset.label })}
className="flex h-12 w-12 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-50"
title={accessibleDeleteLabel}
aria-label={accessibleDeleteLabel}
>
<IconX className="h-4 w-4" />
</button>
</div>
}
/>
);
}
export function PresetsSidebar({
selected,
onSelect,