feat(ui): localize watcher delete confirmations (#2883)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user