Files
kandev/apps/web/components/azure-devops/azure-devops-watch-settings.tsx
T
2026-09-12 14:58:49 +02:00

756 lines
27 KiB
TypeScript

/* eslint-disable max-lines, sonarjs/no-duplicate-string -- watcher settings intentionally co-locate both Azure watch forms and actions. */
"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
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";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { SettingsSection } from "@/components/settings/settings-section";
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,
} from "./azure-devops-watch-placeholders";
import type {
AzureDevOpsCleanupPolicy,
AzureDevOpsPullRequestWatch,
AzureDevOpsPullRequestWatchInput,
AzureDevOpsWorkItemWatch,
AzureDevOpsWorkItemWatchInput,
} from "@/lib/types/azure-devops";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
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. */
const AZURE_ME_TOKEN = "@me";
/**
* Catalog keys for the two watch kinds. `Kind` is a wire discriminant compared
* with `===` and baked into `data-testid`s, so it is never rendered directly —
* only the label resolves at render.
*/
const KIND_NOUN_KEYS: Record<Kind, string> = {
"work-item": "azuredevops:workItemKindNoun",
"pull-request": "azuredevops:pullRequestKindNoun",
};
/**
* Sentence-cased cleanup-policy labels for the watch summary line. The map key
* is the persisted `AzureDevOpsCleanupPolicy` value; the menu items in the
* editor use the standalone (capitalized) forms instead.
*/
const CLEANUP_POLICY_KEYS: Record<string, string> = {
auto: "azuredevops:cleanupAuto",
always: "azuredevops:cleanupAlways",
never: "azuredevops:cleanupNever",
};
/**
* Sentence-cased labels for the watch card's status line. The map key is the
* Azure DevOps pull-request status sent on the wire; `all` shares the label used
* when no status is set, since both mean "not filtered by status". The editor's
* menu items use the standalone (capitalized) forms instead.
*/
const WATCH_STATUS_KEYS: Record<string, string> = {
active: "azuredevops:watchStatusActive",
completed: "azuredevops:watchStatusCompleted",
abandoned: "azuredevops:watchStatusAbandoned",
all: "azuredevops:pullRequestAny",
};
/** An unrecognized status is echoed verbatim rather than blanked. */
function watchStatusLabel(t: Translate, status: string | undefined): string {
if (!status) return t("azuredevops:pullRequestAny");
const key = WATCH_STATUS_KEYS[status];
return key ? t(key) : status;
}
function formatLastChecked(t: Translate, value?: string | null): string {
if (!value) return t("azuredevops:notCheckedYet");
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return t("azuredevops:notCheckedYet");
return t("azuredevops:checkedAt", { at: formatDateTime(date) });
}
// The two default prompts are PERSISTED on the watch and sent to the agent
// verbatim, and they carry `{{work_item.title}}` / `{{pull_request.title}}`
// placeholders the backend substitutes. They are deliberately NOT translated —
// see the GitLab and Sentry watch defaults for the same contract.
const emptyWorkItem: AzureDevOpsWorkItemWatchInput = {
workflowId: "",
workflowStepId: "",
projectId: "",
wiql: "",
repositoryId: "",
baseBranch: "",
agentProfileId: "",
executorProfileId: "",
prompt: "Implement {{work_item.title}}",
pollIntervalSeconds: 300,
cleanupPolicy: "auto",
maxInflightTasks: undefined,
};
const emptyPullRequest: AzureDevOpsPullRequestWatchInput = {
workflowId: "",
workflowStepId: "",
projectId: "",
azureRepositoryId: "",
status: "active",
creatorId: "",
reviewerId: "",
repositoryId: "",
baseBranch: "",
agentProfileId: "",
executorProfileId: "",
prompt: "Review {{pull_request.title}}",
pollIntervalSeconds: 300,
cleanupPolicy: "auto",
maxInflightTasks: undefined,
};
function WatchActions({
kind,
watch,
onEdit,
onToggle,
onTrigger,
onReset,
onDelete,
}: {
kind: Kind;
watch: AzureDevOpsWorkItemWatch | AzureDevOpsPullRequestWatch;
onEdit: () => void;
onToggle: () => void;
onTrigger: () => void;
onReset: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={onEdit}
data-testid={`azure-${kind}-watch-edit-${watch.id}`}
>
{t("azuredevops:edit")}
</Button>
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={onToggle}
data-testid={`azure-${kind}-watch-toggle-${watch.id}`}
>
{watch.enabled ? t("azuredevops:disable") : t("azuredevops:enable")}
</Button>
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={onTrigger}
data-testid={`azure-${kind}-watch-trigger-${watch.id}`}
>
<IconPlayerPlay className="h-4 w-4" /> {t("azuredevops:runNow")}
</Button>
<Button type="button" variant="outline" className="cursor-pointer" onClick={onReset}>
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
</Button>
<WatcherDeleteAction
targetKey={`${watch.workspaceId}:${kind}:${watch.id}`}
subject={"wiql" in watch ? watch.wiql : watch.projectId}
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>
);
}
function WatchCard({
kind,
watch,
onEdit,
onToggle,
onTrigger,
onReset,
onDelete,
}: {
kind: Kind;
watch: AzureDevOpsWorkItemWatch | AzureDevOpsPullRequestWatch;
onEdit: () => void;
onToggle: () => void;
onTrigger: () => void;
onReset: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
return (
<Card data-testid={`azure-${kind}-watch-${watch.id}`}>
<CardHeader className="space-y-2">
<CardTitle className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
{kind === "work-item"
? t("azuredevops:workItemQuery")
: t("azuredevops:pullRequestFilter")}
</span>
<Badge variant={watch.enabled ? "default" : "secondary"}>
{watch.enabled ? t("azuredevops:enabled") : t("azuredevops:disabled")}
</Badge>
</CardTitle>
<div className="text-xs text-muted-foreground">
{/* `projectId` is Azure DevOps data and `cleanupPolicy` a wire enum;
both are interpolated rather than written into the message. */}
{t("azuredevops:watchSummary", {
projectId: watch.projectId,
count: watch.pollIntervalSeconds,
policy: t(CLEANUP_POLICY_KEYS[watch.cleanupPolicy] ?? CLEANUP_POLICY_KEYS.auto),
})}
</div>
<div className="text-xs text-muted-foreground">
{formatLastChecked(t, watch.lastPolledAt)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{kind === "work-item" && (
<pre className="max-h-20 overflow-auto rounded bg-muted/40 p-2 text-xs">
{(watch as AzureDevOpsWorkItemWatch).wiql}
</pre>
)}
{kind === "pull-request" && (
<div className="text-xs text-muted-foreground">
{t("azuredevops:statusLine", {
status: watchStatusLabel(t, (watch as AzureDevOpsPullRequestWatch).status),
})}
</div>
)}
{watch.lastError && (
<Alert variant="destructive">
<AlertDescription>{watch.lastError}</AlertDescription>
</Alert>
)}
<WatchActions
kind={kind}
watch={watch}
onEdit={onEdit}
onToggle={onToggle}
onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete}
/>
</CardContent>
</Card>
);
}
// eslint-disable-next-line max-lines-per-function, complexity -- the editor renders the shared responsive form for both watch kinds.
function WatchEditor({
kind,
open,
onOpenChange,
initial,
editing,
onSubmit,
}: {
kind: Kind;
open: boolean;
onOpenChange: (open: boolean) => void;
initial?: AzureDevOpsWorkItemWatchInput | AzureDevOpsPullRequestWatchInput;
editing: boolean;
onSubmit: (
input: AzureDevOpsWorkItemWatchInput | AzureDevOpsPullRequestWatchInput,
) => Promise<unknown>;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const [workItem, setWorkItem] = useState<AzureDevOpsWorkItemWatchInput>(
kind === "work-item" && initial ? (initial as AzureDevOpsWorkItemWatchInput) : emptyWorkItem,
);
const [pullRequest, setPullRequest] = useState<AzureDevOpsPullRequestWatchInput>(
kind === "pull-request" && initial
? (initial as AzureDevOpsPullRequestWatchInput)
: emptyPullRequest,
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const current = kind === "work-item" ? workItem : pullRequest;
const placeholders = useMemo(
() =>
kind === "work-item"
? azureWorkItemWatchPlaceholders(t)
: azurePullRequestWatchPlaceholders(t),
[kind, t],
);
const set = (key: string, value: string | number) => {
if (kind === "work-item") setWorkItem((previous) => ({ ...previous, [key]: value }));
else setPullRequest((previous) => ({ ...previous, [key]: value }));
};
const submit = async () => {
if (
!current.projectId ||
!current.repositoryId ||
!current.workflowId ||
!current.workflowStepId ||
!current.agentProfileId ||
!current.executorProfileId ||
(kind === "work-item" && !workItem.wiql)
) {
setError(
kind === "work-item"
? t("azuredevops:workItemWatchFieldsRequired")
: t("azuredevops:pullRequestWatchFieldsRequired"),
);
return;
}
setSaving(true);
setError(null);
try {
await onSubmit(current);
onOpenChange(false);
} catch (cause) {
setError(String(cause));
} finally {
setSaving(false);
}
};
let submitLabel = editing ? t("azuredevops:updateWatch") : t("azuredevops:createWatch");
if (saving) submitLabel = t("azuredevops:savingEllipsis");
const kindNoun = t(KIND_NOUN_KEYS[kind]);
const editorTitle = editing
? t("azuredevops:editWatchTitle", { kind: kindNoun })
: t("azuredevops:newWatchTitle", { kind: kindNoun });
const body = (
<div className="min-h-0 space-y-4 overflow-y-auto p-4 pb-[env(safe-area-inset-bottom,0px)] sm:p-6">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("azuredevops:azureProjectId")}</Label>
<Input
value={current.projectId}
onChange={(event) => set("projectId", event.target.value)}
data-testid={`azure-${kind}-watch-project`}
/>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:pollIntervalSeconds")}</Label>
<Input
type="number"
min={60}
value={current.pollIntervalSeconds}
onChange={(event) => set("pollIntervalSeconds", Number(event.target.value))}
/>
</div>
</div>
{kind === "work-item" ? (
<div className="space-y-1.5">
{/* WIQL is the Azure DevOps query language, not copy. */}
<Label>WIQL</Label>
<textarea
className="min-h-28 w-full rounded-md border bg-background p-3 text-sm"
value={workItem.wiql}
onChange={(event) => set("wiql", event.target.value)}
data-testid="azure-work-item-watch-wiql"
/>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("azuredevops:azureRepositoryIdOptional")}</Label>
<Input
value={pullRequest.azureRepositoryId ?? ""}
onChange={(event) => set("azureRepositoryId", event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:status")}</Label>
<Select
value={pullRequest.status || "active"}
onValueChange={(value) => set("status", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{/* `value` is the Azure DevOps pull-request status sent on the
wire; only the item label is copy. */}
<SelectContent>
<SelectItem value="active">{t("azuredevops:statusActive")}</SelectItem>
<SelectItem value="completed">{t("azuredevops:statusCompleted")}</SelectItem>
<SelectItem value="abandoned">{t("azuredevops:statusAbandoned")}</SelectItem>
<SelectItem value="all">{t("azuredevops:statusAny")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:creatorId")}</Label>
<Input
value={pullRequest.creatorId ?? ""}
onChange={(event) => set("creatorId", event.target.value)}
placeholder={t("azuredevops:identityPlaceholder", { token: AZURE_ME_TOKEN })}
/>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:reviewerId")}</Label>
<Input
value={pullRequest.reviewerId ?? ""}
onChange={(event) => set("reviewerId", event.target.value)}
placeholder={t("azuredevops:identityPlaceholder", { token: AZURE_ME_TOKEN })}
/>
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("azuredevops:kandevRepositoryId")}</Label>
<Input
value={current.repositoryId}
onChange={(event) => set("repositoryId", event.target.value)}
data-testid={`azure-${kind}-watch-repository`}
/>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:baseBranch")}</Label>
<Input
value={current.baseBranch}
onChange={(event) => set("baseBranch", event.target.value)}
placeholder="main"
data-testid={`azure-${kind}-watch-branch`}
/>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:workflowId")}</Label>
<Input
value={current.workflowId}
onChange={(event) => set("workflowId", event.target.value)}
data-testid={`azure-${kind}-watch-workflow`}
/>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:workflowStepId")}</Label>
<Input
value={current.workflowStepId}
onChange={(event) => set("workflowStepId", event.target.value)}
data-testid={`azure-${kind}-watch-step`}
/>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:agentProfileId")}</Label>
<Input
value={current.agentProfileId}
onChange={(event) => set("agentProfileId", event.target.value)}
data-testid={`azure-${kind}-watch-agent`}
/>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:executorProfileId")}</Label>
<Input
value={current.executorProfileId}
onChange={(event) => set("executorProfileId", event.target.value)}
data-testid={`azure-${kind}-watch-executor`}
/>
</div>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:prompt")}</Label>
<SettingsPromptEditor
value={current.prompt}
onChange={(value) => set("prompt", value)}
placeholders={placeholders}
promptReferences
ariaLabel={t("azuredevops:prompt")}
testId={`azure-${kind}-watch-prompt-editor`}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("azuredevops:cleanupPolicy")}</Label>
<Select
value={current.cleanupPolicy}
onValueChange={(value: AzureDevOpsCleanupPolicy) => set("cleanupPolicy", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{/* `value` is the persisted `AzureDevOpsCleanupPolicy`; only the
item label is copy. */}
<SelectContent>
<SelectItem value="auto">{t("azuredevops:cleanupPolicyAuto")}</SelectItem>
<SelectItem value="always">{t("azuredevops:cleanupPolicyAlways")}</SelectItem>
<SelectItem value="never">{t("azuredevops:cleanupPolicyNever")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:maxInflightTasks")}</Label>
<Input
type="number"
min={0}
value={current.maxInflightTasks ?? 0}
onChange={(event) => set("maxInflightTasks", Number(event.target.value))}
/>
</div>
</div>
<Button
type="button"
className="w-full cursor-pointer"
onClick={() => void submit()}
disabled={saving}
>
{submitLabel}
</Button>
</div>
);
if (isMobile)
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[100dvh] max-h-[100dvh] overflow-hidden">
<DrawerHeader className="flex items-center justify-between gap-3">
<DrawerTitle>{editorTitle}</DrawerTitle>
<Button
type="button"
variant="ghost"
size="icon"
className={controlSizingClassName("icon", "cursor-pointer")}
aria-label={t("azuredevops:closeWatchEditor")}
data-testid="azure-watch-editor-close"
onClick={() => onOpenChange(false)}
>
<IconX className="h-4 w-4" />
</Button>
</DrawerHeader>
{body}
</DrawerContent>
</Drawer>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90dvh] overflow-hidden p-0">
<DialogHeader className="flex flex-row items-center justify-between gap-3 px-6 pt-6">
<DialogTitle>{editorTitle}</DialogTitle>
<Button
type="button"
variant="ghost"
size="icon"
className={controlSizingClassName("icon", "cursor-pointer")}
aria-label={t("azuredevops:closeWatchEditor")}
data-testid="azure-watch-editor-close"
onClick={() => onOpenChange(false)}
>
<IconX className="h-4 w-4" />
</Button>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
);
}
// eslint-disable-next-line max-lines-per-function -- settings coordinates watch lists, editor state, and destructive confirmations.
export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const watches = useAzureDevOpsWatches(workspaceId);
const [editor, setEditor] = useState<{
kind: Kind;
id?: 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);
setMessage(t("azuredevops:newMatches", { count: result.matched }));
} 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 {
if (kind === "work-item") await watches.updateWorkItem(id, { enabled });
else await watches.updatePullRequest(id, { enabled });
setMessage(enabled ? t("azuredevops:watchEnabled") : t("azuredevops:watchDisabled"));
} catch (error) {
setMessage(String(error));
}
};
const remove = async (kind: Kind, id: string) => {
try {
await watches.remove(kind, id);
setMessage(t("azuredevops:watchDeleted"));
} catch (error) {
setMessage(String(error));
}
};
return (
<div className="space-y-6" data-testid="azure-devops-watch-settings">
{message && (
<Alert>
<AlertDescription>{message}</AlertDescription>
</Alert>
)}
{watches.error && (
<Alert variant="destructive">
<AlertDescription>{watches.error}</AlertDescription>
</Alert>
)}
{watches.loading && (
<p className="text-sm text-muted-foreground">{t("azuredevops:loadingWatchers")}</p>
)}
<SettingsSection
title={t("azuredevops:pullRequestWatches")}
description={t("azuredevops:pullRequestWatchesDescription")}
action={
<Button
type="button"
className="w-full cursor-pointer sm:w-auto"
onClick={() => setEditor({ kind: "pull-request" })}
data-testid="azure-add-pull-request-watch"
>
<IconPlus className="h-4 w-4" /> {t("azuredevops:addPrWatch")}
</Button>
}
>
{!watches.loading && watches.pullRequests.length === 0 && (
<Card>
<CardContent className="p-6 text-sm text-muted-foreground">
{t("azuredevops:noPullRequestWatches")}
</CardContent>
</Card>
)}
<div className="grid gap-4 xl:grid-cols-2">
{watches.pullRequests.map((watch) => (
<WatchCard
key={watch.id}
kind="pull-request"
watch={watch}
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={() => reset("pull-request", watch.id)}
onDelete={() => void remove("pull-request", watch.id)}
/>
))}
</div>
</SettingsSection>
<SettingsSection
title={t("azuredevops:workItemWatches")}
description={t("azuredevops:workItemWatchesDescription")}
action={
<Button
type="button"
className="w-full cursor-pointer sm:w-auto"
onClick={() => setEditor({ kind: "work-item" })}
data-testid="azure-add-work-item-watch"
>
<IconPlus className="h-4 w-4" /> {t("azuredevops:addWorkItemWatch")}
</Button>
}
>
{!watches.loading && watches.workItems.length === 0 && (
<Card>
<CardContent className="p-6 text-sm text-muted-foreground">
{t("azuredevops:noWorkItemWatches")}
</CardContent>
</Card>
)}
<div className="grid gap-4 xl:grid-cols-2">
{watches.workItems.map((watch) => (
<WatchCard
key={watch.id}
kind="work-item"
watch={watch}
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={() => 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"}`}
kind={editor.kind}
open
editing={Boolean(editor.id)}
initial={editor.initial}
onOpenChange={(open) => !open && setEditor(null)}
onSubmit={(input) => {
if (editor.kind === "work-item") {
const value = input as AzureDevOpsWorkItemWatchInput;
return editor.id
? watches.updateWorkItem(editor.id, value)
: watches.createWorkItem(value);
}
const value = input as AzureDevOpsPullRequestWatchInput;
return editor.id
? watches.updatePullRequest(editor.id, value)
: watches.createPullRequest(value);
}}
/>
)}
</div>
);
}