feat(ui): localize watcher delete confirmations (#2883)

This commit is contained in:
José Almeida
2026-08-22 22:40:26 +00:00
committed by GitHub
parent f63a60e426
commit 750019b0ef
17 changed files with 1132 additions and 162 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, 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<AzureDevOpsPullRequestWatch> = {}) {
function state(
overrides: Partial<AzureDevOpsPullRequestWatch> = {},
actionOverrides: Record<string, unknown> = {},
) {
return {
workItems: [WORK_ITEM],
pullRequests: [{ ...PULL_REQUEST, ...overrides }],
@@ -56,6 +59,7 @@ function state(overrides: Partial<AzureDevOpsPullRequestWatch> = {}) {
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(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
@@ -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(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
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(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
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(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
@@ -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, unknown>) => string;
/** Azure DevOps identity shorthand accepted by the creator/reviewer filters. */
@@ -181,15 +189,15 @@ function WatchActions({
>
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="min-h-11 cursor-pointer text-destructive"
onClick={onDelete}
>
<IconTrash className="h-4 w-4" /> {t("azuredevops:delete")}
</Button>
<WatcherDeleteAction
title={t("azuredevops:deleteWatchConfirm")}
cancelLabel={t("common:cancel")}
confirmLabel={t("azuredevops:delete")}
ariaLabel={t("azuredevops:deleteWatchConfirm")}
triggerTestId={`azure-${kind}-watch-delete-${watch.id}`}
confirmTestId="azure-watch-delete-confirm"
onConfirm={onDelete}
/>
</div>
);
}
@@ -585,6 +593,18 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string
initial?: AzureDevOpsWorkItemWatchInput | AzureDevOpsPullRequestWatchInput;
} | null>(null);
const [message, setMessage] = useState<string | null>(null);
const resetCtrl = useWatchResetController<AzureResetTarget>({
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)}
/>
))}
</div>
</SettingsSection>
{resetCtrl.resetting && (
<ResetWatchDialog
open
onOpenChange={resetCtrl.onOpenChange}
integrationLabel={resetCtrl.resetting.integrationLabel}
previewLoader={resetCtrl.previewLoader}
requirePreviewSuccess
onConfirm={resetCtrl.confirmReset}
/>
)}
{editor && (
<WatchEditor
key={`${editor.kind}:${editor.id ?? "new"}`}
@@ -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 { JiraIssueWatch } from "@/lib/types/jira";
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 JiraIssueWatchTableProps = {
watches: JiraIssueWatch[];
@@ -60,14 +56,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 (
<div className="flex items-center justify-end gap-1">
<div
className="flex items-center justify-end gap-1"
data-watcher-actions
onClick={(event) => event.stopPropagation()}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
data-settings-dirty={isDirty}
data-testid={`jira-watch-enabled-${watch.id}`}
onClick={(e) => {
@@ -89,7 +91,7 @@ function WatchActions({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
onClick={(e) => {
e.stopPropagation();
// Toast is fired by the parent's wrappedTrigger on completion —
@@ -107,7 +109,7 @@ function WatchActions({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
data-testid="watch-reset-button"
aria-label={t("jira:resetWatch")}
onClick={(e) => {
@@ -120,27 +122,90 @@ function WatchActions({
</TooltipTrigger>
<TooltipContent>{t("common:reset")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(watch.id);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("jira:delete")}</TooltipContent>
</Tooltip>
<WatcherDeleteAction
title={t("jira:deleteThisJiraWatcher")}
cancelLabel={t("common:cancel")}
confirmLabel={t("jira:delete")}
ariaLabel={t("jira:deleteThisJiraWatcher")}
tooltipLabel={t("jira:delete")}
triggerTestId={`jira-watch-delete-${watch.id}`}
confirmTestId="jira-watch-delete-confirm"
onConfirm={() => onDelete(watch.id)}
/>
</div>
);
}
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 (
<div
className="min-w-0 space-y-3 border-b px-1 py-4 last:border-b-0"
data-settings-dirty={isDirty}
data-testid={`jira-watch-mobile-row-${watch.id}`}
>
<button
type="button"
className="block w-full min-w-0 text-left cursor-pointer"
onClick={() => onEdit(watch)}
>
{workspaceLabel && (
<span
className="mb-1 block truncate text-xs text-muted-foreground"
title={workspaceLabel}
>
{workspaceLabel}
</span>
)}
<span className="block truncate font-mono text-sm" title={watch.jql}>
{watch.jql}
</span>
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant={watch.enabled ? "default" : "secondary"}>
{watch.enabled ? t("jira:active") : t("jira:paused")}
</Badge>
<span>
{t("jira:intervalMinutes", {
count: Math.round(watch.pollIntervalSeconds / 60),
})}
</span>
<span>{formatLastPolled(t, watch.lastPolledAt)}</span>
</span>
</button>
<WatchActions
watch={watch}
isDirty={isDirty}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete}
/>
</div>
);
}
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 (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("jira:noJiraWatchersConfiguredCreateOne")}
</p>
);
}
return (
<Table>
<Table className="min-w-[680px]">
<TableHeader>
<TableRow>
{showWorkspace && <TableHead>{t("common:workspace")}</TableHead>}
@@ -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 && (
<TableCell className="text-xs text-muted-foreground">
@@ -221,3 +284,62 @@ export function JiraIssueWatchTable({
</Table>
);
}
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 (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("jira:noJiraWatchersConfiguredCreateOne")}
</p>
);
}
if (!usesDesktopWorkbench) {
return (
<div className="min-w-0" data-testid="jira-watch-mobile-list">
{watches.map((watch) => (
<MobileWatchCard
key={watch.id}
watch={watch}
isDirty={dirtyIds.has(watch.id)}
showWorkspace={showWorkspace}
workspaceName={workspaceName}
onEdit={onEdit}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete}
/>
))}
</div>
);
}
return (
<DesktopJiraIssueWatchTable
watches={watches}
dirtyIds={dirtyIds}
showWorkspace={showWorkspace}
onEdit={onEdit}
onDelete={onDelete}
onTrigger={onTrigger}
onReset={onReset}
onToggleEnabled={onToggleEnabled}
workspaceName={workspaceName}
/>
);
}
@@ -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> = {}): 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(
<TooltipProvider>
<JiraIssueWatchTable
watches={[watch()]}
dirtyIds={new Set()}
showWorkspace
onEdit={vi.fn()}
onDelete={onDelete}
onTrigger={vi.fn()}
onReset={vi.fn()}
onToggleEnabled={vi.fn()}
/>
</TooltipProvider>,
);
}
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"));
});
});
@@ -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" });
@@ -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 (
<div className="flex items-center justify-end gap-1">
<div
className="flex items-center justify-end gap-1"
data-watcher-actions
onClick={(event) => event.stopPropagation()}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
data-settings-dirty={isDirty}
data-testid={`linear-watch-enabled-${watch.id}`}
onClick={(e) => {
@@ -110,7 +112,7 @@ function WatchActions({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
onClick={(e) => {
e.stopPropagation();
onTrigger(watch.id);
@@ -126,7 +128,7 @@ function WatchActions({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
data-testid="watch-reset-button"
aria-label={t("linear:resetWatch")}
onClick={(e) => {
@@ -139,27 +141,90 @@ function WatchActions({
</TooltipTrigger>
<TooltipContent>{t("common:reset")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(watch.id);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("linear:delete")}</TooltipContent>
</Tooltip>
<WatcherDeleteAction
title={t("linear:deleteThisLinearWatcher")}
cancelLabel={t("common:cancel")}
confirmLabel={t("linear:delete")}
ariaLabel={t("linear:deleteThisLinearWatcher")}
tooltipLabel={t("linear:delete")}
triggerTestId={`linear-watch-delete-${watch.id}`}
confirmTestId="linear-watch-delete-confirm"
onConfirm={() => onDelete(watch.id)}
/>
</div>
);
}
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 (
<div
className="min-w-0 space-y-3 border-b px-1 py-4 last:border-b-0"
data-settings-dirty={isDirty}
data-testid={`linear-watch-mobile-row-${watch.id}`}
>
<button
type="button"
className="block w-full min-w-0 text-left cursor-pointer"
onClick={() => onEdit(watch)}
>
{workspaceLabel && (
<span
className="mb-1 block truncate text-xs text-muted-foreground"
title={workspaceLabel}
>
{workspaceLabel}
</span>
)}
<span className="block truncate font-mono text-sm" title={summarizeFilter(watch.filter)}>
{summarizeFilter(watch.filter)}
</span>
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant={watch.enabled ? "default" : "secondary"}>
{watch.enabled ? t("linear:active") : t("linear:paused")}
</Badge>
<span>
{t("linear:intervalMinutes", {
count: Math.round(watch.pollIntervalSeconds / 60),
})}
</span>
<span>{formatLastPolled(t, watch.lastPolledAt)}</span>
</span>
</button>
<WatchActions
watch={watch}
isDirty={isDirty}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete}
/>
</div>
);
}
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 (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("linear:noLinearWatchersConfiguredCreateOne")}
</p>
);
}
return (
<Table>
<Table className="min-w-[680px]">
<TableHeader>
<TableRow>
{showWorkspace && <TableHead>{t("common:workspace")}</TableHead>}
@@ -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 && (
<TableCell className="text-xs text-muted-foreground">
@@ -243,3 +306,62 @@ export function LinearIssueWatchTable({
</Table>
);
}
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 (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("linear:noLinearWatchersConfiguredCreateOne")}
</p>
);
}
if (!usesDesktopWorkbench) {
return (
<div className="min-w-0" data-testid="linear-watch-mobile-list">
{watches.map((watch) => (
<MobileWatchCard
key={watch.id}
watch={watch}
isDirty={dirtyIds.has(watch.id)}
showWorkspace={showWorkspace}
workspaceName={workspaceName}
onEdit={onEdit}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete}
/>
))}
</div>
);
}
return (
<DesktopLinearIssueWatchTable
watches={watches}
dirtyIds={dirtyIds}
showWorkspace={showWorkspace}
onEdit={onEdit}
onDelete={onDelete}
onTrigger={onTrigger}
onReset={onReset}
onToggleEnabled={onToggleEnabled}
workspaceName={workspaceName}
/>
);
}
@@ -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> = {}): 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(
<TooltipProvider>
<LinearIssueWatchTable
watches={[watch()]}
dirtyIds={new Set()}
showWorkspace
onEdit={vi.fn()}
onDelete={onDelete}
onTrigger={vi.fn()}
onReset={vi.fn()}
onToggleEnabled={vi.fn()}
/>
</TooltipProvider>,
);
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(
<TooltipProvider>
<LinearIssueWatchTable
watches={[watch()]}
dirtyIds={new Set()}
showWorkspace
onEdit={vi.fn()}
onDelete={vi.fn()}
onTrigger={vi.fn()}
onReset={vi.fn()}
onToggleEnabled={vi.fn()}
/>
</TooltipProvider>,
);
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(
<TooltipProvider>
<LinearIssueWatchTable
watches={[watch()]}
dirtyIds={new Set()}
showWorkspace
onEdit={vi.fn()}
onDelete={onDelete}
onTrigger={vi.fn()}
onReset={vi.fn()}
onToggleEnabled={vi.fn()}
/>
</TooltipProvider>,
);
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));
});
});
@@ -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" });
@@ -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>): 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(
<TooltipProvider>
<SentryIssueWatchTable
@@ -44,7 +50,7 @@ function renderTable(watches: SentryIssueWatch[]) {
dirtyIds={new Set(watches.slice(0, 1).map(({ id }) => 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(
<TooltipProvider>
<SentryIssueWatchTable
watches={[watch({ id: "w1" })]}
dirtyIds={new Set()}
instanceName={resolveName}
onEdit={noop}
onDelete={onDelete}
onTrigger={noop}
onReset={noop}
onToggleEnabled={noop}
/>
</TooltipProvider>,
);
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"));
});
});
@@ -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 (
<div className="flex items-center justify-end gap-1">
<div
className="flex items-center justify-end gap-1"
data-watcher-actions
onClick={(event) => event.stopPropagation()}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
data-settings-dirty={isDirty}
data-testid={`sentry-watch-enabled-${watch.id}`}
onClick={(e) => {
@@ -118,7 +120,7 @@ function WatchActions({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
onClick={(e) => {
e.stopPropagation();
onTrigger(watch.id, watch.workspaceId);
@@ -134,7 +136,7 @@ function WatchActions({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
className={`${actionSize} p-0 cursor-pointer`}
data-testid="watch-reset-button"
aria-label={t("sentry:resetWatch")}
onClick={(e) => {
@@ -147,27 +149,83 @@ function WatchActions({
</TooltipTrigger>
<TooltipContent>{t("common:reset")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(watch.id, watch.workspaceId);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sentry:delete")}</TooltipContent>
</Tooltip>
<WatcherDeleteAction
title={t("sentry:deleteThisSentryWatcher")}
cancelLabel={t("common:cancel")}
confirmLabel={t("sentry:delete")}
ariaLabel={t("sentry:deleteThisSentryWatcher")}
tooltipLabel={t("sentry:delete")}
triggerTestId={`sentry-watch-delete-${watch.id}`}
confirmTestId="sentry-watch-delete-confirm"
onConfirm={() => onDelete(watch.id, watch.workspaceId)}
/>
</div>
);
}
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 (
<div
className="min-w-0 space-y-3 border-b px-1 py-4 last:border-b-0"
data-settings-dirty={isDirty}
data-testid={`sentry-watch-mobile-row-${watch.id}`}
>
<button
type="button"
className="block w-full min-w-0 text-left cursor-pointer"
onClick={() => onEdit(watch)}
>
<span className="block truncate text-sm">{instanceName(watch.sentryInstanceId)}</span>
<span
className="mt-1 block truncate font-mono text-xs"
title={summarizeFilter(watch.filter)}
>
{summarizeFilter(watch.filter)}
</span>
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant={watch.enabled ? "default" : "secondary"}>
{watch.enabled ? t("sentry:active") : t("sentry:paused")}
</Badge>
<span>
{t("sentry:intervalMinutes", {
count: Math.round(watch.pollIntervalSeconds / 60),
})}
</span>
<span>{formatLastPolled(t, watch.lastPolledAt)}</span>
</span>
</button>
<WatchActions
watch={watch}
isDirty={isDirty}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete}
/>
</div>
);
}
function DesktopSentryIssueWatchTable({
watches,
dirtyIds,
instanceName,
@@ -178,16 +236,8 @@ export function SentryIssueWatchTable({
onToggleEnabled,
}: SentryIssueWatchTableProps) {
const { t } = useTranslation();
if (watches.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("sentry:noWatchersConfigured")}
</p>
);
}
return (
<Table>
<Table className="min-w-[680px]">
<TableHeader>
<TableRow>
<TableHead>{t("sentry:instance")}</TableHead>
@@ -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);
}}
>
<TableCell className="text-xs text-muted-foreground" data-testid="watch-instance">
{instanceName(watch.sentryInstanceId)}
@@ -246,3 +304,57 @@ export function SentryIssueWatchTable({
</Table>
);
}
export function SentryIssueWatchTable({
watches,
dirtyIds,
instanceName,
onEdit,
onDelete,
onTrigger,
onReset,
onToggleEnabled,
}: SentryIssueWatchTableProps) {
const { t } = useTranslation();
const { usesDesktopWorkbench = true } = useResponsiveBreakpoint();
if (watches.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("sentry:noWatchersConfigured")}
</p>
);
}
if (!usesDesktopWorkbench) {
return (
<div className="min-w-0" data-testid="sentry-watch-mobile-list">
{watches.map((watch) => (
<MobileWatchCard
key={watch.id}
watch={watch}
isDirty={dirtyIds.has(watch.id)}
instanceName={instanceName}
onEdit={onEdit}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete}
/>
))}
</div>
);
}
return (
<DesktopSentryIssueWatchTable
watches={watches}
dirtyIds={dirtyIds}
instanceName={instanceName}
onEdit={onEdit}
onDelete={onDelete}
onTrigger={onTrigger}
onReset={onReset}
onToggleEnabled={onToggleEnabled}
/>
);
}
@@ -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" });
@@ -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<void>;
};
/**
* 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<HTMLButtonElement>(null);
if (!isFinePointer && open) {
return (
<InlineConfirmActions
density="touch"
testId={testId}
ariaLabel={ariaLabel}
cancelLabel={cancelLabel}
confirmLabel={confirmLabel}
confirmTestId={confirmTestId}
onCancel={() => setOpen(false)}
onClose={() => setOpen(false)}
onConfirm={onConfirm}
/>
);
}
const trigger = (
<Button
ref={anchorRef}
variant="ghost"
size="sm"
className={`p-0 text-red-500 hover:text-red-600 cursor-pointer ${
isFinePointer ? "h-7 w-7" : "h-11 w-11"
}`}
aria-label={ariaLabel}
data-testid={triggerTestId}
onClick={(event) => {
event.stopPropagation();
setOpen(true);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
);
return (
<>
{isFinePointer && tooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent>{tooltipLabel}</TooltipContent>
</Tooltip>
) : (
trigger
)}
{isFinePointer ? (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={title}
cancelLabel={cancelLabel}
confirmLabel={confirmLabel}
confirmTestId={confirmTestId}
testId={testId}
onOpenChange={setOpen}
onCancel={() => setOpen(false)}
onConfirm={onConfirm}
/>
) : null}
</>
);
}
@@ -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();
@@ -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();
});
@@ -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);
});
});
@@ -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" },
});
}
@@ -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);
});
});