From 750019b0ef8bd41ced1a15c4e22cd1fa93b14f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:40:26 +0000 Subject: [PATCH] feat(ui): localize watcher delete confirmations (#2883) --- .../azure-devops-watch-settings.test.tsx | 46 +++- .../azure-devops-watch-settings.tsx | 76 ++++--- .../jira/jira-issue-watch-table.tsx | 206 ++++++++++++++---- .../jira/jira-issue-watchers-section.test.tsx | 125 +++++++++++ .../jira/jira-issue-watchers-section.tsx | 1 - .../linear/linear-issue-watch-table.tsx | 206 ++++++++++++++---- .../linear-issue-watchers-section.test.tsx | 135 ++++++++++++ .../linear/linear-issue-watchers-section.tsx | 1 - .../sentry/sentry-issue-watch-table.test.tsx | 73 ++++++- .../sentry/sentry-issue-watch-table.tsx | 188 ++++++++++++---- .../sentry/sentry-issue-watchers-section.tsx | 1 - .../watches/watcher-delete-action.tsx | 106 +++++++++ .../tests/integrations/azure-devops.spec.ts | 11 +- .../integrations/mobile-azure-devops.spec.ts | 4 +- ...obile-watcher-delete-confirmations.spec.ts | 50 +++++ .../watcher-delete-confirmation-flow.ts | 21 ++ .../watcher-delete-confirmations.spec.ts | 44 ++++ 17 files changed, 1132 insertions(+), 162 deletions(-) create mode 100644 apps/web/components/jira/jira-issue-watchers-section.test.tsx create mode 100644 apps/web/components/linear/linear-issue-watchers-section.test.tsx create mode 100644 apps/web/components/watches/watcher-delete-action.tsx create mode 100644 apps/web/e2e/tests/integrations/mobile-watcher-delete-confirmations.spec.ts create mode 100644 apps/web/e2e/tests/integrations/watcher-delete-confirmation-flow.ts create mode 100644 apps/web/e2e/tests/integrations/watcher-delete-confirmations.spec.ts diff --git a/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx b/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx index 667ff52d2a..0fe4798d9e 100644 --- a/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx +++ b/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx @@ -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, beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "@/lib/i18n"; import type { @@ -41,7 +41,10 @@ const WORK_ITEM: AzureDevOpsWorkItemWatch = { cleanupPolicy: "never", } as AzureDevOpsWorkItemWatch; -function state(overrides: Partial = {}) { +function state( + overrides: Partial = {}, + actionOverrides: Record = {}, +) { return { workItems: [WORK_ITEM], pullRequests: [{ ...PULL_REQUEST, ...overrides }], @@ -56,6 +59,7 @@ function state(overrides: Partial = {}) { trigger: vi.fn(), previewReset: vi.fn(), reset: vi.fn(), + ...actionOverrides, }; } @@ -67,8 +71,18 @@ beforeEach(() => { afterEach(async () => { cleanup(); await i18n.changeLanguage("en"); + Object.defineProperty(window, "confirm", { configurable: true, value: undefined }); }); +function installNativeConfirm() { + const nativeConfirm = vi.fn(); + Object.defineProperty(window, "confirm", { + configurable: true, + value: nativeConfirm, + }); + return nativeConfirm; +} + describe("AzureDevOpsWatchSettings", () => { it("renders the watch summary", () => { render(); @@ -123,6 +137,34 @@ describe("AzureDevOpsWatchSettings", () => { expect(screen.getByTestId(PR_CARD).textContent).toContain("Śţàţũś: draft"); }); + it("confirms watch deletion locally without calling browser confirm", async () => { + const remove = vi.fn(); + const nativeConfirm = installNativeConfirm(); + mocks.watches.mockReturnValue(state({}, { remove })); + render(); + + fireEvent.click( + within(screen.getByTestId(PR_CARD)).getByTestId("azure-pull-request-watch-delete-pr-1"), + ); + const confirmation = await screen.findByRole("dialog", { name: "Delete this watch?" }); + expect(nativeConfirm).not.toHaveBeenCalled(); + fireEvent.click(within(confirmation).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(remove).toHaveBeenCalledWith("pull-request", "pr-1")); + }); + + it("keeps Azure reset in ResetWatchDialog while removing native pre-confirm", async () => { + const previewReset = vi.fn().mockResolvedValue({ taskCount: 2 }); + const nativeConfirm = installNativeConfirm(); + mocks.watches.mockReturnValue(state({}, { previewReset })); + render(); + + fireEvent.click(within(screen.getByTestId(PR_CARD)).getByRole("button", { name: "Reset" })); + + expect(await screen.findByTestId("reset-watch-dialog")).toBeTruthy(); + expect(previewReset).toHaveBeenCalledWith("pull-request", "pr-1"); + expect(nativeConfirm).not.toHaveBeenCalled(); + }); + it("uses typed Azure DevOps placeholders with saved-prompt references", () => { render(); diff --git a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx index f8e459b947..5934ead9dd 100644 --- a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx +++ b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { IconPlayerPlay, IconPlus, IconRefresh, IconTrash, IconX } from "@tabler/icons-react"; +import { IconPlayerPlay, IconPlus, IconRefresh, IconX } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; @@ -19,6 +19,8 @@ import { formatDateTime } from "@/lib/i18n/formats"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAzureDevOpsWatches } from "@/hooks/domains/azure-devops/use-azure-devops-watches"; import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor"; +import { ResetWatchDialog, useWatchResetController } from "@/components/watches/reset-watch-dialog"; +import { WatcherDeleteAction } from "@/components/watches/watcher-delete-action"; import { azurePullRequestWatchPlaceholders, azureWorkItemWatchPlaceholders, @@ -33,6 +35,12 @@ import type { type Kind = "work-item" | "pull-request"; +type AzureResetTarget = { + kind: Kind; + id: string; + integrationLabel: string; +}; + type Translate = (key: string, values?: Record) => string; /** Azure DevOps identity shorthand accepted by the creator/reviewer filters. */ @@ -181,15 +189,15 @@ function WatchActions({ > {t("common:reset")} - + ); } @@ -585,6 +593,18 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string initial?: AzureDevOpsWorkItemWatchInput | AzureDevOpsPullRequestWatchInput; } | null>(null); const [message, setMessage] = useState(null); + const resetCtrl = useWatchResetController({ + preview: ({ kind, id }) => watches.previewReset(kind, id), + reset: async ({ kind, id }) => { + try { + await watches.reset(kind, id); + setMessage(t("azuredevops:watchReset")); + } catch (error) { + setMessage(String(error)); + throw error; + } + }, + }); const run = async (kind: Kind, id: string) => { try { const result = await watches.trigger(kind, id); @@ -593,21 +613,12 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string setMessage(String(error)); } }; - const reset = async (kind: Kind, id: string, policy: string) => { - try { - const preview = await watches.previewReset(kind, id); - const confirmed = confirm( - t("azuredevops:resetWatchConfirm", { - policy: t(CLEANUP_POLICY_KEYS[policy] ?? CLEANUP_POLICY_KEYS.auto), - count: preview.taskCount, - }), - ); - if (!confirmed) return; - await watches.reset(kind, id); - setMessage(t("azuredevops:watchReset")); - } catch (error) { - setMessage(String(error)); - } + const reset = (kind: Kind, id: string) => { + resetCtrl.setResetting({ + kind, + id, + integrationLabel: t(KIND_NOUN_KEYS[kind]), + }); }; const toggle = async (kind: Kind, id: string, enabled: boolean) => { try { @@ -619,7 +630,6 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string } }; const remove = async (kind: Kind, id: string) => { - if (!confirm(t("azuredevops:deleteWatchConfirm"))) return; try { await watches.remove(kind, id); setMessage(t("azuredevops:watchDeleted")); @@ -672,7 +682,7 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string onEdit={() => setEditor({ kind: "pull-request", id: watch.id, initial: watch })} onToggle={() => void toggle("pull-request", watch.id, !watch.enabled)} onTrigger={() => void run("pull-request", watch.id)} - onReset={() => void reset("pull-request", watch.id, watch.cleanupPolicy)} + onReset={() => reset("pull-request", watch.id)} onDelete={() => void remove("pull-request", watch.id)} /> ))} @@ -708,12 +718,22 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string onEdit={() => setEditor({ kind: "work-item", id: watch.id, initial: watch })} onToggle={() => void toggle("work-item", watch.id, !watch.enabled)} onTrigger={() => void run("work-item", watch.id)} - onReset={() => void reset("work-item", watch.id, watch.cleanupPolicy)} + onReset={() => reset("work-item", watch.id)} onDelete={() => void remove("work-item", watch.id)} /> ))} + {resetCtrl.resetting && ( + + )} {editor && ( void; }) { const { t } = useTranslation(); + const { isFinePointer } = useResponsiveBreakpoint(); + const actionSize = isFinePointer ? "h-7 w-7" : "h-11 w-11"; return ( -
+
event.stopPropagation()} + > - - {t("jira:delete")} - + onDelete(watch.id)} + />
); } -export function JiraIssueWatchTable({ +function MobileWatchCard({ + watch, + isDirty, + showWorkspace, + workspaceName, + onEdit, + onToggleEnabled, + onTrigger, + onReset, + onDelete, +}: { + watch: JiraIssueWatch; + isDirty: boolean; + showWorkspace?: boolean; + workspaceName: (id: string) => string; + onEdit: (watch: JiraIssueWatch) => void; + onToggleEnabled: (watch: JiraIssueWatch) => void; + onTrigger: (id: string) => void; + onReset: (id: string) => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslation(); + const workspaceLabel = showWorkspace ? workspaceName(watch.workspaceId) : null; + return ( +
+ + +
+ ); +} + +function DesktopJiraIssueWatchTable({ watches, dirtyIds, showWorkspace, @@ -149,21 +214,11 @@ export function JiraIssueWatchTable({ onTrigger, onReset, onToggleEnabled, -}: JiraIssueWatchTableProps) { + workspaceName, +}: JiraIssueWatchTableProps & { workspaceName: (id: string) => string }) { const { t } = useTranslation(); - const workspaces = useAppStore((s) => s.workspaces.items); - const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; - - if (watches.length === 0) { - return ( -

- {t("jira:noJiraWatchersConfiguredCreateOne")} -

- ); - } - return ( - +
{showWorkspace && {t("common:workspace")}} @@ -182,7 +237,15 @@ export function JiraIssueWatchTable({ data-settings-dirty={dirtyIds.has(watch.id)} data-settings-dirty-level="container" data-testid={`jira-watch-row-${watch.id}`} - onClick={() => onEdit(watch)} + onClick={(event) => { + if ( + event.target instanceof Element && + event.target.closest("[data-watcher-actions]") + ) { + return; + } + onEdit(watch); + }} > {showWorkspace && ( @@ -221,3 +284,62 @@ export function JiraIssueWatchTable({
); } + +export function JiraIssueWatchTable({ + watches, + dirtyIds, + showWorkspace, + onEdit, + onDelete, + onTrigger, + onReset, + onToggleEnabled, +}: JiraIssueWatchTableProps) { + const { t } = useTranslation(); + const workspaces = useAppStore((s) => s.workspaces.items); + const { usesDesktopWorkbench = true } = useResponsiveBreakpoint(); + const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; + + if (watches.length === 0) { + return ( +

+ {t("jira:noJiraWatchersConfiguredCreateOne")} +

+ ); + } + + if (!usesDesktopWorkbench) { + return ( +
+ {watches.map((watch) => ( + + ))} +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/components/jira/jira-issue-watchers-section.test.tsx b/apps/web/components/jira/jira-issue-watchers-section.test.tsx new file mode 100644 index 0000000000..9af3f8488d --- /dev/null +++ b/apps/web/components/jira/jira-issue-watchers-section.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import type { JiraIssueWatch } from "@/lib/types/jira"; +import { JiraIssueWatchTable } from "./jira-issue-watch-table"; + +const responsive = vi.hoisted(() => ({ + isFinePointer: true, + usesDesktopWorkbench: true, +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => responsive, +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ workspaces: { items: [{ id: "ws-1", name: "Workspace One" }] } }), +})); + +function watch(overrides: Partial = {}): JiraIssueWatch { + return { + id: "jira-1", + workspaceId: "ws-1", + jql: "project = KANDEV", + workflowId: "workflow-1", + workflowStepId: "step-1", + repositoryId: "repo-1", + baseBranch: "main", + agentProfileId: "agent-1", + executorProfileId: "executor-1", + prompt: "Create task", + enabled: true, + pollIntervalSeconds: 300, + lastPolledAt: null, + ...overrides, + } as JiraIssueWatch; +} + +function renderTable(onDelete = vi.fn()) { + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); + responsive.isFinePointer = true; + responsive.usesDesktopWorkbench = true; + Object.defineProperty(window, "confirm", { configurable: true, value: undefined }); +}); + +function installNativeConfirm() { + const nativeConfirm = vi.fn(); + Object.defineProperty(window, "confirm", { + configurable: true, + value: nativeConfirm, + }); + return nativeConfirm; +} + +describe("JiraIssueWatchTable delete confirmation", () => { + it("confirms beside the delete action and never calls browser confirm", async () => { + const onDelete = vi.fn(); + const nativeConfirm = installNativeConfirm(); + renderTable(onDelete); + + fireEvent.click(screen.getByTestId("jira-watch-delete-jira-1")); + const confirmation = await screen.findByRole("dialog", { + name: "Delete this JIRA watcher?", + }); + expect(nativeConfirm).not.toHaveBeenCalled(); + + fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" })); + expect(onDelete).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + fireEvent.click(screen.getByTestId("jira-watch-delete-jira-1")); + fireEvent.click( + within(await screen.findByRole("dialog", { name: "Delete this JIRA watcher?" })).getByRole( + "button", + { name: "Delete" }, + ), + ); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith("jira-1")); + }); + + it("preserves workspace identity in install-wide mobile cards", () => { + responsive.usesDesktopWorkbench = false; + renderTable(); + + expect( + within(screen.getByTestId("jira-watch-mobile-row-jira-1")).getByText("Workspace One"), + ).toBeTruthy(); + }); + + it("keeps coarse-pointer confirmation inline with 44px actions", async () => { + responsive.isFinePointer = false; + const onDelete = vi.fn(); + renderTable(onDelete); + + fireEvent.click(screen.getByTestId("jira-watch-delete-jira-1")); + const confirmation = await screen.findByRole("group", { + name: "Delete this JIRA watcher?", + }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(within(confirmation).getByRole("button", { name: "Cancel" }).className).toContain( + "h-11", + ); + expect(within(confirmation).getByRole("button", { name: "Delete" }).className).toContain( + "min-w-11", + ); + fireEvent.click(within(confirmation).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith("jira-1")); + }); +}); diff --git a/apps/web/components/jira/jira-issue-watchers-section.tsx b/apps/web/components/jira/jira-issue-watchers-section.tsx index 3c02dbc59c..66e9509609 100644 --- a/apps/web/components/jira/jira-issue-watchers-section.tsx +++ b/apps/web/components/jira/jira-issue-watchers-section.tsx @@ -61,7 +61,6 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction const wrappedDelete = useCallback( async (w: JiraIssueWatch) => { - if (!confirm(t("jira:deleteThisJiraWatcher"))) return; try { await remove(w.id, w.workspaceId); toast({ description: t("jira:watcherDeleted"), variant: "success" }); diff --git a/apps/web/components/linear/linear-issue-watch-table.tsx b/apps/web/components/linear/linear-issue-watch-table.tsx index 6a4fff9dc7..689f1f3b44 100644 --- a/apps/web/components/linear/linear-issue-watch-table.tsx +++ b/apps/web/components/linear/linear-issue-watch-table.tsx @@ -1,12 +1,6 @@ "use client"; -import { - IconTrash, - IconRefresh, - IconPlayerPlay, - IconPlayerPause, - IconRestore, -} from "@tabler/icons-react"; +import { IconRefresh, IconPlayerPlay, IconPlayerPause, IconRestore } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; @@ -16,6 +10,8 @@ import type { LinearIssueWatch, LinearSearchFilter } from "@/lib/types/linear"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { formatRelative } from "@/lib/i18n/formats"; +import { WatcherDeleteAction } from "@/components/watches/watcher-delete-action"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; type LinearIssueWatchTableProps = { watches: LinearIssueWatch[]; @@ -81,14 +77,20 @@ function WatchActions({ onDelete: (id: string) => void; }) { const { t } = useTranslation(); + const { isFinePointer } = useResponsiveBreakpoint(); + const actionSize = isFinePointer ? "h-7 w-7" : "h-11 w-11"; return ( -
+
event.stopPropagation()} + > - - {t("linear:delete")} - + onDelete(watch.id)} + />
); } -export function LinearIssueWatchTable({ +function MobileWatchCard({ + watch, + isDirty, + showWorkspace, + workspaceName, + onEdit, + onToggleEnabled, + onTrigger, + onReset, + onDelete, +}: { + watch: LinearIssueWatch; + isDirty: boolean; + showWorkspace?: boolean; + workspaceName: (id: string) => string; + onEdit: (watch: LinearIssueWatch) => void; + onToggleEnabled: (watch: LinearIssueWatch) => void; + onTrigger: (id: string) => void; + onReset: (id: string) => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslation(); + const workspaceLabel = showWorkspace ? workspaceName(watch.workspaceId) : null; + return ( +
+ + +
+ ); +} + +function DesktopLinearIssueWatchTable({ watches, dirtyIds, showWorkspace, @@ -168,21 +233,11 @@ export function LinearIssueWatchTable({ onTrigger, onReset, onToggleEnabled, -}: LinearIssueWatchTableProps) { + workspaceName, +}: LinearIssueWatchTableProps & { workspaceName: (id: string) => string }) { const { t } = useTranslation(); - const workspaces = useAppStore((s) => s.workspaces.items); - const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; - - if (watches.length === 0) { - return ( -

- {t("linear:noLinearWatchersConfiguredCreateOne")} -

- ); - } - return ( - +
{showWorkspace && {t("common:workspace")}} @@ -201,7 +256,15 @@ export function LinearIssueWatchTable({ data-settings-dirty={dirtyIds.has(watch.id)} data-settings-dirty-level="container" data-testid={`linear-watch-row-${watch.id}`} - onClick={() => onEdit(watch)} + onClick={(event) => { + if ( + event.target instanceof Element && + event.target.closest("[data-watcher-actions]") + ) { + return; + } + onEdit(watch); + }} > {showWorkspace && ( @@ -243,3 +306,62 @@ export function LinearIssueWatchTable({
); } + +export function LinearIssueWatchTable({ + watches, + dirtyIds, + showWorkspace, + onEdit, + onDelete, + onTrigger, + onReset, + onToggleEnabled, +}: LinearIssueWatchTableProps) { + const { t } = useTranslation(); + const workspaces = useAppStore((s) => s.workspaces.items); + const { usesDesktopWorkbench = true } = useResponsiveBreakpoint(); + const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; + + if (watches.length === 0) { + return ( +

+ {t("linear:noLinearWatchersConfiguredCreateOne")} +

+ ); + } + + if (!usesDesktopWorkbench) { + return ( +
+ {watches.map((watch) => ( + + ))} +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/components/linear/linear-issue-watchers-section.test.tsx b/apps/web/components/linear/linear-issue-watchers-section.test.tsx new file mode 100644 index 0000000000..e868c5b520 --- /dev/null +++ b/apps/web/components/linear/linear-issue-watchers-section.test.tsx @@ -0,0 +1,135 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { i18n } from "@/lib/i18n"; +import type { LinearIssueWatch } from "@/lib/types/linear"; +import { LinearIssueWatchTable } from "./linear-issue-watch-table"; + +const responsive = vi.hoisted(() => ({ + isFinePointer: true, + usesDesktopWorkbench: true, +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => responsive, +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ workspaces: { items: [{ id: "ws-1", name: "Workspace One" }] } }), +})); + +function watch(overrides: Partial = {}): LinearIssueWatch { + return { + id: "linear-1", + workspaceId: "ws-1", + filter: { teamKey: "ENG" }, + workflowId: "workflow-1", + workflowStepId: "step-1", + repositoryId: "repo-1", + baseBranch: "main", + agentProfileId: "agent-1", + executorProfileId: "executor-1", + prompt: "Create task", + enabled: true, + pollIntervalSeconds: 300, + lastPolledAt: null, + ...overrides, + } as LinearIssueWatch; +} + +afterEach(async () => { + cleanup(); + await i18n.changeLanguage("en"); + responsive.isFinePointer = true; + responsive.usesDesktopWorkbench = true; + Object.defineProperty(window, "confirm", { configurable: true, value: undefined }); +}); + +function installNativeConfirm() { + const nativeConfirm = vi.fn(); + Object.defineProperty(window, "confirm", { + configurable: true, + value: nativeConfirm, + }); + return nativeConfirm; +} + +describe("LinearIssueWatchTable delete confirmation", () => { + it("uses anchored localized confirmation instead of browser confirm", async () => { + await i18n.changeLanguage("pseudo"); + const onDelete = vi.fn(); + const nativeConfirm = installNativeConfirm(); + render( + + + , + ); + + fireEvent.click(screen.getByTestId("linear-watch-delete-linear-1")); + expect(await screen.findByRole("dialog", { name: "Ďēĺēţē ţĥĩś Ĺĩńēàŕ ŵàţćĥēŕ?" })).toBeTruthy(); + expect(nativeConfirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Ćàńćēĺ" })); + expect(onDelete).not.toHaveBeenCalled(); + }); + + it("preserves workspace identity in install-wide mobile cards", () => { + responsive.usesDesktopWorkbench = false; + render( + + + , + ); + + expect( + within(screen.getByTestId("linear-watch-mobile-row-linear-1")).getByText("Workspace One"), + ).toBeTruthy(); + }); + + it("uses touch-sized inline actions for coarse pointers", async () => { + await i18n.changeLanguage("pseudo"); + responsive.isFinePointer = false; + const onDelete = vi.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByTestId("linear-watch-delete-linear-1")); + const confirmation = await screen.findByRole("group", { + name: "Ďēĺēţē ţĥĩś Ĺĩńēàŕ ŵàţćĥēŕ?", + }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByRole("button", { name: "Ćàńćēĺ" }).className).toContain("h-11"); + fireEvent.click(screen.getByRole("button", { name: "Ďēĺēţē" })); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith("linear-1")); + await waitFor(() => expect(confirmation.isConnected).toBe(false)); + }); +}); diff --git a/apps/web/components/linear/linear-issue-watchers-section.tsx b/apps/web/components/linear/linear-issue-watchers-section.tsx index ecf2ae9d8c..287f97b00f 100644 --- a/apps/web/components/linear/linear-issue-watchers-section.tsx +++ b/apps/web/components/linear/linear-issue-watchers-section.tsx @@ -58,7 +58,6 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction const wrappedDelete = useCallback( async (w: LinearIssueWatch) => { - if (!confirm(t("linear:deleteThisLinearWatcher"))) return; try { await remove(w.id, w.workspaceId); toast({ description: t("linear:watcherDeleted"), variant: "success" }); diff --git a/apps/web/components/sentry/sentry-issue-watch-table.test.tsx b/apps/web/components/sentry/sentry-issue-watch-table.test.tsx index da90e3f7f4..181277876c 100644 --- a/apps/web/components/sentry/sentry-issue-watch-table.test.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-table.test.tsx @@ -1,9 +1,15 @@ -import { cleanup, 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 { TooltipProvider } from "@kandev/ui/tooltip"; import type { SentryIssueWatch } from "@/lib/types/sentry"; import { SentryIssueWatchTable } from "./sentry-issue-watch-table"; +const responsive = vi.hoisted(() => ({ isFinePointer: true })); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => responsive, +})); + function watch(over: Partial): SentryIssueWatch { return { id: "w1", @@ -36,7 +42,7 @@ function resolveName(id: string): string { return "(unavailable)"; } -function renderTable(watches: SentryIssueWatch[]) { +function renderTable(watches: SentryIssueWatch[], onDelete = noop) { return render( id))} instanceName={resolveName} onEdit={noop} - onDelete={noop} + onDelete={onDelete} onTrigger={noop} onReset={noop} onToggleEnabled={noop} @@ -56,8 +62,18 @@ function renderTable(watches: SentryIssueWatch[]) { afterEach(() => { cleanup(); vi.clearAllMocks(); + Object.defineProperty(window, "confirm", { configurable: true, value: undefined }); }); +function installNativeConfirm() { + const nativeConfirm = vi.fn(); + Object.defineProperty(window, "confirm", { + configurable: true, + value: nativeConfirm, + }); + return nativeConfirm; +} + describe("SentryIssueWatchTable", () => { it("shows an Instance column, not a Workspace column (integration is workspace-scoped)", () => { renderTable([watch({ id: "w1" })]); @@ -95,4 +111,55 @@ describe("SentryIssueWatchTable", () => { ]); expect(screen.getByText("org:acme · project:frontend,backend")).toBeTruthy(); }); + + it("anchors delete confirmation to the row action without using native confirm", async () => { + const onDelete = vi.fn(); + const nativeConfirm = installNativeConfirm(); + renderTable([watch({ id: "w1" })], onDelete); + + fireEvent.click(screen.getByTestId("sentry-watch-delete-w1")); + + const confirmation = await screen.findByRole("dialog", { + name: "Delete this Sentry watcher?", + }); + expect(nativeConfirm).not.toHaveBeenCalled(); + fireEvent.click(within(confirmation).getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(onDelete).toHaveBeenCalledWith("w1", "ws-1")); + }); + + it("morphs coarse-pointer delete into one 44px inline confirmation row", async () => { + responsive.isFinePointer = false; + const onDelete = vi.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByTestId("sentry-watch-delete-w1")); + + const confirmation = await screen.findByRole("group", { + name: "Delete this Sentry watcher?", + }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(within(confirmation).getByRole("button", { name: "Cancel" }).className).toContain( + "h-11", + ); + expect(within(confirmation).getByRole("button", { name: "Delete" }).className).toContain( + "min-w-11", + ); + + fireEvent.click(within(confirmation).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith("w1", "ws-1")); + }); }); diff --git a/apps/web/components/sentry/sentry-issue-watch-table.tsx b/apps/web/components/sentry/sentry-issue-watch-table.tsx index 5e3c1bc320..f33d6934bd 100644 --- a/apps/web/components/sentry/sentry-issue-watch-table.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-table.tsx @@ -1,12 +1,6 @@ "use client"; -import { - IconTrash, - IconRefresh, - IconPlayerPlay, - IconPlayerPause, - IconRestore, -} from "@tabler/icons-react"; +import { IconRefresh, IconPlayerPlay, IconPlayerPause, IconRestore } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; @@ -15,6 +9,8 @@ import type { SentryIssueWatch, SentrySearchFilter } from "@/lib/types/sentry"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { formatRelative } from "@/lib/i18n/formats"; +import { WatcherDeleteAction } from "@/components/watches/watcher-delete-action"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; type SentryIssueWatchTableProps = { watches: SentryIssueWatch[]; @@ -89,14 +85,20 @@ function WatchActions({ onDelete: (id: string, workspaceId: string) => void; }) { const { t } = useTranslation(); + const { isFinePointer } = useResponsiveBreakpoint(); + const actionSize = isFinePointer ? "h-7 w-7" : "h-11 w-11"; return ( -
+
event.stopPropagation()} + > - - {t("sentry:delete")} - + onDelete(watch.id, watch.workspaceId)} + />
); } -export function SentryIssueWatchTable({ +function MobileWatchCard({ + watch, + isDirty, + instanceName, + onEdit, + onToggleEnabled, + onTrigger, + onReset, + onDelete, +}: { + watch: SentryIssueWatch; + isDirty: boolean; + instanceName: (sentryInstanceId: string) => string; + onEdit: (watch: SentryIssueWatch) => void; + onToggleEnabled: (watch: SentryIssueWatch) => void; + onTrigger: (id: string, workspaceId: string) => void; + onReset: (id: string, workspaceId: string) => void; + onDelete: (id: string, workspaceId: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +} + +function DesktopSentryIssueWatchTable({ watches, dirtyIds, instanceName, @@ -178,16 +236,8 @@ export function SentryIssueWatchTable({ onToggleEnabled, }: SentryIssueWatchTableProps) { const { t } = useTranslation(); - if (watches.length === 0) { - return ( -

- {t("sentry:noWatchersConfigured")} -

- ); - } - return ( - +
{t("sentry:instance")} @@ -206,7 +256,15 @@ export function SentryIssueWatchTable({ data-settings-dirty={dirtyIds.has(watch.id)} data-settings-dirty-level="container" data-testid={`sentry-watch-row-${watch.id}`} - onClick={() => onEdit(watch)} + onClick={(event) => { + if ( + event.target instanceof Element && + event.target.closest("[data-watcher-actions]") + ) { + return; + } + onEdit(watch); + }} > {instanceName(watch.sentryInstanceId)} @@ -246,3 +304,57 @@ export function SentryIssueWatchTable({
); } + +export function SentryIssueWatchTable({ + watches, + dirtyIds, + instanceName, + onEdit, + onDelete, + onTrigger, + onReset, + onToggleEnabled, +}: SentryIssueWatchTableProps) { + const { t } = useTranslation(); + const { usesDesktopWorkbench = true } = useResponsiveBreakpoint(); + if (watches.length === 0) { + return ( +

+ {t("sentry:noWatchersConfigured")} +

+ ); + } + + if (!usesDesktopWorkbench) { + return ( +
+ {watches.map((watch) => ( + + ))} +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/components/sentry/sentry-issue-watchers-section.tsx b/apps/web/components/sentry/sentry-issue-watchers-section.tsx index 57579804d5..7360df6a27 100644 --- a/apps/web/components/sentry/sentry-issue-watchers-section.tsx +++ b/apps/web/components/sentry/sentry-issue-watchers-section.tsx @@ -58,7 +58,6 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction const wrappedDelete = useCallback( async (id: string, workspaceId: string) => { - if (!confirm(t("sentry:deleteThisSentryWatcher"))) return; try { await remove(id, workspaceId); toast({ description: t("sentry:watcherDeleted"), variant: "success" }); diff --git a/apps/web/components/watches/watcher-delete-action.tsx b/apps/web/components/watches/watcher-delete-action.tsx new file mode 100644 index 0000000000..74c651770b --- /dev/null +++ b/apps/web/components/watches/watcher-delete-action.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useRef, useState, type ReactNode } from "react"; +import { IconTrash } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover"; +import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions"; + +type WatcherDeleteActionProps = { + title: ReactNode; + cancelLabel: ReactNode; + confirmLabel: ReactNode; + ariaLabel: string; + tooltipLabel?: ReactNode; + triggerTestId?: string; + confirmTestId?: string; + testId?: string; + onConfirm: () => void | Promise; +}; + +/** + * Local delete confirmation used by watcher rows/cards. + * + * Fine pointers keep the initiating delete button as the popover anchor. + * Coarse pointers replace the action region with one inline cancel/delete + * group so a phone never stacks a second overlay on top of its row. + */ +export function WatcherDeleteAction({ + title, + cancelLabel, + confirmLabel, + ariaLabel, + tooltipLabel, + triggerTestId, + confirmTestId, + testId = "watcher-delete-confirmation", + onConfirm, +}: WatcherDeleteActionProps) { + const { isFinePointer } = useResponsiveBreakpoint(); + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + + if (!isFinePointer && open) { + return ( + setOpen(false)} + onClose={() => setOpen(false)} + onConfirm={onConfirm} + /> + ); + } + + const trigger = ( + + ); + + return ( + <> + {isFinePointer && tooltipLabel ? ( + + {trigger} + {tooltipLabel} + + ) : ( + trigger + )} + {isFinePointer ? ( + setOpen(false)} + onConfirm={onConfirm} + /> + ) : null} + + ); +} diff --git a/apps/web/e2e/tests/integrations/azure-devops.spec.ts b/apps/web/e2e/tests/integrations/azure-devops.spec.ts index 0c089d7246..d2c9296cd4 100644 --- a/apps/web/e2e/tests/integrations/azure-devops.spec.ts +++ b/apps/web/e2e/tests/integrations/azure-devops.spec.ts @@ -301,11 +301,16 @@ test("connects and browses Azure work items, PRs, and feedback", async ({ await expect(workItemWatch.getByText("Disabled")).toBeVisible(); await workItemWatch.getByRole("button", { name: "Enable" }).click(); await expect(workItemWatch.getByText("Enabled")).toBeVisible(); - await testPage.once("dialog", (dialog) => dialog.accept()); await workItemWatch.getByRole("button", { name: "Reset" }).click(); + const resetDialog = testPage.getByTestId("reset-watch-dialog"); + await expect(resetDialog).toBeVisible(); + await resetDialog.getByTestId("reset-watch-dialog-confirm").click(); await expect(testPage.getByText("Watch reset.")).toBeVisible(); - await testPage.once("dialog", (dialog) => dialog.accept()); - await workItemWatch.getByRole("button", { name: "Delete" }).click(); + await workItemWatch.getByRole("button", { name: "Delete this watch?" }).click(); + await testPage + .getByTestId("watcher-delete-confirmation") + .getByRole("button", { name: "Delete" }) + .click(); await expect(workItemWatch).toHaveCount(0); await testPage.getByTestId("azure-add-pull-request-watch").click(); diff --git a/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts b/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts index 076ed14511..d0bf7ba496 100644 --- a/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts +++ b/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts @@ -254,7 +254,9 @@ test("mobile board opens a focused column editor without horizontal overflow", a await testPage.getByTestId("azure-watch-editor-close").click(); await workItemWatch.getByRole("button", { name: "Run now" }).click(); await expect(testPage.getByText(/\d+ new match/)).toBeVisible(); - await testPage.once("dialog", (dialog) => dialog.accept()); await workItemWatch.getByRole("button", { name: "Reset" }).click(); + const resetDialog = testPage.getByTestId("reset-watch-dialog"); + await expect(resetDialog).toBeVisible(); + await resetDialog.getByTestId("reset-watch-dialog-confirm").tap(); await expect(testPage.getByText("Watch reset.")).toBeVisible(); }); diff --git a/apps/web/e2e/tests/integrations/mobile-watcher-delete-confirmations.spec.ts b/apps/web/e2e/tests/integrations/mobile-watcher-delete-confirmations.spec.ts new file mode 100644 index 0000000000..242de887ec --- /dev/null +++ b/apps/web/e2e/tests/integrations/mobile-watcher-delete-confirmations.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "../../fixtures/test-base"; +import { waitForHttp } from "../../helpers/causal-waits"; +import { seedLinearWatcher } from "./watcher-delete-confirmation-flow"; + +test.describe("watcher delete confirmations (mobile)", () => { + test("keeps Linear delete inline, touch-sized, and inside the viewport", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + const watch = await seedLinearWatcher(apiClient, seedData); + let nativeDialogSeen = false; + testPage.on("dialog", (dialog) => { + nativeDialogSeen = true; + void dialog.dismiss(); + }); + + const watchesLoaded = waitForHttp(testPage, "GET", /^\/api\/v1\/linear\/watches\/issue$/); + await testPage.goto("/settings/integrations/linear"); + await watchesLoaded; + const row = testPage.getByTestId(`linear-watch-mobile-row-${watch.id}`); + await expect(row).toBeVisible(); + await expect(row).toContainText("E2E Workspace"); + const trigger = row.getByTestId(`linear-watch-delete-${watch.id}`); + await expect(trigger).toHaveCSS("height", "44px"); + + await trigger.tap(); + const confirmation = row.getByTestId("watcher-delete-confirmation"); + await expect(confirmation).toBeVisible(); + await expect(testPage.getByRole("dialog")).toHaveCount(0); + await expect(confirmation.getByRole("button", { name: "Cancel" })).toHaveCSS("height", "44px"); + await expect(confirmation.getByRole("button", { name: "Delete" })).toHaveCSS("height", "44px"); + await prCapture.screenshot("watcher-delete-confirmation-mobile", { + caption: "Mobile inline watcher delete confirmation with touch-sized actions", + }); + + const viewportFits = await testPage.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ); + expect(viewportFits).toBe(true); + + await confirmation.getByRole("button", { name: "Cancel" }).tap(); + await expect(confirmation).toBeHidden(); + await trigger.tap(); + await row.getByTestId("linear-watch-delete-confirm").tap(); + await expect(row).toHaveCount(0); + expect(nativeDialogSeen).toBe(false); + }); +}); diff --git a/apps/web/e2e/tests/integrations/watcher-delete-confirmation-flow.ts b/apps/web/e2e/tests/integrations/watcher-delete-confirmation-flow.ts new file mode 100644 index 0000000000..c4cecdb222 --- /dev/null +++ b/apps/web/e2e/tests/integrations/watcher-delete-confirmation-flow.ts @@ -0,0 +1,21 @@ +import type { ApiClient } from "../../helpers/api-client"; +import type { SeedData } from "../../fixtures/test-base"; + +export async function seedLinearWatcher(apiClient: ApiClient, seedData: SeedData) { + await apiClient.setLinearConfig({ + secret: "lin_api_watcher_delete", + workspaceId: seedData.workspaceId, + }); + await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]); + await apiClient.waitForIntegrationAuthHealthy("linear", { + workspaceId: seedData.workspaceId, + }); + return apiClient.createLinearIssueWatch({ + workspaceId: seedData.workspaceId, + workflowId: seedData.workflowId, + workflowStepId: seedData.startStepId, + agentProfileId: seedData.agentProfileId, + executorProfileId: seedData.worktreeExecutorProfileId, + filter: { teamKey: "ENG" }, + }); +} diff --git a/apps/web/e2e/tests/integrations/watcher-delete-confirmations.spec.ts b/apps/web/e2e/tests/integrations/watcher-delete-confirmations.spec.ts new file mode 100644 index 0000000000..fe2f8795a9 --- /dev/null +++ b/apps/web/e2e/tests/integrations/watcher-delete-confirmations.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from "../../fixtures/test-base"; +import { waitForHttp } from "../../helpers/causal-waits"; +import { seedLinearWatcher } from "./watcher-delete-confirmation-flow"; + +test.describe("watcher delete confirmations", () => { + test("uses an anchored local confirmation for a Linear watcher", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + const watch = await seedLinearWatcher(apiClient, seedData); + let nativeDialogSeen = false; + testPage.on("dialog", (dialog) => { + nativeDialogSeen = true; + void dialog.dismiss(); + }); + + const watchesLoaded = waitForHttp(testPage, "GET", /^\/api\/v1\/linear\/watches\/issue$/); + await testPage.goto("/settings/integrations/linear"); + await watchesLoaded; + const row = testPage.getByTestId(`linear-watch-row-${watch.id}`); + await expect(row).toBeVisible(); + const trigger = row.getByTestId(`linear-watch-delete-${watch.id}`); + + await trigger.click(); + const confirmation = testPage.getByTestId("watcher-delete-confirmation"); + await expect(confirmation).toBeVisible(); + await expect( + testPage.getByRole("dialog", { name: "Delete this Linear watcher?" }), + ).toBeVisible(); + await prCapture.screenshot("watcher-delete-confirmation-desktop", { + caption: "Desktop watcher delete confirmation anchored to the action", + }); + await confirmation.getByRole("button", { name: "Cancel" }).click(); + await expect(confirmation).toBeHidden(); + await expect(row).toBeVisible(); + + await trigger.click(); + await testPage.getByTestId("linear-watch-delete-confirm").click(); + await expect(row).toHaveCount(0); + expect(nativeDialogSeen).toBe(false); + }); +});