feat(ui): confirm saved view deletion (#3461)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -35,6 +35,10 @@ export type ActionConfirmPopoverProps = {
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function isActionConfirmationTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && target.closest("[data-confirmation-boundary]") !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-modal confirmation surface for one anchored action.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { isActionConfirmationTarget } from "./action-confirm-popover";
|
||||
import { SavedTaskViewDeleteConfirmation } from "./saved-task-view-delete-confirmation";
|
||||
|
||||
const TARGET = { id: "view-1", label: "Needs review" };
|
||||
const TARGET_TITLE = "Delete Needs review?";
|
||||
|
||||
function Harness({
|
||||
presentation,
|
||||
onConfirm,
|
||||
confirmDisabled = false,
|
||||
}: {
|
||||
presentation: "popover" | "inline";
|
||||
onConfirm: (id: string) => void;
|
||||
confirmDisabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<>
|
||||
<button ref={anchorRef} type="button" data-testid="delete-anchor">
|
||||
Delete view
|
||||
</button>
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={TARGET}
|
||||
presentation={presentation}
|
||||
open={open}
|
||||
anchorRef={anchorRef}
|
||||
confirmDisabled={confirmDisabled}
|
||||
onOpenChange={setOpen}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplacingHarness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
data-testid="replacement-delete-anchor"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Delete view
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={TARGET}
|
||||
presentation="inline"
|
||||
open
|
||||
anchorRef={anchorRef}
|
||||
onOpenChange={setOpen}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SavedTaskViewDeleteConfirmation", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("names the target and dispatches its captured id only after fine-pointer confirmation", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<Harness presentation="popover" onConfirm={onConfirm} />);
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: TARGET_TITLE });
|
||||
expect(dialog.textContent).toContain(
|
||||
"This removes only the saved filters and view settings. It does not delete tasks or data in connected services.",
|
||||
);
|
||||
expect(isActionConfirmationTarget(dialog)).toBe(true);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete Needs review" }));
|
||||
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
||||
expect(onConfirm).toHaveBeenCalledWith("view-1");
|
||||
});
|
||||
|
||||
it("cancels the inline presentation on Escape and returns focus to the trigger", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<Harness presentation="inline" onConfirm={onConfirm} />);
|
||||
|
||||
const confirmation = screen.getByRole("group", { name: TARGET_TITLE });
|
||||
expect(confirmation.textContent).toContain(TARGET_TITLE);
|
||||
fireEvent.keyDown(confirmation, { key: "Escape" });
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("group", { name: TARGET_TITLE })).toBeNull();
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("delete-anchor")));
|
||||
});
|
||||
|
||||
it("returns focus after inline confirmation is replaced by its trigger", async () => {
|
||||
render(<ReplacingHarness />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("replacement-delete-anchor"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
const replacementAnchor = await screen.findByTestId("replacement-delete-anchor");
|
||||
await waitFor(() => expect(document.activeElement).toBe(replacementAnchor));
|
||||
});
|
||||
|
||||
it("keeps Cancel available while inline confirmation is disabled", () => {
|
||||
render(<Harness presentation="inline" onConfirm={vi.fn()} confirmDisabled />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Cancel" }).hasAttribute("disabled")).toBe(false);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Delete Needs review" }).hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ActionConfirmPopover } from "./action-confirm-popover";
|
||||
import { InlineConfirmActions } from "./inline-confirm-actions";
|
||||
import type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
|
||||
|
||||
export type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
|
||||
|
||||
type SavedTaskViewDeleteConfirmationProps = {
|
||||
target: SavedTaskViewDeleteTarget;
|
||||
presentation: "popover" | "inline";
|
||||
open: boolean;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
confirmDisabled?: boolean;
|
||||
testId?: string;
|
||||
confirmTestId?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (id: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function SavedTaskViewDeleteConfirmation({
|
||||
target,
|
||||
presentation,
|
||||
open,
|
||||
anchorRef,
|
||||
focusBoundaryRef,
|
||||
confirmDisabled = false,
|
||||
testId = "saved-task-view-delete-confirmation",
|
||||
confirmTestId = "saved-task-view-delete-confirm",
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: SavedTaskViewDeleteConfirmationProps) {
|
||||
const { t } = useTranslation();
|
||||
const title = t("common:deleteSavedTaskViewTitle", { name: target.label });
|
||||
const description = t("common:deleteSavedTaskViewDescription");
|
||||
|
||||
if (presentation === "popover") {
|
||||
return (
|
||||
<ActionConfirmPopover
|
||||
open={open}
|
||||
anchorRef={anchorRef}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
title={title}
|
||||
description={description}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={t("common:delete")}
|
||||
confirmAriaLabel={t("common:deleteSavedTaskViewAction", { name: target.label })}
|
||||
confirmDisabled={confirmDisabled}
|
||||
testId={testId}
|
||||
confirmTestId={confirmTestId}
|
||||
confirmationBoundary
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirm={() => onConfirm(target.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<InlineConfirmActions
|
||||
density="touch"
|
||||
testId={testId}
|
||||
ariaLabel={title}
|
||||
description={
|
||||
<>
|
||||
<span className="block font-medium text-foreground">{title}</span>
|
||||
<span className="mt-1 block">{description}</span>
|
||||
</>
|
||||
}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={t("common:delete")}
|
||||
confirmAriaLabel={t("common:deleteSavedTaskViewAction", { name: target.label })}
|
||||
confirmTestId={confirmTestId}
|
||||
confirmDisabled={confirmDisabled}
|
||||
onCancel={() => {
|
||||
onOpenChange(false);
|
||||
queueMicrotask(() => {
|
||||
if (anchorRef.current?.isConnected) anchorRef.current.focus();
|
||||
});
|
||||
}}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onConfirm={() => onConfirm(target.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type SavedTaskViewDeleteTarget = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function useSavedTaskViewDeleteConfirmation<T extends HTMLElement = HTMLElement>(
|
||||
availableTargets: ReadonlyArray<{ id: string }>,
|
||||
) {
|
||||
const [requestedTarget, setRequestedTarget] = useState<SavedTaskViewDeleteTarget | null>(null);
|
||||
const anchorRef = useRef<T>(null);
|
||||
const anchorIdRef = useRef<string | null>(null);
|
||||
const anchorsRef = useRef(new Map<string, T>());
|
||||
const target =
|
||||
requestedTarget && availableTargets.some((item) => item.id === requestedTarget.id)
|
||||
? requestedTarget
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedTarget && !target) setRequestedTarget(null);
|
||||
}, [requestedTarget, target]);
|
||||
|
||||
function registerAnchor(id: string, element: T | null) {
|
||||
if (element) {
|
||||
anchorsRef.current.set(id, element);
|
||||
if (anchorIdRef.current === id) anchorRef.current = element;
|
||||
return;
|
||||
}
|
||||
anchorsRef.current.delete(id);
|
||||
}
|
||||
|
||||
function request(targetToDelete: SavedTaskViewDeleteTarget) {
|
||||
anchorIdRef.current = targetToDelete.id;
|
||||
const registeredAnchor = anchorsRef.current.get(targetToDelete.id);
|
||||
// Preserve a directly bound anchor when a surface does not register keyed anchors.
|
||||
if (registeredAnchor) anchorRef.current = registeredAnchor;
|
||||
setRequestedTarget(targetToDelete);
|
||||
}
|
||||
|
||||
return {
|
||||
target,
|
||||
anchorRef,
|
||||
close: () => setRequestedTarget(null),
|
||||
registerAnchor,
|
||||
request,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
import { PresetsSidebar } from "./presets-sidebar";
|
||||
@@ -15,7 +15,7 @@ const savedPreset: SavedPreset = {
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
function expectDeleteDoesNotSelect() {
|
||||
async function expectDeleteDoesNotSelect() {
|
||||
const onSelect = vi.fn();
|
||||
const onDeleteSaved = vi.fn();
|
||||
render(
|
||||
@@ -35,7 +35,17 @@ function expectDeleteDoesNotSelect() {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
|
||||
|
||||
expect(onDeleteSaved).toHaveBeenCalledWith(savedPreset.id);
|
||||
expect(onDeleteSaved).not.toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
const confirmation = screen.getByRole("group", { name: "Delete Kandev PRs?" });
|
||||
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
|
||||
expect(onDeleteSaved).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs" }));
|
||||
|
||||
await waitFor(() => expect(onDeleteSaved).toHaveBeenCalledWith(savedPreset.id));
|
||||
expect(onDeleteSaved).toHaveBeenCalledOnce();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "
|
||||
import type { SavedPreset } from "./saved-preset-model";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SavedQueryDefaultButton } from "@/components/integrations/saved-query-default-button";
|
||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||
import {
|
||||
useSavedTaskViewDeleteConfirmation,
|
||||
type SavedTaskViewDeleteTarget,
|
||||
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||
|
||||
export type SidebarSelection = {
|
||||
kind: "pr" | "issue";
|
||||
@@ -157,6 +162,7 @@ function SavedSection({
|
||||
defaultMutationPendingId: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
|
||||
const defaultMutationPending = defaultMutationPendingId !== null;
|
||||
return (
|
||||
<>
|
||||
@@ -166,43 +172,19 @@ function SavedSection({
|
||||
{t("github:noSavedQueriesYet")}
|
||||
</div>
|
||||
)}
|
||||
{saved.map((s) => {
|
||||
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
|
||||
const accessibleDeleteLabel = defaultMutationPending
|
||||
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
|
||||
: deleteLabel;
|
||||
return (
|
||||
<PresetItem
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
Icon={IconBookmark}
|
||||
active={selected.source === "saved" && selected.id === s.id}
|
||||
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
|
||||
trailing={
|
||||
<div className="flex shrink-0 items-center">
|
||||
<SavedQueryDefaultButton
|
||||
label={s.label}
|
||||
isDefault={s.isDefault}
|
||||
disabled={defaultMutationPending}
|
||||
pending={defaultMutationPendingId === s.id}
|
||||
testId={`github-saved-query-default-${s.id}`}
|
||||
onToggle={() => void onToggleSavedDefault(s)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={defaultMutationPending}
|
||||
onClick={() => onDelete(s.id)}
|
||||
className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-50"
|
||||
title={accessibleDeleteLabel}
|
||||
aria-label={accessibleDeleteLabel}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{saved.map((preset) => (
|
||||
<GitHubSavedPresetEntry
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
active={selected.source === "saved" && selected.id === preset.id}
|
||||
deletion={deletion}
|
||||
defaultMutationPending={defaultMutationPending}
|
||||
defaultMutationPendingForPreset={defaultMutationPendingId === preset.id}
|
||||
onSelect={() => onSelect({ kind, source: "saved", id: preset.id })}
|
||||
onDelete={onDelete}
|
||||
onToggleDefault={() => void onToggleSavedDefault(preset)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveCurrent}
|
||||
@@ -222,6 +204,88 @@ function SavedSection({
|
||||
);
|
||||
}
|
||||
|
||||
type GitHubSavedDeletion = {
|
||||
target: SavedTaskViewDeleteTarget | null;
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
close: () => void;
|
||||
registerAnchor: (id: string, element: HTMLButtonElement | null) => void;
|
||||
request: (target: SavedTaskViewDeleteTarget) => void;
|
||||
};
|
||||
|
||||
function GitHubSavedPresetEntry({
|
||||
preset,
|
||||
active,
|
||||
deletion,
|
||||
defaultMutationPending,
|
||||
defaultMutationPendingForPreset,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onToggleDefault,
|
||||
}: {
|
||||
preset: SavedPreset;
|
||||
active: boolean;
|
||||
deletion: GitHubSavedDeletion;
|
||||
defaultMutationPending: boolean;
|
||||
defaultMutationPendingForPreset: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onToggleDefault: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (deletion.target?.id === preset.id) {
|
||||
return (
|
||||
<div className="mx-1 min-w-0 p-1">
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deletion.target}
|
||||
presentation="inline"
|
||||
open
|
||||
anchorRef={deletion.anchorRef}
|
||||
confirmDisabled={defaultMutationPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) deletion.close();
|
||||
}}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: preset.label });
|
||||
const accessibleDeleteLabel = defaultMutationPending
|
||||
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
|
||||
: deleteLabel;
|
||||
return (
|
||||
<PresetItem
|
||||
label={preset.label}
|
||||
Icon={IconBookmark}
|
||||
active={active}
|
||||
onClick={onSelect}
|
||||
trailing={
|
||||
<div className="flex shrink-0 items-center">
|
||||
<SavedQueryDefaultButton
|
||||
label={preset.label}
|
||||
isDefault={preset.isDefault}
|
||||
disabled={defaultMutationPending}
|
||||
pending={defaultMutationPendingForPreset}
|
||||
testId={`github-saved-query-default-${preset.id}`}
|
||||
onToggle={onToggleDefault}
|
||||
/>
|
||||
<button
|
||||
ref={(element) => deletion.registerAnchor(preset.id, element)}
|
||||
type="button"
|
||||
disabled={defaultMutationPending}
|
||||
onClick={() => deletion.request({ id: preset.id, label: preset.label })}
|
||||
className="flex h-12 w-12 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-50"
|
||||
title={accessibleDeleteLabel}
|
||||
aria-label={accessibleDeleteLabel}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PresetsSidebar({
|
||||
selected,
|
||||
onSelect,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SavedPreset } from "./use-saved-presets";
|
||||
import { PresetsSidebar } from "./presets-sidebar";
|
||||
|
||||
const SAVED: SavedPreset = {
|
||||
id: "saved-mr",
|
||||
kind: "mr",
|
||||
label: "Ready MRs",
|
||||
customQuery: "state=opened",
|
||||
projectFilter: "kdlbs/kandev",
|
||||
milestone: "",
|
||||
preset: "",
|
||||
createdAt: "2026-09-07T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("GitLab PresetsSidebar", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("uses visible touch controls and deletes without selecting only after confirmation", async () => {
|
||||
const onDeleteSaved = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<PresetsSidebar
|
||||
selected={{ kind: "mr", source: "saved", id: SAVED.id }}
|
||||
onSelect={onSelect}
|
||||
savedPresets={[SAVED]}
|
||||
onDeleteSaved={onDeleteSaved}
|
||||
canSaveCurrent={false}
|
||||
onSaveCurrent={vi.fn()}
|
||||
mrPresets={[]}
|
||||
issuePresets={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByRole("button", { name: "Delete Ready MRs saved query" });
|
||||
expect(deleteButton.className).toContain("h-12");
|
||||
expect(deleteButton.className).not.toContain("opacity-0");
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(onDeleteSaved).not.toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
const confirmation = screen.getByRole("group", { name: "Delete Ready MRs?" });
|
||||
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Ready MRs saved query" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Ready MRs" }));
|
||||
|
||||
await waitFor(() => expect(onDeleteSaved).toHaveBeenCalledWith(SAVED.id));
|
||||
expect(onDeleteSaved).toHaveBeenCalledOnce();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { cn } from "@/lib/utils";
|
||||
import { MR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./presets";
|
||||
import type { SavedPreset } from "./use-saved-presets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||
import { useSavedTaskViewDeleteConfirmation } from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||
|
||||
export type SidebarSelection = {
|
||||
kind: "mr" | "issue";
|
||||
@@ -171,6 +173,7 @@ function SavedSection({
|
||||
onSaveCurrent: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
|
||||
return (
|
||||
<>
|
||||
<SectionHeader id="saved" title={t("gitlab:saved")} />
|
||||
@@ -179,36 +182,58 @@ function SavedSection({
|
||||
{t("gitlab:noSavedQueriesYet")}
|
||||
</div>
|
||||
)}
|
||||
{saved.map((s) => (
|
||||
<PresetItem
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
Icon={IconBookmark}
|
||||
active={selected.source === "saved" && selected.id === s.id}
|
||||
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
|
||||
trailing={
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(s.id);
|
||||
}}
|
||||
className="opacity-0 group-hover/item:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
title={t("gitlab:deleteSavedQuery")}
|
||||
aria-label={t("gitlab:deleteSavedQuery2", { label: s.label })}
|
||||
data-testid={`gitlab-saved-delete-${s.id}`}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{saved.map((s) => {
|
||||
if (deletion.target?.id === s.id) {
|
||||
return (
|
||||
<div key={s.id} className="mx-1 min-w-0 p-1">
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deletion.target}
|
||||
presentation="inline"
|
||||
open
|
||||
anchorRef={deletion.anchorRef}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) deletion.close();
|
||||
}}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
|
||||
return (
|
||||
<PresetItem
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
Icon={IconBookmark}
|
||||
active={selected.source === "saved" && selected.id === s.id}
|
||||
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
|
||||
trailing={
|
||||
<button
|
||||
ref={(element) => {
|
||||
deletion.registerAnchor(s.id, element);
|
||||
}}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
deletion.request({ id: s.id, label: s.label });
|
||||
}}
|
||||
className="flex h-12 w-12 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={deleteLabel}
|
||||
aria-label={deleteLabel}
|
||||
data-testid={`gitlab-saved-delete-${s.id}`}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveCurrent}
|
||||
disabled={!canSaveCurrent}
|
||||
className={cn(
|
||||
"mx-1 mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||
"mx-1 mt-1 flex min-h-11 items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||
canSaveCurrent
|
||||
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
|
||||
: "text-muted-foreground/50 cursor-not-allowed",
|
||||
@@ -240,7 +265,7 @@ export function PresetsSidebar({
|
||||
onSelect({ kind, source: "preset", id: fallback });
|
||||
};
|
||||
return (
|
||||
<nav className="flex flex-col py-3">
|
||||
<nav className="flex w-full min-w-0 flex-col overflow-x-hidden py-3">
|
||||
<KindToggle kind={selected.kind} onChange={onKindChange} />
|
||||
<PresetGroupList
|
||||
presets={presets}
|
||||
|
||||
@@ -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 { IconInbox } from "@tabler/icons-react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -207,7 +207,9 @@ describe("IntegrationScopeBar saved defaults", () => {
|
||||
.getAttribute(ARIA_DISABLED_ATTRIBUTE),
|
||||
).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IntegrationScopeBar saved-query actions", () => {
|
||||
it("renders no default actions when an integration omits the optional contract", () => {
|
||||
renderBar({ onToggleSavedDefault: undefined });
|
||||
|
||||
@@ -227,13 +229,22 @@ describe("IntegrationScopeBar saved defaults", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents the menu from closing when a saved query is deleted", () => {
|
||||
it("keeps the menu open and waits for named confirmation before deletion", async () => {
|
||||
const { onDeleteSaved } = renderBar();
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: FUTURE_DELETE_LABEL }));
|
||||
|
||||
expect(onDeleteSaved).toHaveBeenCalledWith("saved-b");
|
||||
expect(onDeleteSaved).not.toHaveBeenCalled();
|
||||
expect(lastMenuItemSelectEvent?.defaultPrevented).toBe(true);
|
||||
const confirmation = await screen.findByRole("dialog", { name: "Delete Future default?" });
|
||||
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
|
||||
expect(onDeleteSaved).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: FUTURE_DELETE_LABEL }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Future default" }));
|
||||
|
||||
await waitFor(() => expect(onDeleteSaved).toHaveBeenCalledWith("saved-b"));
|
||||
expect(onDeleteSaved).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses a host icon when a plugin preset does not provide one", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef, useState, type RefObject } from "react";
|
||||
import { IconBookmark, IconChevronDown, IconDeviceFloppy, IconX } from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -14,6 +14,13 @@ import { cn } from "@/lib/utils";
|
||||
import { IntegrationIcon, type IntegrationIconName } from "./integration-icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SavedQueryDefaultDropdownItem } from "./saved-query-default-button";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
|
||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||
import {
|
||||
useSavedTaskViewDeleteConfirmation,
|
||||
type SavedTaskViewDeleteTarget,
|
||||
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||
|
||||
/**
|
||||
* Shared, domain-agnostic scope bar for the integration dashboards (/github,
|
||||
@@ -113,6 +120,31 @@ function PresetPill({
|
||||
);
|
||||
}
|
||||
|
||||
function SavedMenuTrigger({
|
||||
testId,
|
||||
active,
|
||||
label,
|
||||
}: {
|
||||
testId: string;
|
||||
active: boolean;
|
||||
label: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
className={cn(PILL_BASE, active ? PILL_ACTIVE : PILL_IDLE)}
|
||||
>
|
||||
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-[140px] truncate">{label ?? t("integrations:savedQueries")}</span>
|
||||
<IconChevronDown className="h-3 w-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedMenu<K extends string>({
|
||||
testId,
|
||||
selected,
|
||||
@@ -135,69 +167,51 @@ function SavedMenu<K extends string>({
|
||||
defaultMutationPendingId: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const deletion = useSavedTaskViewDeleteConfirmation(saved);
|
||||
const menuContentRef = useRef<HTMLDivElement>(null);
|
||||
const defaultMutationPending = defaultMutationPendingId !== null;
|
||||
const activeSaved = selected.source === "saved";
|
||||
const activeLabel = activeSaved ? saved.find((s) => s.id === selected.id)?.label : null;
|
||||
const activeLabel = activeSaved ? (saved.find((s) => s.id === selected.id)?.label ?? null) : null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
className={cn(PILL_BASE, activeSaved ? PILL_ACTIVE : PILL_IDLE)}
|
||||
>
|
||||
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-[140px] truncate">
|
||||
{activeLabel ?? t("integrations:savedQueries")}
|
||||
</span>
|
||||
<IconChevronDown className="h-3 w-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenu
|
||||
open={menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setMenuOpen(open);
|
||||
if (!open) deletion.close();
|
||||
}}
|
||||
>
|
||||
<SavedMenuTrigger testId={testId} active={activeSaved} label={activeLabel} />
|
||||
<DropdownMenuContent
|
||||
ref={menuContentRef}
|
||||
align="end"
|
||||
className="w-56"
|
||||
onFocusOutside={(event) => {
|
||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{saved.length === 0 ? (
|
||||
<DropdownMenuItem disabled>{t("integrations:noSavedQueriesYet")}</DropdownMenuItem>
|
||||
) : (
|
||||
saved.map((s) => {
|
||||
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
|
||||
const accessibleDeleteLabel = defaultMutationPending
|
||||
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
|
||||
: deleteLabel;
|
||||
return (
|
||||
<div key={s.id} role="none" className="group/saved flex items-center gap-0.5">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSelect({ kind: s.kind, source: "saved", id: s.id })}
|
||||
className="min-w-0 flex-1 cursor-pointer gap-2"
|
||||
>
|
||||
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{s.label}</span>
|
||||
</DropdownMenuItem>
|
||||
{onToggleSavedDefault && (
|
||||
<SavedQueryDefaultDropdownItem
|
||||
label={s.label}
|
||||
isDefault={s.isDefault === true}
|
||||
disabled={defaultMutationPending}
|
||||
pending={defaultMutationPendingId === s.id}
|
||||
testId={`saved-query-default-${s.id}`}
|
||||
onToggle={() => onToggleSavedDefault(s.id)}
|
||||
/>
|
||||
)}
|
||||
{/* A peer Radix item is intentional: arrow navigation reaches delete,
|
||||
and focus opacity reveals it without pointer hover. */}
|
||||
<DropdownMenuItem
|
||||
disabled={defaultMutationPending}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onDeleteSaved(s.id);
|
||||
}}
|
||||
className="h-7 min-h-7 w-7 shrink-0 cursor-pointer justify-center p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus:opacity-100 group-hover/saved:opacity-100 data-[disabled]:cursor-wait data-[disabled]:opacity-50 group-hover/saved:data-[disabled]:opacity-50"
|
||||
title={accessibleDeleteLabel}
|
||||
aria-label={accessibleDeleteLabel}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
saved.map((preset) => (
|
||||
<SavedMenuEntry
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isFinePointer={isFinePointer}
|
||||
deletion={deletion}
|
||||
defaultMutationPending={defaultMutationPending}
|
||||
defaultMutationPendingForPreset={defaultMutationPendingId === preset.id}
|
||||
onSelect={() => onSelect({ kind: preset.kind, source: "saved", id: preset.id })}
|
||||
onToggleDefault={
|
||||
onToggleSavedDefault ? () => onToggleSavedDefault(preset.id) : undefined
|
||||
}
|
||||
onDeleteSaved={onDeleteSaved}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -209,10 +223,112 @@ function SavedMenu<K extends string>({
|
||||
<span>{t("integrations:saveCurrentQuery")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
{isFinePointer && deletion.target ? (
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deletion.target}
|
||||
presentation="popover"
|
||||
open
|
||||
anchorRef={deletion.anchorRef}
|
||||
focusBoundaryRef={menuContentRef}
|
||||
confirmDisabled={defaultMutationPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) deletion.close();
|
||||
}}
|
||||
onConfirm={onDeleteSaved}
|
||||
/>
|
||||
) : null}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type SavedMenuDeletion = {
|
||||
target: SavedTaskViewDeleteTarget | null;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
close: () => void;
|
||||
registerAnchor: (id: string, element: HTMLElement | null) => void;
|
||||
request: (target: SavedTaskViewDeleteTarget) => void;
|
||||
};
|
||||
|
||||
function SavedMenuEntry({
|
||||
preset,
|
||||
isFinePointer,
|
||||
deletion,
|
||||
defaultMutationPending,
|
||||
defaultMutationPendingForPreset,
|
||||
onSelect,
|
||||
onToggleDefault,
|
||||
onDeleteSaved,
|
||||
}: {
|
||||
preset: ScopeSavedPreset<string>;
|
||||
isFinePointer: boolean;
|
||||
deletion: SavedMenuDeletion;
|
||||
defaultMutationPending: boolean;
|
||||
defaultMutationPendingForPreset: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleDefault?: () => void;
|
||||
onDeleteSaved: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!isFinePointer && deletion.target?.id === preset.id) {
|
||||
return (
|
||||
<div role="none" className="min-w-0 p-1">
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deletion.target}
|
||||
presentation="inline"
|
||||
open
|
||||
anchorRef={deletion.anchorRef}
|
||||
confirmDisabled={defaultMutationPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) deletion.close();
|
||||
}}
|
||||
onConfirm={onDeleteSaved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: preset.label });
|
||||
const accessibleDeleteLabel = defaultMutationPending
|
||||
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
|
||||
: deleteLabel;
|
||||
return (
|
||||
<div role="none" className="group/saved flex items-center gap-0.5">
|
||||
<DropdownMenuItem onSelect={onSelect} className="min-w-0 flex-1 cursor-pointer gap-2">
|
||||
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{preset.label}</span>
|
||||
</DropdownMenuItem>
|
||||
{onToggleDefault && (
|
||||
<SavedQueryDefaultDropdownItem
|
||||
label={preset.label}
|
||||
isDefault={preset.isDefault === true}
|
||||
disabled={defaultMutationPending}
|
||||
pending={defaultMutationPendingForPreset}
|
||||
testId={`saved-query-default-${preset.id}`}
|
||||
onToggle={onToggleDefault}
|
||||
/>
|
||||
)}
|
||||
{/* A peer Radix item keeps delete keyboard-reachable without selecting the preset. */}
|
||||
<DropdownMenuItem
|
||||
ref={(element) => deletion.registerAnchor(preset.id, element)}
|
||||
disabled={defaultMutationPending}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
deletion.request({ id: preset.id, label: preset.label });
|
||||
}}
|
||||
className={cn(
|
||||
"shrink-0 cursor-pointer justify-center p-0 text-muted-foreground transition-opacity hover:text-foreground data-[disabled]:cursor-wait data-[disabled]:opacity-50",
|
||||
isFinePointer
|
||||
? "h-7 min-h-7 w-7 opacity-0 focus:opacity-100 group-hover/saved:opacity-100 group-hover/saved:data-[disabled]:opacity-50"
|
||||
: "min-h-12 min-w-12 opacity-100",
|
||||
)}
|
||||
title={accessibleDeleteLabel}
|
||||
aria-label={accessibleDeleteLabel}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SavedDefaultActionProps =
|
||||
| {
|
||||
/** Emits only the stable id; domain wrappers own richer saved-query data. */
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_FILTERS } from "./filter-model";
|
||||
import type { SavedView } from "./use-saved-views";
|
||||
import { ListToolbar } from "./list-toolbar";
|
||||
|
||||
const responsive = vi.hoisted(() => ({ isFinePointer: false }));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => responsive,
|
||||
}));
|
||||
|
||||
const BUILTIN: SavedView = {
|
||||
id: "builtin:assigned",
|
||||
name: "",
|
||||
nameKey: "jira:builtinViewAssignedToMe",
|
||||
builtin: true,
|
||||
filters: DEFAULT_FILTERS,
|
||||
};
|
||||
const CUSTOM: SavedView = {
|
||||
id: "view-sprint-bugs",
|
||||
name: "Sprint bugs",
|
||||
filters: DEFAULT_FILTERS,
|
||||
};
|
||||
|
||||
function renderToolbar(onDeleteView = vi.fn()) {
|
||||
return {
|
||||
onDeleteView,
|
||||
...render(
|
||||
<ListToolbar
|
||||
searchText=""
|
||||
onSearchChange={vi.fn()}
|
||||
views={[BUILTIN, CUSTOM]}
|
||||
activeViewId={CUSTOM.id}
|
||||
onSelectView={vi.fn()}
|
||||
onDeleteView={onDeleteView}
|
||||
onSaveView={vi.fn()}
|
||||
count={1}
|
||||
loading={false}
|
||||
sort="updated"
|
||||
onSortChange={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
showJqlEditor={false}
|
||||
onToggleJqlEditor={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Jira ListToolbar saved views", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("keeps built-ins protected and confirms custom deletion inline on coarse pointers", async () => {
|
||||
const { onDeleteView } = renderToolbar();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sprint bugs" }));
|
||||
|
||||
expect(screen.getAllByTitle("Delete view")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByTitle("Delete view"));
|
||||
|
||||
expect(onDeleteView).not.toHaveBeenCalled();
|
||||
const confirmation = screen.getByRole("group", { name: "Delete Sprint bugs?" });
|
||||
expect(within(confirmation).getByRole("button", { name: "Cancel" }).className).toContain(
|
||||
"h-11",
|
||||
);
|
||||
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
|
||||
expect(onDeleteView).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Delete view"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete Sprint bugs" }));
|
||||
|
||||
await waitFor(() => expect(onDeleteView).toHaveBeenCalledWith(CUSTOM.id));
|
||||
expect(onDeleteView).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
IconArrowsSort,
|
||||
IconBookmark,
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
import { savedViewLabel, type SavedView } from "./use-saved-views";
|
||||
import type { SortKey } from "./filter-model";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
|
||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||
import {
|
||||
useSavedTaskViewDeleteConfirmation,
|
||||
type SavedTaskViewDeleteTarget,
|
||||
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||
|
||||
/** `value` is the persisted SortKey; only the catalog key is copy. */
|
||||
const SORT_OPTIONS: { value: SortKey; labelKey: string }[] = [
|
||||
@@ -172,18 +180,50 @@ function ViewsDropdown({
|
||||
activeName: string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
const [open, setOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const builtin = views.filter((v) => v.builtin);
|
||||
const custom = views.filter((v) => !v.builtin);
|
||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(custom);
|
||||
|
||||
const deleteProps: ViewDeletionProps = {
|
||||
isFinePointer,
|
||||
deleteTarget: deletion.target,
|
||||
deleteAnchorRef: deletion.anchorRef,
|
||||
onDeleteOpenChange: (nextOpen) => {
|
||||
if (!nextOpen) deletion.close();
|
||||
},
|
||||
onRequestDelete: (view) => deletion.request({ id: view.id, label: savedViewLabel(t, view) }),
|
||||
onConfirmDelete: onDelete,
|
||||
onRegisterDeleteAnchor: deletion.registerAnchor,
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) deletion.close();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer h-8 text-xs gap-1.5">
|
||||
<IconBookmark className="h-3.5 w-3.5" />
|
||||
{activeName ?? t("jira:noView")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-60 p-0">
|
||||
<PopoverContent
|
||||
ref={contentRef}
|
||||
align="start"
|
||||
className="w-60 max-w-[calc(100vw-1rem)] overflow-x-hidden p-0"
|
||||
onFocusOutside={(event) => {
|
||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ViewsGroup
|
||||
label={t("jira:builtIn")}
|
||||
views={builtin}
|
||||
@@ -192,7 +232,7 @@ function ViewsDropdown({
|
||||
onSelect(id);
|
||||
setOpen(false);
|
||||
}}
|
||||
onDelete={onDelete}
|
||||
{...deleteProps}
|
||||
/>
|
||||
{custom.length > 0 && (
|
||||
<>
|
||||
@@ -205,28 +245,56 @@ function ViewsDropdown({
|
||||
onSelect(id);
|
||||
setOpen(false);
|
||||
}}
|
||||
onDelete={onDelete}
|
||||
{...deleteProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
{isFinePointer && deletion.target ? (
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deletion.target}
|
||||
presentation="popover"
|
||||
open
|
||||
anchorRef={deletion.anchorRef}
|
||||
focusBoundaryRef={contentRef}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) deletion.close();
|
||||
}}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type ViewDeletionProps = {
|
||||
isFinePointer: boolean;
|
||||
deleteTarget: SavedTaskViewDeleteTarget | null;
|
||||
deleteAnchorRef: RefObject<HTMLElement | null>;
|
||||
onDeleteOpenChange: (open: boolean) => void;
|
||||
onRequestDelete: (view: SavedView) => void;
|
||||
onConfirmDelete: (id: string) => void;
|
||||
onRegisterDeleteAnchor: (id: string, element: HTMLButtonElement | null) => void;
|
||||
};
|
||||
|
||||
function ViewsGroup({
|
||||
label,
|
||||
views,
|
||||
activeViewId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
isFinePointer,
|
||||
deleteTarget,
|
||||
deleteAnchorRef,
|
||||
onDeleteOpenChange,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onRegisterDeleteAnchor,
|
||||
}: {
|
||||
label: string;
|
||||
views: SavedView[];
|
||||
activeViewId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
} & ViewDeletionProps) {
|
||||
return (
|
||||
<div className="py-1">
|
||||
<div className="px-3 py-1 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
@@ -238,7 +306,13 @@ function ViewsGroup({
|
||||
view={v}
|
||||
active={v.id === activeViewId}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
isFinePointer={isFinePointer}
|
||||
deleteTarget={deleteTarget}
|
||||
deleteAnchorRef={deleteAnchorRef}
|
||||
onDeleteOpenChange={onDeleteOpenChange}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onRegisterDeleteAnchor={onRegisterDeleteAnchor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -249,33 +323,62 @@ function ViewRow({
|
||||
view,
|
||||
active,
|
||||
onSelect,
|
||||
onDelete,
|
||||
isFinePointer,
|
||||
deleteTarget,
|
||||
deleteAnchorRef,
|
||||
onDeleteOpenChange,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onRegisterDeleteAnchor,
|
||||
}: {
|
||||
view: SavedView;
|
||||
active: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
} & ViewDeletionProps) {
|
||||
const { t } = useTranslation();
|
||||
const label = savedViewLabel(t, view);
|
||||
|
||||
if (!isFinePointer && deleteTarget?.id === view.id) {
|
||||
return (
|
||||
<div className="min-w-0 px-2 py-1">
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deleteTarget}
|
||||
presentation="inline"
|
||||
open
|
||||
anchorRef={deleteAnchorRef}
|
||||
onOpenChange={onDeleteOpenChange}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-center px-2">
|
||||
<div className="group flex min-w-0 items-center px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(view.id)}
|
||||
className="flex-1 flex items-center gap-2 px-2 py-1.5 text-sm cursor-pointer rounded hover:bg-muted/50"
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
|
||||
>
|
||||
<IconCheck className={`h-3.5 w-3.5 ${active ? "opacity-100" : "opacity-0"}`} />
|
||||
<span className="truncate">{savedViewLabel(t, view)}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
{!view.builtin && (
|
||||
<button
|
||||
ref={(element) => onRegisterDeleteAnchor(view.id, element)}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(view.id);
|
||||
onRequestDelete(view);
|
||||
}}
|
||||
className="cursor-pointer opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-muted"
|
||||
className={cn(
|
||||
"flex shrink-0 cursor-pointer items-center justify-center rounded hover:bg-muted",
|
||||
isFinePointer
|
||||
? "h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
: "h-12 w-12 opacity-100",
|
||||
)}
|
||||
title={t("jira:deleteView")}
|
||||
aria-label={t("common:deleteSavedTaskViewAction", { name: label })}
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
|
||||
import { SidebarFilterPopover } from "./sidebar-filter-popover";
|
||||
|
||||
const responsive = vi.hoisted(() => ({
|
||||
usesDesktopWorkbench: true,
|
||||
isFinePointer: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => responsive,
|
||||
}));
|
||||
|
||||
const VIEW: SidebarView = {
|
||||
id: "view-all",
|
||||
name: "All tasks",
|
||||
@@ -18,6 +27,12 @@ const VIEW: SidebarView = {
|
||||
},
|
||||
};
|
||||
|
||||
const SECOND_VIEW: SidebarView = {
|
||||
...VIEW,
|
||||
id: "view-review",
|
||||
name: "Needs review",
|
||||
};
|
||||
|
||||
const state = {
|
||||
sidebarViews: {
|
||||
views: [VIEW],
|
||||
@@ -47,9 +62,58 @@ vi.mock("@/components/state-provider", () => ({
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
state.sidebarViews.views = [VIEW];
|
||||
state.sidebarViews.activeViewId = VIEW.id;
|
||||
responsive.usesDesktopWorkbench = true;
|
||||
responsive.isFinePointer = true;
|
||||
});
|
||||
|
||||
describe("SidebarFilterPopover task-row editor", () => {
|
||||
it("waits for named confirmation before deleting the active view", async () => {
|
||||
state.sidebarViews.views = [VIEW, SECOND_VIEW];
|
||||
render(
|
||||
<SidebarFilterPopover
|
||||
trigger={<button type="button">Open</button>}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-delete-button"));
|
||||
|
||||
expect(state.deleteSidebarView).not.toHaveBeenCalled();
|
||||
const confirmation = await screen.findByRole("dialog", { name: "Delete All tasks?" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(confirmation.isConnected).toBe(false);
|
||||
expect(state.deleteSidebarView).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-delete-button"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete All tasks" }));
|
||||
|
||||
await waitFor(() => expect(state.deleteSidebarView).toHaveBeenCalledWith(VIEW.id));
|
||||
expect(state.deleteSidebarView).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps deletion touch-reachable in a phone drawer with a fine pointer", async () => {
|
||||
responsive.usesDesktopWorkbench = false;
|
||||
responsive.isFinePointer = true;
|
||||
state.sidebarViews.views = [VIEW, SECOND_VIEW];
|
||||
render(
|
||||
<SidebarFilterPopover
|
||||
trigger={<button type="button">Open</button>}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId("view-delete-button");
|
||||
expect(deleteButton.className).toContain("min-h-11");
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(state.deleteSidebarView).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole("group", { name: "Delete All tasks?" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps view settings collapsed until the user opens them", () => {
|
||||
render(
|
||||
<SidebarFilterPopover
|
||||
@@ -94,7 +158,9 @@ describe("SidebarFilterPopover task-row editor", () => {
|
||||
"border-t",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarFilterPopover option details", () => {
|
||||
it("removes the popover primitive's default section gap", () => {
|
||||
render(
|
||||
<SidebarFilterPopover
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { useRef, type ComponentProps, type RefObject } from "react";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from "@kandev/ui/drawer";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
} from "./sidebar-view-editor";
|
||||
import { ViewHeaderRow } from "./view-manager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
|
||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||
import {
|
||||
useSavedTaskViewDeleteConfirmation,
|
||||
type SavedTaskViewDeleteTarget,
|
||||
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
|
||||
|
||||
type Props = {
|
||||
trigger: React.ReactNode;
|
||||
@@ -22,6 +29,15 @@ type Props = {
|
||||
onRenameRequestHandled?: (viewId: string) => void;
|
||||
};
|
||||
|
||||
type SidebarFilterSurfaceProps = Pick<Props, "trigger" | "open" | "onOpenChange"> & {
|
||||
editor: React.ReactNode;
|
||||
deletion: {
|
||||
target: SavedTaskViewDeleteTarget | null;
|
||||
anchorRef: RefObject<HTMLButtonElement | null>;
|
||||
close: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function SidebarFilterPopover({
|
||||
trigger,
|
||||
open,
|
||||
@@ -39,11 +55,27 @@ export function SidebarFilterPopover({
|
||||
const discard = useAppStore((s) => s.discardSidebarDraft);
|
||||
const deleteView = useAppStore((s) => s.deleteSidebarView);
|
||||
const renameView = useAppStore((s) => s.renameSidebarView);
|
||||
const { usesDesktopWorkbench } = useResponsiveBreakpoint();
|
||||
const { usesDesktopWorkbench, isFinePointer } = useResponsiveBreakpoint();
|
||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(views);
|
||||
const popoverContentRef = useRef<HTMLDivElement>(null);
|
||||
const activeView: SidebarView | undefined = views.find((view) => view.id === activeViewId);
|
||||
const current = createSidebarViewEditorCurrent(activeView, storedDraft);
|
||||
const hasDraft = !!storedDraft && activeView?.id === storedDraft.baseViewId;
|
||||
const usesInlineDeleteConfirmation = !usesDesktopWorkbench || !isFinePointer;
|
||||
|
||||
const inlineDeleteConfirmation =
|
||||
usesInlineDeleteConfirmation && deletion.target ? (
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deletion.target}
|
||||
presentation="inline"
|
||||
open
|
||||
anchorRef={deletion.anchorRef}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) deletion.close();
|
||||
}}
|
||||
onConfirm={deleteView}
|
||||
/>
|
||||
) : undefined;
|
||||
const headerProps: ComponentProps<typeof ViewHeaderRow> = {
|
||||
activeView,
|
||||
hasDraft,
|
||||
@@ -52,7 +84,13 @@ export function SidebarFilterPopover({
|
||||
onSaveAs: saveAs,
|
||||
onRename: renameView,
|
||||
onDiscard: discard,
|
||||
onDelete: () => activeView && deleteView(activeView.id),
|
||||
onDelete: () => {
|
||||
if (!activeView) return;
|
||||
deletion.request({ id: activeView.id, label: sidebarViewName(activeView, t) });
|
||||
},
|
||||
deleteAnchorRef: deletion.anchorRef,
|
||||
deleteDensity: usesInlineDeleteConfirmation ? "touch" : "compact",
|
||||
deleteConfirmation: inlineDeleteConfirmation,
|
||||
renameRequestedViewId,
|
||||
onRenameRequestHandled,
|
||||
};
|
||||
@@ -78,29 +116,111 @@ export function SidebarFilterPopover({
|
||||
|
||||
if (usesDesktopWorkbench) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="max-h-[var(--radix-popper-available-height)] w-[calc(100vw-1rem)] max-w-[22rem] overflow-y-auto gap-0 p-0"
|
||||
style={{ maxHeight: "var(--radix-popper-available-height, calc(100dvh - 1rem))" }}
|
||||
align="end"
|
||||
data-testid="sidebar-filter-popover"
|
||||
>
|
||||
{editor}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DesktopSidebarFilterSurface
|
||||
trigger={trigger}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
editor={editor}
|
||||
deletion={deletion}
|
||||
contentRef={popoverContentRef}
|
||||
isFinePointer={isFinePointer}
|
||||
onDelete={deleteView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<MobileSidebarFilterSurface
|
||||
trigger={trigger}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
editor={editor}
|
||||
deletion={deletion}
|
||||
title={t("task:sidebarFilters")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopSidebarFilterSurface({
|
||||
trigger,
|
||||
open,
|
||||
onOpenChange,
|
||||
editor,
|
||||
deletion,
|
||||
contentRef,
|
||||
isFinePointer,
|
||||
onDelete,
|
||||
}: SidebarFilterSurfaceProps & {
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
isFinePointer: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) deletion.close();
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
ref={contentRef}
|
||||
className="max-h-[var(--radix-popper-available-height)] w-[calc(100vw-1rem)] max-w-[22rem] overflow-y-auto gap-0 p-0"
|
||||
style={{ maxHeight: "var(--radix-popper-available-height, calc(100dvh - 1rem))" }}
|
||||
align="end"
|
||||
data-testid="sidebar-filter-popover"
|
||||
onFocusOutside={(event) => {
|
||||
if (deletion.target && isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (deletion.target && isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{editor}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{isFinePointer && deletion.target ? (
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deletion.target}
|
||||
presentation="popover"
|
||||
open
|
||||
anchorRef={deletion.anchorRef}
|
||||
focusBoundaryRef={contentRef}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) deletion.close();
|
||||
}}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebarFilterSurface({
|
||||
trigger,
|
||||
open,
|
||||
onOpenChange,
|
||||
editor,
|
||||
deletion,
|
||||
title,
|
||||
}: SidebarFilterSurfaceProps & { title: string }) {
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) deletion.close();
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent
|
||||
data-testid="sidebar-filter-drawer"
|
||||
className="h-[min(90dvh,48rem)] max-h-[calc(100dvh-1rem)] overflow-hidden rounded-t-xl"
|
||||
>
|
||||
<DrawerHeader className="shrink-0 border-b px-4 pb-3 pt-5 text-left">
|
||||
<DrawerTitle>{t("task:sidebarFilters")}</DrawerTitle>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div
|
||||
data-testid="sidebar-filter-popover"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
|
||||
@@ -18,6 +25,9 @@ type HeaderProps = {
|
||||
onRename: (id: string, name: string) => void;
|
||||
onDiscard: () => void;
|
||||
onDelete: () => void;
|
||||
deleteAnchorRef?: RefObject<HTMLButtonElement | null>;
|
||||
deleteDensity?: "compact" | "touch";
|
||||
deleteConfirmation?: ReactNode;
|
||||
renameRequestedViewId?: string | null;
|
||||
onRenameRequestHandled?: (viewId: string) => void;
|
||||
};
|
||||
@@ -71,6 +81,10 @@ export function ViewHeaderRow(props: HeaderProps) {
|
||||
setEditingViewId(null);
|
||||
}, [activeViewId, editingViewId, mode]);
|
||||
|
||||
if (props.deleteConfirmation) {
|
||||
return <div className="min-w-0 p-1">{props.deleteConfirmation}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 text-xs">
|
||||
@@ -222,6 +236,8 @@ function ViewActions({
|
||||
onRename,
|
||||
onDiscard,
|
||||
onDelete,
|
||||
deleteAnchorRef,
|
||||
deleteDensity = "compact",
|
||||
}: {
|
||||
activeView: SidebarView | undefined;
|
||||
hasDraft: boolean;
|
||||
@@ -231,6 +247,8 @@ function ViewActions({
|
||||
onRename: () => void;
|
||||
onDiscard: () => void;
|
||||
onDelete: () => void;
|
||||
deleteAnchorRef?: RefObject<HTMLButtonElement | null>;
|
||||
deleteDensity?: "compact" | "touch";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const canOverwrite = hasDraft && !!activeView;
|
||||
@@ -286,10 +304,11 @@ function ViewActions({
|
||||
)}
|
||||
{!hasDraft && activeView && canDelete && (
|
||||
<Button
|
||||
ref={deleteAnchorRef}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 cursor-pointer text-xs text-destructive"
|
||||
className={`${deleteDensity === "touch" ? "min-h-11" : "h-6"} cursor-pointer text-xs text-destructive`}
|
||||
onClick={onDelete}
|
||||
data-testid="view-delete-button"
|
||||
>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ThreadView, ThreadViewDraft } from "@/lib/state/slices/ui/thread-view-types";
|
||||
import { ThreadsViewControls } from "./threads-view-controls";
|
||||
|
||||
const responsive = vi.hoisted(() => ({ usesDesktopWorkbench: true }));
|
||||
const responsive = vi.hoisted(() => ({ usesDesktopWorkbench: true, isFinePointer: true }));
|
||||
const EMPTY_CANDIDATES: never[] = [];
|
||||
const VIEW_PICKER_TEST_ID = "threads-view-picker";
|
||||
const DELETE_ACTION_TEST_ID = "threads-view-delete";
|
||||
const MOBILE_DRAWER_TEST_ID = "threads-mobile-view-drawer";
|
||||
|
||||
const ALL_VIEW: ThreadView = {
|
||||
id: "view-all-threads",
|
||||
@@ -56,11 +58,73 @@ afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
responsive.usesDesktopWorkbench = true;
|
||||
responsive.isFinePointer = true;
|
||||
state.threadViews.draft = null;
|
||||
state.threadViews.syncError = null;
|
||||
});
|
||||
|
||||
describe("ThreadsViewControls", () => {
|
||||
it("deletes the captured desktop view only after named confirmation", async () => {
|
||||
render(
|
||||
<ThreadsViewControls
|
||||
candidates={EMPTY_CANDIDATES}
|
||||
admittedCount={0}
|
||||
matchingCount={0}
|
||||
hiddenCount={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("threads-view-settings"));
|
||||
fireEvent.click(screen.getByTestId(DELETE_ACTION_TEST_ID));
|
||||
|
||||
expect(state.deleteThreadView).not.toHaveBeenCalled();
|
||||
const dialog = await screen.findByRole("dialog", { name: "Delete All threads?" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(state.deleteThreadView).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("threads-view-settings-popover")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByTestId(DELETE_ACTION_TEST_ID));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete All threads" }));
|
||||
|
||||
await waitFor(() => expect(state.deleteThreadView).toHaveBeenCalledWith(ALL_VIEW.id));
|
||||
expect(state.deleteThreadView).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(screen.queryByTestId("threads-view-settings-popover")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps mobile deletion inline until the user confirms", async () => {
|
||||
responsive.usesDesktopWorkbench = false;
|
||||
responsive.isFinePointer = false;
|
||||
render(
|
||||
<ThreadsViewControls
|
||||
candidates={EMPTY_CANDIDATES}
|
||||
admittedCount={0}
|
||||
matchingCount={0}
|
||||
hiddenCount={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("threads-mobile-view-trigger"));
|
||||
fireEvent.click(await screen.findByTestId("threads-mobile-view-settings"));
|
||||
fireEvent.click(await screen.findByTestId(DELETE_ACTION_TEST_ID));
|
||||
|
||||
expect(state.deleteThreadView).not.toHaveBeenCalled();
|
||||
const confirmation = screen.getByRole("group", { name: "Delete All threads?" });
|
||||
expect(within(confirmation).getByRole("button", { name: "Cancel" }).className).toContain(
|
||||
"h-11",
|
||||
);
|
||||
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByTestId(MOBILE_DRAWER_TEST_ID).dataset.state).toBe("open");
|
||||
|
||||
fireEvent.click(screen.getByTestId(DELETE_ACTION_TEST_ID));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete All threads" }));
|
||||
|
||||
await waitFor(() => expect(state.deleteThreadView).toHaveBeenCalledWith(ALL_VIEW.id));
|
||||
expect(state.deleteThreadView).toHaveBeenCalledOnce();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId(MOBILE_DRAWER_TEST_ID).dataset.state).toBe("closed"),
|
||||
);
|
||||
});
|
||||
|
||||
it("switches saved views from the compact selector and shows bounded counts", async () => {
|
||||
render(
|
||||
<ThreadsViewControls
|
||||
@@ -99,7 +163,9 @@ describe("ThreadsViewControls", () => {
|
||||
expect.objectContaining({ filters: expect.arrayContaining([expect.any(Object)]) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThreadsViewControls mobile composition", () => {
|
||||
it("keeps the saved-view surface independent from sidebar view state", async () => {
|
||||
render(
|
||||
<ThreadsViewControls
|
||||
@@ -133,7 +199,7 @@ describe("ThreadsViewControls", () => {
|
||||
expect(screen.queryByTestId(VIEW_PICKER_TEST_ID)).toBeNull();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(await screen.findByTestId("threads-mobile-view-drawer")).toBeTruthy();
|
||||
expect(await screen.findByTestId(MOBILE_DRAWER_TEST_ID)).toBeTruthy();
|
||||
fireEvent.click(await screen.findByTestId("threads-mobile-view-option-view-review"));
|
||||
|
||||
expect(state.setThreadActiveView).toHaveBeenCalledWith(REVIEW_VIEW.id);
|
||||
@@ -165,6 +231,6 @@ describe("ThreadsViewControls", () => {
|
||||
expect(screen.getByTestId("threads-task-picker")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId("threads-task-picker-back"));
|
||||
expect(screen.getByTestId("threads-view-editor")).toBeTruthy();
|
||||
expect(screen.getByTestId("threads-mobile-view-drawer")).toBeTruthy();
|
||||
expect(screen.getByTestId(MOBILE_DRAWER_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/dra
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import {
|
||||
DEFAULT_THREAD_VIEW,
|
||||
@@ -65,6 +66,7 @@ export function ThreadsViewControls({
|
||||
const clearSyncError = useAppStore((state) => state.clearThreadViewSyncError);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const openSettingsAfterPickerClose = useRef(false);
|
||||
const settingsContentRef = useRef<HTMLDivElement>(null);
|
||||
const activeView = useMemo(
|
||||
() => views.find((view) => view.id === activeViewId) ?? DEFAULT_THREAD_VIEW,
|
||||
[activeViewId, views],
|
||||
@@ -105,7 +107,7 @@ export function ThreadsViewControls({
|
||||
onSaveAs={saveAs}
|
||||
onDiscard={discard}
|
||||
onRename={(name) => renameView(activeView.id, name)}
|
||||
onDelete={() => deleteView(activeView.id)}
|
||||
onDelete={deleteView}
|
||||
onDuplicate={() => duplicateView(activeView.id, "")}
|
||||
onReapplySort={reapplySort}
|
||||
onRetrySync={retrySync}
|
||||
@@ -192,9 +194,16 @@ export function ThreadsViewControls({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
ref={settingsContentRef}
|
||||
align="start"
|
||||
className="max-h-[calc(100dvh-1rem)] w-[min(42rem,calc(100vw-1rem))] overflow-y-auto border border-border/80 p-0 shadow-xl ring-1 ring-foreground/20"
|
||||
data-testid="threads-view-settings-popover"
|
||||
onFocusOutside={(event) => {
|
||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ThreadsViewEditor
|
||||
activeView={activeView}
|
||||
@@ -208,8 +217,9 @@ export function ThreadsViewControls({
|
||||
onSaveAs={saveAs}
|
||||
onDiscard={discard}
|
||||
onRename={(name) => renameView(activeView.id, name)}
|
||||
onDelete={() => {
|
||||
deleteView(activeView.id);
|
||||
deleteFocusBoundaryRef={settingsContentRef}
|
||||
onDelete={(viewId) => {
|
||||
deleteView(viewId);
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
onDuplicate={() => duplicateView(activeView.id, "")}
|
||||
@@ -254,7 +264,7 @@ type MobileThreadsViewControlsProps = {
|
||||
onSaveAs: (name: string) => void;
|
||||
onDiscard: () => void;
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onDelete: (viewId: string) => void;
|
||||
onDuplicate: () => void;
|
||||
onReapplySort: () => void;
|
||||
onRetrySync: () => void;
|
||||
@@ -407,8 +417,8 @@ function MobileThreadsViewControls({
|
||||
onSaveAs={onSaveAs}
|
||||
onDiscard={onDiscard}
|
||||
onRename={onRename}
|
||||
onDelete={() => {
|
||||
onDelete();
|
||||
onDelete={(viewId) => {
|
||||
onDelete(viewId);
|
||||
closeDrawer();
|
||||
}}
|
||||
onDuplicate={onDuplicate}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { EditorActions } from "./threads-view-editor-actions";
|
||||
|
||||
describe("EditorActions", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("anchors delete requests and lets inline confirmation replace the action row", () => {
|
||||
const onDelete = vi.fn();
|
||||
const { rerender } = render(
|
||||
<EditorActions
|
||||
hasDraft={false}
|
||||
canDelete
|
||||
viewCount={2}
|
||||
invalidDraft={false}
|
||||
onSave={vi.fn()}
|
||||
onSaveAs={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("threads-view-delete"));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
|
||||
rerender(
|
||||
<EditorActions
|
||||
hasDraft={false}
|
||||
canDelete
|
||||
viewCount={2}
|
||||
invalidDraft={false}
|
||||
deleteConfirmation={<div data-testid="inline-delete-confirmation" />}
|
||||
onSave={vi.fn()}
|
||||
onSaveAs={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("inline-delete-confirmation")).toBeTruthy();
|
||||
expect(screen.queryByTestId("threads-view-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { MAX_THREAD_VIEWS } from "@/lib/state/slices/ui/thread-view-builtins";
|
||||
@@ -13,6 +14,8 @@ export function EditorActions({
|
||||
onSaveAs,
|
||||
onDiscard,
|
||||
onDelete,
|
||||
deleteAnchorRef,
|
||||
deleteConfirmation,
|
||||
}: {
|
||||
hasDraft: boolean;
|
||||
canDelete: boolean;
|
||||
@@ -22,8 +25,15 @@ export function EditorActions({
|
||||
onSaveAs: () => void;
|
||||
onDiscard: () => void;
|
||||
onDelete: () => void;
|
||||
deleteAnchorRef?: RefObject<HTMLButtonElement | null>;
|
||||
deleteConfirmation?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (deleteConfirmation) {
|
||||
return <div className="min-w-0 p-2">{deleteConfirmation}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 p-2">
|
||||
{hasDraft && (
|
||||
@@ -66,6 +76,7 @@ export function EditorActions({
|
||||
)}
|
||||
{!hasDraft && canDelete && (
|
||||
<Button
|
||||
ref={deleteAnchorRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -52,6 +53,8 @@ export type EditorBodyProps = {
|
||||
onRename: (name: string) => void;
|
||||
onDiscard: () => void;
|
||||
onDelete: () => void;
|
||||
deleteAnchorRef?: RefObject<HTMLButtonElement | null>;
|
||||
deleteConfirmation?: ReactNode;
|
||||
onDuplicate: () => void;
|
||||
onReapplySort: () => void;
|
||||
onSetMaxColumns: (value: string, badInput?: boolean) => void;
|
||||
@@ -82,6 +85,8 @@ export function EditorBody({
|
||||
onRename,
|
||||
onDiscard,
|
||||
onDelete,
|
||||
deleteAnchorRef,
|
||||
deleteConfirmation,
|
||||
onDuplicate,
|
||||
onReapplySort,
|
||||
onSetMaxColumns,
|
||||
@@ -124,6 +129,8 @@ export function EditorBody({
|
||||
onSave={onSave}
|
||||
onDiscard={onDiscard}
|
||||
onDelete={onDelete}
|
||||
deleteAnchorRef={deleteAnchorRef}
|
||||
deleteConfirmation={deleteConfirmation}
|
||||
onNameChange={onNameChange}
|
||||
onNameModeChange={onNameModeChange}
|
||||
onReapplySort={onReapplySort}
|
||||
@@ -151,6 +158,8 @@ type EditorSectionsProps = Pick<
|
||||
| "onSave"
|
||||
| "onDiscard"
|
||||
| "onDelete"
|
||||
| "deleteAnchorRef"
|
||||
| "deleteConfirmation"
|
||||
| "onNameChange"
|
||||
| "onNameModeChange"
|
||||
| "onReapplySort"
|
||||
@@ -174,6 +183,8 @@ function EditorSections({
|
||||
onSave,
|
||||
onDiscard,
|
||||
onDelete,
|
||||
deleteAnchorRef,
|
||||
deleteConfirmation,
|
||||
onNameChange,
|
||||
onNameModeChange,
|
||||
onReapplySort,
|
||||
@@ -232,6 +243,8 @@ function EditorSections({
|
||||
}}
|
||||
onDiscard={onDiscard}
|
||||
onDelete={onDelete}
|
||||
deleteAnchorRef={deleteAnchorRef}
|
||||
deleteConfirmation={deleteConfirmation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||
import {
|
||||
useSavedTaskViewDeleteConfirmation,
|
||||
type SavedTaskViewDeleteTarget,
|
||||
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||
import type { ThreadCandidate } from "@/lib/threads/thread-view-query";
|
||||
import type { ThreadView, ThreadViewDraft } from "@/lib/state/slices/ui/thread-view-types";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import { ThreadsViewTaskPicker } from "./threads-view-task-picker";
|
||||
import { EditorBody, type EditorBodyProps } from "./threads-view-editor-sections";
|
||||
import { parseThreadMaxColumns } from "./threads-view-editor-utils";
|
||||
import { threadViewName } from "@/lib/state/slices/ui/thread-view-builtins";
|
||||
|
||||
type EditorProps = {
|
||||
activeView: ThreadView;
|
||||
@@ -20,11 +27,12 @@ type EditorProps = {
|
||||
onSaveAs: (name: string) => void;
|
||||
onDiscard: () => void;
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onDelete: (viewId: string) => void;
|
||||
onDuplicate: () => void;
|
||||
onReapplySort: () => void;
|
||||
showHeader?: boolean;
|
||||
mobile?: boolean;
|
||||
deleteFocusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export function ThreadsViewEditor({
|
||||
@@ -44,7 +52,9 @@ export function ThreadsViewEditor({
|
||||
onReapplySort,
|
||||
showHeader = true,
|
||||
mobile = false,
|
||||
deleteFocusBoundaryRef,
|
||||
}: EditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [nameMode, setNameMode] = useState<"rename" | "saveAs" | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
@@ -53,17 +63,20 @@ export function ThreadsViewEditor({
|
||||
formatMaxColumns(current.maxColumns),
|
||||
);
|
||||
const [maxColumnsInvalid, setMaxColumnsInvalid] = useState(false);
|
||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>([activeView]);
|
||||
const invalidSelectedScope =
|
||||
current.taskScope.mode === "selected" && current.taskScope.taskIds.length === 0;
|
||||
const hasDraft = !!draft && draft.baseViewId === activeView.id;
|
||||
const repositoryNames = useMemo(
|
||||
() =>
|
||||
new Map<string, string>(
|
||||
repositories.map((repository) => [String(repository.id), repository.name]),
|
||||
),
|
||||
[repositories],
|
||||
);
|
||||
const repositoryNames = useMemo(() => mapRepositoryNames(repositories), [repositories]);
|
||||
const invalidDraft = invalidSelectedScope || maxColumnsInvalid;
|
||||
const deleteConfirmation = deletion.target ? (
|
||||
<ThreadViewDeleteConfirmation
|
||||
deletion={deletion}
|
||||
mobile={mobile}
|
||||
focusBoundaryRef={deleteFocusBoundaryRef}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
useEffect(() => {
|
||||
setMaxColumnsInput(formatMaxColumns(current.maxColumns));
|
||||
@@ -72,55 +85,120 @@ export function ThreadsViewEditor({
|
||||
|
||||
if (pickerOpen) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col" data-testid="threads-view-editor">
|
||||
<ThreadsViewTaskPicker
|
||||
candidates={candidates}
|
||||
selectedTaskIds={current.taskScope.mode === "selected" ? current.taskScope.taskIds : []}
|
||||
onChange={(taskIds) => onUpdate({ taskScope: { mode: "selected", taskIds } })}
|
||||
onBack={() => setPickerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<TaskPickerEditor
|
||||
candidates={candidates}
|
||||
current={current}
|
||||
onUpdate={onUpdate}
|
||||
onBack={() => setPickerOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorBody
|
||||
activeView={activeView}
|
||||
current={current}
|
||||
candidates={candidates}
|
||||
repositoryNames={repositoryNames}
|
||||
mobile={mobile}
|
||||
showHeader={showHeader}
|
||||
nameMode={nameMode}
|
||||
name={name}
|
||||
maxColumnsInput={maxColumnsInput}
|
||||
maxColumnsInvalid={maxColumnsInvalid}
|
||||
invalidSelectedScope={invalidSelectedScope}
|
||||
invalidDraft={invalidDraft}
|
||||
hasDraft={hasDraft}
|
||||
viewCount={viewCount}
|
||||
canDelete={canDelete}
|
||||
onNameChange={setName}
|
||||
onNameModeChange={setNameMode}
|
||||
onUpdate={onUpdate}
|
||||
onSave={onSave}
|
||||
onSaveAs={onSaveAs}
|
||||
onRename={onRename}
|
||||
onDiscard={onDiscard}
|
||||
onDelete={onDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
onReapplySort={onReapplySort}
|
||||
onSetMaxColumns={(value, badInput = false) => {
|
||||
setMaxColumnsInput(value);
|
||||
const parsed = parseThreadMaxColumns(value, badInput);
|
||||
setMaxColumnsInvalid(parsed === undefined);
|
||||
if (parsed !== undefined) onUpdate({ maxColumns: parsed });
|
||||
<>
|
||||
<EditorBody
|
||||
activeView={activeView}
|
||||
current={current}
|
||||
candidates={candidates}
|
||||
repositoryNames={repositoryNames}
|
||||
mobile={mobile}
|
||||
showHeader={showHeader}
|
||||
nameMode={nameMode}
|
||||
name={name}
|
||||
maxColumnsInput={maxColumnsInput}
|
||||
maxColumnsInvalid={maxColumnsInvalid}
|
||||
invalidSelectedScope={invalidSelectedScope}
|
||||
invalidDraft={invalidDraft}
|
||||
hasDraft={hasDraft}
|
||||
viewCount={viewCount}
|
||||
canDelete={canDelete}
|
||||
onNameChange={setName}
|
||||
onNameModeChange={setNameMode}
|
||||
onUpdate={onUpdate}
|
||||
onSave={onSave}
|
||||
onSaveAs={onSaveAs}
|
||||
onRename={onRename}
|
||||
onDiscard={onDiscard}
|
||||
onDelete={() =>
|
||||
deletion.request({ id: activeView.id, label: threadViewName(activeView, t) })
|
||||
}
|
||||
deleteAnchorRef={deletion.anchorRef}
|
||||
deleteConfirmation={mobile ? deleteConfirmation : undefined}
|
||||
onDuplicate={onDuplicate}
|
||||
onReapplySort={onReapplySort}
|
||||
onSetMaxColumns={(value, badInput = false) => {
|
||||
setMaxColumnsInput(value);
|
||||
const parsed = parseThreadMaxColumns(value, badInput);
|
||||
setMaxColumnsInvalid(parsed === undefined);
|
||||
if (parsed !== undefined) onUpdate({ maxColumns: parsed });
|
||||
}}
|
||||
onOpenPicker={() => setPickerOpen(true)}
|
||||
/>
|
||||
{!mobile ? deleteConfirmation : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadViewDeleteConfirmation({
|
||||
deletion,
|
||||
mobile,
|
||||
focusBoundaryRef,
|
||||
onConfirm,
|
||||
}: {
|
||||
deletion: {
|
||||
target: SavedTaskViewDeleteTarget | null;
|
||||
anchorRef: RefObject<HTMLButtonElement | null>;
|
||||
close: () => void;
|
||||
};
|
||||
mobile: boolean;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
onConfirm: (id: string) => void;
|
||||
}) {
|
||||
if (!deletion.target) return null;
|
||||
return (
|
||||
<SavedTaskViewDeleteConfirmation
|
||||
target={deletion.target}
|
||||
presentation={mobile ? "inline" : "popover"}
|
||||
open
|
||||
anchorRef={deletion.anchorRef}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) deletion.close();
|
||||
}}
|
||||
onOpenPicker={() => setPickerOpen(true)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskPickerEditor({
|
||||
candidates,
|
||||
current,
|
||||
onUpdate,
|
||||
onBack,
|
||||
}: {
|
||||
candidates: ThreadCandidate[];
|
||||
current: ThreadView | ThreadViewDraft;
|
||||
onUpdate: EditorBodyProps["onUpdate"];
|
||||
onBack: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col" data-testid="threads-view-editor">
|
||||
<ThreadsViewTaskPicker
|
||||
candidates={candidates}
|
||||
selectedTaskIds={current.taskScope.mode === "selected" ? current.taskScope.taskIds : []}
|
||||
onChange={(taskIds) => onUpdate({ taskScope: { mode: "selected", taskIds } })}
|
||||
onBack={onBack}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mapRepositoryNames(
|
||||
repositories: ReadonlyArray<Pick<Repository, "id" | "name">>,
|
||||
): Map<string, string> {
|
||||
return new Map(repositories.map((repository) => [String(repository.id), repository.name]));
|
||||
}
|
||||
|
||||
function formatMaxColumns(value: number | null): string {
|
||||
return value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
@@ -240,7 +240,23 @@ export class SidebarFilterPopoverPage {
|
||||
await this.popover.getByTestId("view-discard-button").click();
|
||||
}
|
||||
|
||||
async deleteActiveView(): Promise<void> {
|
||||
get deleteConfirmation(): Locator {
|
||||
return this.page.getByTestId("saved-task-view-delete-confirmation");
|
||||
}
|
||||
|
||||
async beginDeleteActiveView(name: string): Promise<void> {
|
||||
await this.popover.getByTestId("view-delete-button").click();
|
||||
await expect(this.deleteConfirmation).toBeVisible();
|
||||
await expect(this.deleteConfirmation).toHaveAccessibleName(`Delete ${name}?`);
|
||||
}
|
||||
|
||||
async cancelDeleteActiveView(): Promise<void> {
|
||||
await this.deleteConfirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(this.deleteConfirmation).toBeHidden();
|
||||
}
|
||||
|
||||
async confirmDeleteActiveView(name: string): Promise<void> {
|
||||
await this.deleteConfirmation.getByRole("button", { name: `Delete ${name}` }).click();
|
||||
await expect(this.deleteConfirmation).toBeHidden();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,26 @@ test.describe("Desktop /github scope bar", () => {
|
||||
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
|
||||
await expect(title).toContainText("Assigned");
|
||||
await expect(repoFilter).toContainText("All repos");
|
||||
|
||||
await savedMenu.click();
|
||||
await deleteIssueSavedQuery.click();
|
||||
const deleteConfirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(deleteConfirmation).toHaveAccessibleName(`Delete ${issueDefaultLabel}?`);
|
||||
await expect(testPage.getByRole("menu")).toBeVisible();
|
||||
await deleteConfirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(issueSavedQuery).toBeVisible();
|
||||
await expect(testPage.getByRole("menu")).toBeVisible();
|
||||
|
||||
await deleteIssueSavedQuery.click();
|
||||
const deleteResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.url().includes("/api/v1/github/workspace-settings"),
|
||||
);
|
||||
await deleteConfirmation.getByRole("button", { name: `Delete ${issueDefaultLabel}` }).click();
|
||||
await deleteResponse;
|
||||
await expect(issueSavedQuery).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ test.describe("Mobile /github sidebar", () => {
|
||||
test("saved query defaults to its chosen repository and persists by touch", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
prCapture,
|
||||
}) => {
|
||||
const savedQuery = "Mobile default repo issues";
|
||||
await apiClient.mockGitHubReset();
|
||||
@@ -264,5 +265,39 @@ test.describe("Mobile /github sidebar", () => {
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.mobileMenuButton.tap();
|
||||
const deleteAction = page.mobileSidebar.getByRole("button", {
|
||||
name: `Delete ${savedQuery} saved query`,
|
||||
});
|
||||
const deleteBox = await deleteAction.boundingBox();
|
||||
expect(deleteBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(deleteBox?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await deleteAction.tap();
|
||||
const deleteConfirmation = page.mobileSidebar.getByTestId(
|
||||
"saved-task-view-delete-confirmation",
|
||||
);
|
||||
await expect(deleteConfirmation).toHaveAccessibleName(`Delete ${savedQuery}?`);
|
||||
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
|
||||
await prCapture.screenshot("saved-query-delete-mobile", {
|
||||
caption: "A saved integration query confirms inline without nesting another sheet.",
|
||||
});
|
||||
await deleteConfirmation.getByRole("button", { name: "Cancel" }).tap();
|
||||
await expect(page.savedQueryByLabel(savedQuery)).toBeVisible();
|
||||
|
||||
await deleteAction.tap();
|
||||
const deleteResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.url().includes("/api/v1/github/workspace-settings"),
|
||||
);
|
||||
await page.mobileSidebar
|
||||
.getByTestId("saved-task-view-delete-confirmation")
|
||||
.getByRole("button", { name: `Delete ${savedQuery}` })
|
||||
.tap();
|
||||
await deleteResponse;
|
||||
await expect(page.savedQueryByLabel(savedQuery)).toHaveCount(0);
|
||||
await expect(page.mobileSidebar).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,8 +157,19 @@ test.describe("GitLab issue milestone filter", () => {
|
||||
|
||||
// Deleting the selected saved query clears the milestone in the same update.
|
||||
await savedMenu.click();
|
||||
const deleteAction = testPage.getByRole("menuitem", {
|
||||
name: "Delete Sprint 42 saved query",
|
||||
});
|
||||
await deleteAction.click();
|
||||
const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(confirmation).toHaveAccessibleName("Delete Sprint 42?");
|
||||
await expect(testPage.getByRole("menu")).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(testPage.getByRole("menuitem", { name: "Sprint 42", exact: true })).toBeVisible();
|
||||
|
||||
await deleteAction.click();
|
||||
const deleted = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
|
||||
await testPage.getByRole("menuitem", { name: "Delete Sprint 42 saved query" }).click();
|
||||
await confirmation.getByRole("button", { name: "Delete Sprint 42" }).click();
|
||||
await deleted;
|
||||
await expect(milestoneInput).toHaveValue("");
|
||||
await expect(gitlab.issueRow(602)).toBeVisible();
|
||||
|
||||
@@ -6,6 +6,65 @@ import { GitLabPage } from "../../pages/gitlab-page";
|
||||
import { ISSUES_ENDPOINT, seededIssue } from "./gitlab-issue-milestone-filter-helpers";
|
||||
|
||||
test.describe("Mobile GitLab issue milestone filter", () => {
|
||||
test("confirms a saved query inline with visible touch actions", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const savedLabel = "Mobile release MRs";
|
||||
await apiClient.configureGitLab(seedData.workspaceId);
|
||||
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
gitlab_saved_presets: [
|
||||
{
|
||||
id: "mobile-release-mrs",
|
||||
kind: "mr",
|
||||
label: savedLabel,
|
||||
customQuery: "state=opened",
|
||||
projectFilter: GITLAB_PROJECT,
|
||||
milestone: "",
|
||||
preset: "",
|
||||
createdAt: "2026-09-07T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(seedResponse.ok).toBe(true);
|
||||
|
||||
const gitlab = new GitLabPage(testPage);
|
||||
await gitlab.goto();
|
||||
await gitlab.mobileFiltersButton.tap();
|
||||
const deleteAction = gitlab.mobileSidebar.getByRole("button", {
|
||||
name: `Delete ${savedLabel} saved query`,
|
||||
exact: true,
|
||||
});
|
||||
await expect(deleteAction).toBeVisible();
|
||||
const deleteBox = await deleteAction.boundingBox();
|
||||
expect(deleteBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(deleteBox?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await deleteAction.tap();
|
||||
|
||||
const confirmation = gitlab.mobileSidebar.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(confirmation).toHaveAccessibleName(`Delete ${savedLabel}?`);
|
||||
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).tap();
|
||||
await expect(deleteAction).toBeVisible();
|
||||
|
||||
await deleteAction.tap();
|
||||
const deleteResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PATCH" &&
|
||||
response.url().includes("/api/v1/user/settings"),
|
||||
);
|
||||
await gitlab.mobileSidebar
|
||||
.getByTestId("saved-task-view-delete-confirmation")
|
||||
.getByRole("button", { name: `Delete ${savedLabel}` })
|
||||
.tap();
|
||||
await deleteResponse;
|
||||
await expect(deleteAction).toHaveCount(0);
|
||||
await expect(gitlab.mobileSidebar).toBeVisible();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab saved query deletion");
|
||||
});
|
||||
|
||||
test("filters issues from the mobile sheet without clipping the milestone input", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -195,6 +195,24 @@ test("connects and browses Azure work items, PRs, and feedback", async ({
|
||||
"Connected as Ada Reviewer",
|
||||
);
|
||||
await testPage.getByTestId("azure-devops-save-button").click();
|
||||
const savedViewsSeed = await testPage.request.put(
|
||||
`/api/v1/azure-devops/views?workspace_id=${encodeURIComponent(seedData.workspaceId)}`,
|
||||
{
|
||||
data: {
|
||||
views: [
|
||||
{
|
||||
id: "security-queue",
|
||||
kind: "work_item",
|
||||
label: "Security queue",
|
||||
projectId: "project-1",
|
||||
wiql: "SELECT [System.Id] FROM WorkItems",
|
||||
createdAt: "2026-09-07T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(savedViewsSeed.ok()).toBe(true);
|
||||
const defaultQueries = testPage
|
||||
.getByRole("heading", { name: "Default queries" })
|
||||
.locator("xpath=ancestor::section");
|
||||
@@ -204,6 +222,30 @@ test("connects and browses Azure work items, PRs, and feedback", async ({
|
||||
|
||||
await testPage.goto("/azure-devops");
|
||||
await expect(testPage.getByTestId("azure-devops-presets-scope-bar")).toContainText("Team queue");
|
||||
const savedViewsMenu = testPage.getByTestId("azure-devops-saved-views-menu");
|
||||
await savedViewsMenu.click();
|
||||
const deleteSavedView = testPage.getByRole("menuitem", {
|
||||
name: "Delete Security queue saved query",
|
||||
});
|
||||
await deleteSavedView.click();
|
||||
const deleteConfirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(deleteConfirmation).toHaveAccessibleName("Delete Security queue?");
|
||||
await expect(testPage.getByRole("menu")).toBeVisible();
|
||||
await deleteConfirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(deleteSavedView).toBeVisible();
|
||||
|
||||
await deleteSavedView.click();
|
||||
const deleteSavedViewResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.url().includes("/api/v1/azure-devops/views"),
|
||||
);
|
||||
await deleteConfirmation.getByRole("button", { name: "Delete Security queue" }).click();
|
||||
await deleteSavedViewResponse;
|
||||
await expect(deleteSavedView).toHaveCount(0);
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(testPage.getByRole("menu")).toBeHidden();
|
||||
await expect(testPage.getByTestId("azure-devops-board")).toBeVisible();
|
||||
await expect(testPage.getByText("Handle token rotation")).toBeVisible();
|
||||
await testPage.getByTestId("azure-board-select").click();
|
||||
|
||||
@@ -77,4 +77,53 @@ test.describe("Jira status filter", () => {
|
||||
await expect(testPage.getByText("CLIP-2")).toBeVisible();
|
||||
await expect(testPage.getByText("CLIP-1")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("confirms deletion of a custom view while preserving built-ins", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
jira_saved_views: [
|
||||
{
|
||||
id: "jira-sprint-bugs",
|
||||
name: "Sprint bugs",
|
||||
filters: {
|
||||
projectKeys: [],
|
||||
statuses: [],
|
||||
assignee: "anyone",
|
||||
searchText: "",
|
||||
sort: "updated",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(seedResponse.ok).toBe(true);
|
||||
await testPage.goto("/jira");
|
||||
|
||||
await testPage.getByRole("button", { name: "Assigned to me" }).click();
|
||||
await expect(testPage.getByRole("button", { name: "Delete Assigned to me" })).toHaveCount(0);
|
||||
const deleteAction = testPage.getByRole("button", { name: "Delete Sprint bugs" });
|
||||
await deleteAction.click();
|
||||
const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(confirmation).toHaveAccessibleName("Delete Sprint bugs?");
|
||||
await expect(testPage.getByText("Sprint bugs", { exact: true })).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
const settingsAfterCancel = await apiClient.getUserSettings();
|
||||
expect(
|
||||
(settingsAfterCancel.settings.jira_saved_views as Array<{ id?: string }> | undefined)?.some(
|
||||
(view) => view.id === "jira-sprint-bugs",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await deleteAction.click();
|
||||
const deleteResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PATCH" &&
|
||||
response.url().includes("/api/v1/user/settings"),
|
||||
);
|
||||
await confirmation.getByRole("button", { name: "Delete Sprint bugs" }).click();
|
||||
await deleteResponse;
|
||||
await expect(deleteAction).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,9 +173,64 @@ test("mobile board opens a focused column editor without horizontal overflow", a
|
||||
organizationUrl: "https://dev.azure.com/acme",
|
||||
pat: "azure-test-pat",
|
||||
});
|
||||
const savedViewsSeed = await testPage.request.put(
|
||||
`/api/v1/azure-devops/views?workspace_id=${encodeURIComponent(seedData.workspaceId)}`,
|
||||
{
|
||||
data: {
|
||||
views: [
|
||||
{
|
||||
id: "mobile-security-queue",
|
||||
kind: "work_item",
|
||||
label: "Mobile security queue",
|
||||
projectId: "project-1",
|
||||
wiql: "SELECT [System.Id] FROM WorkItems",
|
||||
createdAt: "2026-09-07T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(savedViewsSeed.ok()).toBe(true);
|
||||
await testPage.goto("/azure-devops");
|
||||
|
||||
await expect(testPage.getByTestId("azure-devops-board")).toBeVisible();
|
||||
const savedViewsMenu = testPage.getByTestId("azure-devops-saved-views-menu");
|
||||
await savedViewsMenu.tap();
|
||||
const deleteSavedView = testPage.getByRole("menuitem", {
|
||||
name: "Delete Mobile security queue saved query",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await deleteSavedView.boundingBox())?.height ?? 0)
|
||||
.toBeGreaterThanOrEqual(44);
|
||||
await expect
|
||||
.poll(async () => (await deleteSavedView.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(44);
|
||||
await deleteSavedView.tap();
|
||||
const deleteConfirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(deleteConfirmation).toHaveAccessibleName("Delete Mobile security queue?");
|
||||
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(0);
|
||||
for (const action of await deleteConfirmation.getByRole("button").all()) {
|
||||
const box = await action.boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await deleteConfirmation.getByRole("button", { name: "Cancel" }).tap();
|
||||
await expect(deleteSavedView).toBeVisible();
|
||||
|
||||
await deleteSavedView.tap();
|
||||
const deleteSavedViewResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PUT" &&
|
||||
response.url().includes("/api/v1/azure-devops/views"),
|
||||
);
|
||||
await testPage
|
||||
.getByTestId("saved-task-view-delete-confirmation")
|
||||
.getByRole("button", { name: "Delete Mobile security queue" })
|
||||
.tap();
|
||||
await deleteSavedViewResponse;
|
||||
await expect(deleteSavedView).toHaveCount(0);
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(testPage.getByRole("menu")).toBeHidden();
|
||||
await expect(testPage.getByText("Handle token rotation")).toBeVisible();
|
||||
await prCapture.screenshot("board-mobile", {
|
||||
caption: "Azure DevOps focused mobile board column",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
|
||||
test.describe("Mobile Jira saved views", () => {
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
await apiClient.mockJiraReset();
|
||||
await apiClient.setJiraConfig({
|
||||
siteUrl: "https://acme.atlassian.net",
|
||||
email: "alice@example.com",
|
||||
secret: "api-token-value",
|
||||
});
|
||||
await apiClient.waitForIntegrationAuthHealthy("jira");
|
||||
});
|
||||
|
||||
test("keeps custom-view confirmation inline, touch-sized, and contained", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
jira_saved_views: [
|
||||
{
|
||||
id: "jira-mobile-long-view",
|
||||
name: "Long mobile sprint bugs that need review",
|
||||
filters: {
|
||||
projectKeys: [],
|
||||
statuses: [],
|
||||
assignee: "anyone",
|
||||
searchText: "",
|
||||
sort: "updated",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(seedResponse.ok).toBe(true);
|
||||
await testPage.goto("/jira");
|
||||
|
||||
await testPage.getByRole("button", { name: "Assigned to me" }).tap();
|
||||
const deleteAction = testPage.getByRole("button", {
|
||||
name: "Delete Long mobile sprint bugs that need review",
|
||||
});
|
||||
const deleteBox = await deleteAction.boundingBox();
|
||||
expect(deleteBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(deleteBox?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await deleteAction.tap();
|
||||
|
||||
const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(confirmation).toHaveAccessibleName(
|
||||
"Delete Long mobile sprint bugs that need review?",
|
||||
);
|
||||
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
|
||||
for (const action of await confirmation.getByRole("button").all()) {
|
||||
const box = await action.boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile Jira saved-view confirmation");
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).tap();
|
||||
await expect(deleteAction).toBeFocused();
|
||||
|
||||
await deleteAction.tap();
|
||||
const deleteResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PATCH" &&
|
||||
response.url().includes("/api/v1/user/settings"),
|
||||
);
|
||||
await testPage
|
||||
.getByTestId("saved-task-view-delete-confirmation")
|
||||
.getByRole("button", { name: "Delete Long mobile sprint bugs that need review" })
|
||||
.tap();
|
||||
await deleteResponse;
|
||||
await expect(deleteAction).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -173,6 +173,7 @@ test.describe("Mobile sidebar — view system", () => {
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, ["Mobile New View Task"]);
|
||||
const newView = sheet.getByTestId("sidebar-new-view");
|
||||
@@ -235,6 +236,62 @@ test.describe("Mobile sidebar — view system", () => {
|
||||
.getByTestId("sidebar-view-chip")
|
||||
.filter({ hasText: "New view" }),
|
||||
).toHaveAttribute("data-active", "true");
|
||||
|
||||
await reloadedSheet.getByTestId("sidebar-filter-gear").tap();
|
||||
const deleteDrawer = testPage.getByTestId("sidebar-filter-drawer");
|
||||
const deleteEditor = deleteDrawer.getByTestId("sidebar-filter-popover");
|
||||
const deleteButton = deleteEditor.getByTestId("view-delete-button");
|
||||
await deleteButton.scrollIntoViewIfNeeded();
|
||||
const deleteButtonBox = await deleteButton.boundingBox();
|
||||
expect(deleteButtonBox).not.toBeNull();
|
||||
expect(deleteButtonBox!.height).toBeGreaterThanOrEqual(44);
|
||||
expect(
|
||||
await deleteButton.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(
|
||||
bounds.left + bounds.width / 2,
|
||||
bounds.top + bounds.height / 2,
|
||||
);
|
||||
return hit === element || (hit !== null && element.contains(hit));
|
||||
}),
|
||||
).toBe(true);
|
||||
await deleteButton.tap();
|
||||
const confirmation = deleteEditor.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(confirmation).toHaveAccessibleName("Delete New view?");
|
||||
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(2);
|
||||
for (const action of await confirmation.getByRole("button").all()) {
|
||||
const box = await action.boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await prCapture.screenshot("saved-task-view-delete-mobile", {
|
||||
caption: "Mobile confirmation stays inline in the existing task-filter drawer.",
|
||||
});
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).tap();
|
||||
await expect(deleteDrawer).toBeVisible();
|
||||
await expect(deleteEditor.getByTestId("sidebar-filter-active-view-name")).toContainText(
|
||||
"New view",
|
||||
);
|
||||
|
||||
await deleteEditor.getByTestId("view-delete-button").tap();
|
||||
const settingsWrite = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/v1/user/settings") &&
|
||||
response.request().method() === "PATCH" &&
|
||||
response.ok(),
|
||||
);
|
||||
await deleteEditor
|
||||
.getByTestId("saved-task-view-delete-confirmation")
|
||||
.getByRole("button", { name: "Delete New view" })
|
||||
.tap();
|
||||
await settingsWrite;
|
||||
await expect(deleteEditor.getByTestId("sidebar-filter-active-view-name")).toContainText(
|
||||
"All tasks",
|
||||
);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("editing filters in the mobile sheet narrows the task list live", async ({
|
||||
|
||||
@@ -269,4 +269,60 @@ test.describe("Mobile Threads view", () => {
|
||||
await expect(geometryDrawer).toBeHidden();
|
||||
await expect(reloadedTrigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("confirms deletion of a saved view inside the native drawer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const baseView = {
|
||||
task_scope: { mode: "all", task_ids: [] },
|
||||
filters: [],
|
||||
sort: { key: "attention", direction: "asc" },
|
||||
max_columns: null,
|
||||
};
|
||||
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
thread_views: [
|
||||
{ ...baseView, id: "view-all-threads", name: "All threads" },
|
||||
{ ...baseView, id: "view-release", name: "Release threads" },
|
||||
],
|
||||
thread_active_view_id: "view-release",
|
||||
thread_view_draft: null,
|
||||
});
|
||||
expect(seedResponse.ok).toBe(true);
|
||||
await testPage.goto("/threads");
|
||||
|
||||
const trigger = testPage.getByTestId("threads-mobile-view-trigger");
|
||||
await expect(trigger).toContainText("Release threads");
|
||||
await trigger.tap();
|
||||
const drawer = testPage.getByTestId("threads-mobile-view-drawer");
|
||||
await drawer.getByTestId("threads-mobile-view-settings").tap();
|
||||
const editor = drawer.getByTestId("threads-view-editor");
|
||||
await editor.getByTestId("threads-view-delete").tap();
|
||||
const confirmation = editor.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(confirmation).toHaveAccessibleName("Delete Release threads?");
|
||||
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
|
||||
for (const action of await confirmation.getByRole("button").all()) {
|
||||
const box = await action.boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).tap();
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(editor.getByTestId("threads-view-delete")).toBeFocused();
|
||||
|
||||
await editor.getByTestId("threads-view-delete").tap();
|
||||
const deletedViewResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PATCH" &&
|
||||
response.url().includes("/api/v1/user/settings"),
|
||||
);
|
||||
await editor
|
||||
.getByTestId("saved-task-view-delete-confirmation")
|
||||
.getByRole("button", { name: "Delete Release threads" })
|
||||
.tap();
|
||||
await deletedViewResponse;
|
||||
await expect(drawer).toBeHidden();
|
||||
await expect(trigger).toContainText("All threads");
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,7 +192,8 @@ test.describe("Sidebar filter — view ordering", () => {
|
||||
|
||||
await filters.selectViewByName("Beta View");
|
||||
await filters.open();
|
||||
await filters.deleteActiveView();
|
||||
await filters.beginDeleteActiveView("Beta View");
|
||||
await filters.confirmDeleteActiveView("Beta View");
|
||||
await filters.expectChipOrder(["All tasks", "Alpha View", "Gamma View"]);
|
||||
// Deleting the active view falls back to the first remaining view.
|
||||
await filters.expectActiveViewChip("All tasks");
|
||||
@@ -629,6 +630,7 @@ test.describe("Sidebar filter — saved views CRUD", () => {
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Delete View Task"]);
|
||||
await filters.addFilterRow();
|
||||
@@ -638,7 +640,31 @@ test.describe("Sidebar filter — saved views CRUD", () => {
|
||||
await filters.expectActiveViewChip("Ephemeral");
|
||||
|
||||
await filters.open();
|
||||
await filters.deleteActiveView();
|
||||
await filters.beginDeleteActiveView("Ephemeral");
|
||||
await expect(filters.popover).toBeVisible();
|
||||
if (prCapture.capturing) {
|
||||
await filters.deleteConfirmation.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
element.getAnimations().map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
}
|
||||
await prCapture.screenshot("saved-task-view-delete-desktop", {
|
||||
caption: "Desktop saved task view deletion names the target before removing filters.",
|
||||
});
|
||||
await filters.cancelDeleteActiveView();
|
||||
await expect(filters.popover.getByTestId("sidebar-filter-active-view-name")).toContainText(
|
||||
"Ephemeral",
|
||||
);
|
||||
const settingsAfterCancel = await apiClient.getUserSettings();
|
||||
expect(
|
||||
(settingsAfterCancel.settings.sidebar_views as Array<{ name?: string }> | undefined)?.some(
|
||||
(view) => view.name === "Ephemeral",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await filters.beginDeleteActiveView("Ephemeral");
|
||||
await filters.confirmDeleteActiveView("Ephemeral");
|
||||
await filters.close();
|
||||
await filters.openViewPicker();
|
||||
await expect(
|
||||
|
||||
@@ -252,6 +252,52 @@ test.describe("Threads view", () => {
|
||||
await expect(board.locator("[data-thread-column-id]")).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("confirms deletion of a saved Threads view before closing settings", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const baseView = {
|
||||
task_scope: { mode: "all", task_ids: [] },
|
||||
filters: [],
|
||||
sort: { key: "attention", direction: "asc" },
|
||||
max_columns: null,
|
||||
};
|
||||
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
thread_views: [
|
||||
{ ...baseView, id: "view-all-threads", name: "All threads" },
|
||||
{ ...baseView, id: "view-release", name: "Release threads" },
|
||||
],
|
||||
thread_active_view_id: "view-release",
|
||||
thread_view_draft: null,
|
||||
});
|
||||
expect(seedResponse.ok).toBe(true);
|
||||
await testPage.goto("/threads");
|
||||
|
||||
await expect(testPage.getByTestId("threads-view-picker")).toContainText("Release threads");
|
||||
await testPage.getByTestId("threads-view-settings").click();
|
||||
const settings = testPage.getByTestId("threads-view-settings-popover");
|
||||
await expect(settings).toBeVisible();
|
||||
await settings.getByTestId("threads-view-delete").click();
|
||||
const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
|
||||
await expect(confirmation).toHaveAccessibleName("Delete Release threads?");
|
||||
await expect(settings).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(settings).toBeVisible();
|
||||
await expect(testPage.getByTestId("threads-view-picker")).toContainText("Release threads");
|
||||
|
||||
await settings.getByTestId("threads-view-delete").click();
|
||||
const deletedViewResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.ok() &&
|
||||
response.request().method() === "PATCH" &&
|
||||
response.url().includes("/api/v1/user/settings"),
|
||||
);
|
||||
await confirmation.getByRole("button", { name: "Delete Release threads" }).click();
|
||||
await deletedViewResponse;
|
||||
await expect(settings).toBeHidden();
|
||||
await expect(testPage.getByTestId("threads-view-picker")).toContainText("All threads");
|
||||
});
|
||||
|
||||
test("explains sorts and shows live task details in the task picker", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -322,6 +368,20 @@ test.describe("Threads view", () => {
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const baseView = {
|
||||
id: "view-all-threads",
|
||||
name: "All threads",
|
||||
task_scope: { mode: "all", task_ids: [] },
|
||||
filters: [],
|
||||
sort: { key: "attention", direction: "asc" },
|
||||
max_columns: null,
|
||||
};
|
||||
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
thread_views: [baseView],
|
||||
thread_active_view_id: baseView.id,
|
||||
thread_view_draft: null,
|
||||
});
|
||||
expect(seedResponse.ok).toBe(true);
|
||||
const first = await startAgentTask(testPage, apiClient, seedData, "threads-focus-a");
|
||||
const second = await startAgentTask(testPage, apiClient, seedData, "threads-focus-b", {
|
||||
title: SECOND_TITLE,
|
||||
|
||||
@@ -197,6 +197,12 @@ describe("convertMessage regional vocabulary", () => {
|
||||
assert.equal(convertMessage("点击“启动智能体”", "zh-tw"), "點選「啟動代理程式」");
|
||||
assert.equal(convertMessage("点击“启动智能体”", "zh-hk"), "點擊「啓動代理程式」");
|
||||
});
|
||||
|
||||
it("keeps the adverb in copy that means only", () => {
|
||||
for (const locale of TARGET_LOCALES) {
|
||||
assert.equal(convertMessage("这只会删除", locale), "這只會刪除");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertMessage idempotence", () => {
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
"DELETE ALL"
|
||||
],
|
||||
"phrases": [
|
||||
{
|
||||
"from": "这只会",
|
||||
"zh-tw": "這只會",
|
||||
"zh-hk": "這只會"
|
||||
},
|
||||
{
|
||||
"from": "個人訪問令牌",
|
||||
"zh-tw": "個人存取權杖",
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
"preventAutoStartAgentOnOpen": "開啟任務時不自動啟動代理程式",
|
||||
"preventAutoStartAgentOnOpenHelp": "重新啟動後,或從工作流程最後一步開啟任務時,不會自動啟動代理程式,改為顯示「啟動代理程式」按鈕。"
|
||||
},
|
||||
"agents": {
|
||||
"dynamicProfileSettings": "動態設定檔設定"
|
||||
"common": {
|
||||
"nativeFolderPickerUnavailable": "原生資料夾選擇器無法使用。請重新載入 Kandev 後再試。",
|
||||
"sidebarWeeks_one": "{{count}}週",
|
||||
"sidebarWeeks_other": "{{count}}週"
|
||||
},
|
||||
"github": {
|
||||
"resolvedFromOneNamedHostAccount": "需要時從一個指定的主機帳戶解析。受管理任務會取得限定範圍的中介身分;除非在下方選取,否則不會繼承主機 Git。",
|
||||
@@ -39,8 +41,10 @@
|
||||
"preventAutoStartAgentOnOpen": "開啟任務時不自動啓動代理程式",
|
||||
"preventAutoStartAgentOnOpenHelp": "重新啓動後,或從工作流程最後一步開啟任務時,不會自動啓動代理程式,改為顯示「啓動代理程式」按鈕。"
|
||||
},
|
||||
"agents": {
|
||||
"dynamicProfileSettings": "動態設定檔設定"
|
||||
"common": {
|
||||
"nativeFolderPickerUnavailable": "原生資料夾選擇器無法使用。請重新載入 Kandev 後再試。",
|
||||
"sidebarWeeks_one": "{{count}}週",
|
||||
"sidebarWeeks_other": "{{count}}週"
|
||||
},
|
||||
"github": {
|
||||
"resolvedFromOneNamedHostAccount": "需要時從一個指定的主機帳戶解析。受管理任務會取得限定範圍的中介身份;除非在下方選取,否則不會繼承主機 Git。",
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
"dShort": "{{diffDay}}d",
|
||||
"default": "default",
|
||||
"defaultBadge": "Default",
|
||||
"delete": "Delete",
|
||||
"deleteSavedTaskViewAction": "Delete {{name}}",
|
||||
"deleteSavedTaskViewDescription": "This removes only the saved filters and view settings. It does not delete tasks or data in connected services.",
|
||||
"deleteSavedTaskViewTitle": "Delete {{name}}?",
|
||||
"deleteSession": "Delete session",
|
||||
"describeABugYouHitOr": "Describe a bug you hit or a feature you'd like, and we'll create a task on your own agent to implement it in the kandev codebase.",
|
||||
"destination": "Destination",
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
"dShort": "{{diffDay}}ď",
|
||||
"default": "ďēƒàũĺţ",
|
||||
"defaultBadge": "Ďēƒàũĺţ",
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteSavedTaskViewAction": "Ďēĺēţē {{name}}",
|
||||
"deleteSavedTaskViewDescription": "Ţĥĩś ŕēḿōvēś ōńĺŷ ţĥē śàvēď ƒĩĺţēŕś àńď vĩēŵ śēţţĩńĝś. Ĩţ ďōēś ńōţ ďēĺēţē ţàśķś ōŕ ďàţà ĩń ćōńńēćţēď śēŕvĩćēś.",
|
||||
"deleteSavedTaskViewTitle": "Ďēĺēţē {{name}}?",
|
||||
"deleteSession": "Ďēĺēţē śēśśĩōń",
|
||||
"describeABugYouHitOr": "Ďēśćŕĩƀē à ƀũĝ ŷōũ ĥĩţ ōŕ à ƒēàţũŕē ŷōũ'ď ĺĩķē, àńď ŵē'ĺĺ ćŕēàţē à ţàśķ ōń ŷōũŕ ōŵń àĝēńţ ţō ĩḿƥĺēḿēńţ ĩţ ĩń ţĥē ķàńďēv ćōďēƀàśē.",
|
||||
"destination": "Ďēśţĩńàţĩōń",
|
||||
@@ -166,8 +170,8 @@
|
||||
"failedToCreateFolder": "Ƒàĩĺēď ţō ćŕēàţē ƒōĺďēŕ",
|
||||
"failedToCreateRepository": "Ƒàĩĺēď ţō ćŕēàţē ŕēƥōśĩţōŕŷ",
|
||||
"failedToLoadDirectory": "Ƒàĩĺēď ţō ĺōàď ďĩŕēćţōŕŷ",
|
||||
"nativeFolderPickerFailed": "Ţħē ńàţĩṽē ƒōĺďēŕ ƥĩćķēŕ ćōũĺď ńōţ ōƥēń.",
|
||||
"nativeFolderPickerUnavailable": "Ţħē ńàţĩṽē ƒōĺďēŕ ƥĩćķēŕ ĩś ũńàṽàĩĺàƀĺē. Ŕēĺōàď Ķàńďēṽ àńď ţŕŷ àĝàĩń.",
|
||||
"nativeFolderPickerFailed": "Ţĥē ńàţĩvē ƒōĺďēŕ ƥĩćķēŕ ćōũĺď ńōţ ōƥēń.",
|
||||
"nativeFolderPickerUnavailable": "Ţĥē ńàţĩvē ƒōĺďēŕ ƥĩćķēŕ ĩś ũńàvàĩĺàƀĺē. Ŕēĺōàď Ķàńďēv àńď ţŕŷ àĝàĩń.",
|
||||
"failedToLoadRoutine": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńē",
|
||||
"failedToLoadSetup": "Ƒàĩĺēď ţō ĺōàď śēţũƥ",
|
||||
"failedToRenderDiagram": "Ƒàĩĺēď ţō ŕēńďēŕ ďĩàĝŕàḿ",
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
"dShort": "{{diffDay}}d",
|
||||
"default": "pré-definido",
|
||||
"defaultBadge": "Pré-definido",
|
||||
"delete": "Eliminar",
|
||||
"deleteSavedTaskViewAction": "Eliminar {{name}}",
|
||||
"deleteSavedTaskViewDescription": "Isto remove apenas os filtros guardados e as definições da vista. Não elimina tarefas nem dados em serviços ligados.",
|
||||
"deleteSavedTaskViewTitle": "Eliminar {{name}}?",
|
||||
"deleteSession": "Eliminar sessão",
|
||||
"describeABugYouHitOr": "Descreva um erro que encontrou ou uma funcionalidade que gostaria de ter, e criamos uma tarefa no seu próprio agente para a implementar no código do kandev.",
|
||||
"destination": "Destino",
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
"dShort": "{{diffDay}}天",
|
||||
"default": "默认",
|
||||
"defaultBadge": "默认",
|
||||
"delete": "删除",
|
||||
"deleteSavedTaskViewAction": "删除{{name}}",
|
||||
"deleteSavedTaskViewDescription": "这只会删除已保存的筛选条件和视图设置,不会删除任务或关联服务中的数据。",
|
||||
"deleteSavedTaskViewTitle": "删除{{name}}?",
|
||||
"deleteSession": "删除会话",
|
||||
"describeABugYouHitOr": "描述你遇到的缺陷或想要的功能,我们会在你自己的智能体上创建任务,并在 Kandev 代码库中实现。",
|
||||
"destination": "目标位置",
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
"dShort": "{{diffDay}}天",
|
||||
"default": "預設",
|
||||
"defaultBadge": "預設",
|
||||
"delete": "刪除",
|
||||
"deleteSavedTaskViewAction": "刪除{{name}}",
|
||||
"deleteSavedTaskViewDescription": "這只會刪除已儲存的篩選條件和視圖設定,不會刪除任務或關聯服務中的數據。",
|
||||
"deleteSavedTaskViewTitle": "刪除{{name}}?",
|
||||
"deleteSession": "刪除工作階段",
|
||||
"describeABugYouHitOr": "描述你遇到的缺陷或想要的功能,我們會在你自己的代理程式上建立任務,並在 Kandev 程式碼庫中實現。",
|
||||
"destination": "目標位置",
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
"dShort": "{{diffDay}}天",
|
||||
"default": "預設",
|
||||
"defaultBadge": "預設",
|
||||
"delete": "刪除",
|
||||
"deleteSavedTaskViewAction": "刪除{{name}}",
|
||||
"deleteSavedTaskViewDescription": "這只會刪除已儲存的篩選條件和檢視設定,不會刪除任務或關聯服務中的資料。",
|
||||
"deleteSavedTaskViewTitle": "刪除{{name}}?",
|
||||
"deleteSession": "刪除工作階段",
|
||||
"describeABugYouHitOr": "描述你遇到的缺陷或想要的功能,我們會在你自己的代理程式上建立任務,並在 Kandev 程式碼庫中實現。",
|
||||
"destination": "目標位置",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
created: 2026-09-06
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Saved Task View Deletion Confirmation
|
||||
|
||||
## Overview
|
||||
|
||||
Add the shared localized confirmation shell first, connect the independent task
|
||||
and integration surface adapters in parallel, then exercise their integrated
|
||||
desktop and mobile behavior with production-build Playwright. This order gives
|
||||
each surface one stable confirmation contract while leaving every existing
|
||||
delete and persistence callback in place.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Confirm deletion of task-sidebar views and Threads views.
|
||||
- Confirm deletion of Jira saved views and GitHub, GitLab, and Azure DevOps
|
||||
saved queries in every Kandev-owned desktop and mobile entry point.
|
||||
- Name the target and explain that tasks and connected-service data are safe.
|
||||
- Use an anchored fine-pointer confirmation and in-place coarse-pointer
|
||||
confirmation with keyboard, focus, touch-target, safe-area, and overflow
|
||||
coverage.
|
||||
- Preserve existing eligibility, default-marker, fallback, persistence,
|
||||
rollback, error, and parent-surface behavior.
|
||||
- Add localized copy in all supported catalogs and pseudo-locale coverage.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Backend, API, user-settings schema, provider, or saved-view model changes.
|
||||
- Confirmation for saved layouts, action presets, review watches, run filters,
|
||||
tasks, or unrelated saved artifacts.
|
||||
- Bespoke UI maintained only in an external plugin repository.
|
||||
- A preference to skip confirmation or a redesign of the owning surfaces.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Shared confirmation contract
|
||||
|
||||
- Add
|
||||
`apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx`
|
||||
as a presentation-only adapter around `ActionConfirmPopover` and
|
||||
`InlineConfirmActions`.
|
||||
- Pass a captured `{ id, label }` target, explicit `popover` or `inline`
|
||||
presentation, anchor/focus-boundary refs, pending state, and the existing
|
||||
delete callback. Keep mutation and parent-close behavior in callers.
|
||||
- Reuse the existing `data-confirmation-boundary` contract and expose one
|
||||
predicate for parent Radix menus/popovers to ignore interaction originating
|
||||
inside the fine-pointer confirmation.
|
||||
- Add shared `common` localization keys for the named title, consequence and
|
||||
reassurance, and Delete action. Update `en`, `pt-pt`, and `zh-cn`, then
|
||||
generate `zh-hk`, `zh-tw`, and `pseudo` with repository scripts.
|
||||
|
||||
### Task-owned surfaces
|
||||
|
||||
- In `SidebarFilterPopover` and `ViewHeaderRow`, replace immediate active-view
|
||||
deletion with a captured candidate. Render an anchored confirmation from the
|
||||
desktop popover and replace the drawer header action region on coarse
|
||||
pointers. Continue calling `deleteSidebarView` only after confirmation.
|
||||
- In `ThreadsViewControls`, carry the active candidate through
|
||||
`ThreadsViewEditor` and `EditorActions`. Keep the current behavior that closes
|
||||
the desktop settings popover or mobile drawer after confirmed deletion.
|
||||
- Use `sidebarViewName` and `threadViewName` for confirmation labels so built-in
|
||||
labels match the text shown to the user.
|
||||
- Do not modify `sidebar-view-actions.ts`, `thread-view-actions.ts`, their
|
||||
persistence queues, last-view guards, or optimistic rollback.
|
||||
|
||||
### Integration-owned surfaces
|
||||
|
||||
- In `presets-scope-bar-base.tsx`, add one candidate per `SavedMenu`; anchor a
|
||||
fine-pointer confirmation to the selected delete item and replace that row
|
||||
inline when the shared dropdown uses coarse-pointer presentation. Preserve
|
||||
GitHub default-mutation disabling and prevent delete controls from selecting
|
||||
the query or toggling its default marker.
|
||||
- In the GitHub and GitLab mobile `PresetsSidebar` components, replace the
|
||||
affected saved-query row with touch-density confirmation. Keep the GitHub
|
||||
default action separate; make GitLab's delete action visible and at least
|
||||
44px without hover.
|
||||
- In Jira `ListToolbar`, add candidate state to `ViewsDropdown`; protect the
|
||||
parent popover around fine-pointer confirmation and replace the custom saved
|
||||
row inline for coarse pointers. Built-in rows remain nondeletable, and the
|
||||
coarse delete trigger becomes visible and touch reachable.
|
||||
- Continue invoking each wrapper's existing `onDeleteSaved` or `onDeleteView`
|
||||
callback. Azure DevOps inherits the behavior through `IntegrationScopeBar`.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criteria | Evidence |
|
||||
| --- | --- |
|
||||
| `AC-UI-SAVED-TASK-VIEW-DELETION-001.2`, `.3`, `.4`, `.6`, `.7`, `.8`, `.10` | New `saved-task-view-delete-confirmation.test.tsx` covers named localized content, fine/inline routing, cancel, Escape, disconnected anchors, focus, destructive confirmation, and a single captured-ID callback. Existing `action-confirm-popover.test.tsx` retains nested-boundary and anchor-lifecycle coverage. |
|
||||
| `AC-UI-SAVED-TASK-VIEW-DELETION-001.1`, `.3`, `.4`, `.5`, `.8` for task surfaces | `view-manager.test.tsx`, `sidebar-filter-popover.test.tsx`, new `threads-view-editor-actions.test.tsx`, and `threads-view-controls.test.tsx` cover no mutation before confirmation, cancel, exact confirmation, last-view guards, active labels, and current parent-close behavior. |
|
||||
| `AC-UI-SAVED-TASK-VIEW-DELETION-001.1`, `.3`, `.4`, `.5`, `.8` for integration surfaces | `presets-scope-bar-base.test.tsx`, GitHub `presets-sidebar.test.tsx`, new GitLab `presets-sidebar.test.tsx`, and new Jira `list-toolbar.test.tsx` cover each adapter, built-in/pending guards, target IDs, and isolation from select/default actions. |
|
||||
| `AC-UI-SAVED-TASK-VIEW-DELETION-001.7`, `.9` | Responsive component assertions and mobile Playwright inspect visible 44px controls, parent surface retention, safe-area reachability, long-copy containment, and zero document overflow. |
|
||||
|
||||
## E2E tests
|
||||
|
||||
| Flow | Acceptance criteria | Playwright evidence |
|
||||
| --- | --- | --- |
|
||||
| Open task-sidebar and Threads delete confirmations, cancel without changing settings, then confirm and observe the existing fallback/reload behavior. | `.1`, `.2`, `.3`, `.4`, `.5`, `.6`, `.8` | `tests/task/sidebar-filter.spec.ts` and `tests/task/threads-view.spec.ts` on `chromium` |
|
||||
| Confirm saved-query deletion without selecting the row or changing a GitHub default; exercise the shared GitLab and Azure adapters and Jira custom-view guard. | `.1`, `.2`, `.3`, `.4`, `.5`, `.6`, `.8` | `tests/github/github-scope-bar.spec.ts`, `tests/gitlab/gitlab-issue-milestone-filter.spec.ts`, `tests/integrations/azure-devops.spec.ts`, and `tests/integrations/jira-status-filter.spec.ts` on `chromium` |
|
||||
| Repeat every distinct coarse-pointer composition inside its existing drawer, sheet, dropdown, or editor. Check touch targets, one overlay/scroll owner, safe area, long labels, and overflow before confirming. | `.1`, `.2`, `.3`, `.4`, `.7`, `.8`, `.9`, `.10` | `tests/task/mobile-sidebar-views.spec.ts`, `tests/task/mobile-threads-view.spec.ts`, `tests/github/mobile-github-sidebar.spec.ts`, `tests/gitlab/mobile-gitlab-issue-milestone-filter.spec.ts`, `tests/integrations/mobile-azure-devops.spec.ts`, and new `tests/integrations/mobile-jira-saved-view.spec.ts` on `mobile-chrome` |
|
||||
|
||||
## Work orders
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Shared confirmation shell](task-01-shared-confirmation-shell.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [Task 02: Task saved-view surfaces](task-02-task-saved-view-surfaces.md)
|
||||
- [x] [Task 03: Integration saved-query surfaces](task-03-integration-saved-query-surfaces.md)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [x] [Task 04: Cross-surface browser verification](task-04-cross-surface-browser-verification.md)
|
||||
|
||||
Tasks 02 and 03 are parallel-safe after Task 01 because their production and
|
||||
unit-test files are disjoint. The wave is a dependency description only and
|
||||
does not authorize subagent execution.
|
||||
|
||||
## Verification results
|
||||
|
||||
- Task 01: focused confirmation tests passed 15/15; locale generation,
|
||||
`i18n:check`, and web typecheck passed.
|
||||
- Task 02: task sidebar and Threads component tests passed 18/18; web typecheck
|
||||
passed.
|
||||
- Task 03: shared integration, GitHub, GitLab, and Jira component tests passed
|
||||
15/15; the final combined focused suite passed 50/50 and web typecheck passed.
|
||||
- Task 04: the desktop matrix passed 44/44 on `chromium`, and the mobile
|
||||
matrix passed 24/24 on `mobile-chrome`. Fresh desktop and phone screenshots
|
||||
were captured from those flows, inspected, and compressed for the PR.
|
||||
- Final checks passed: changed-file ESLint, web typecheck, `i18n:check`,
|
||||
`i18n:ratchet`, specification lint and lint tests, and `git diff --check`.
|
||||
|
||||
## Risks
|
||||
|
||||
- A nested Radix confirmation can dismiss its parent or misdirect focus unless
|
||||
every fine-pointer owner applies the shared confirmation-boundary predicate.
|
||||
- Optimistic deletion removes the anchor immediately after confirmation; the
|
||||
shell must close first and invoke the callback with the captured ID.
|
||||
- GitHub default mutation and delete state share one row. Confirm must remain
|
||||
disabled while pending without trapping the user from cancelling.
|
||||
- Hidden desktop controls can remain mounted during mobile tests, so locators
|
||||
must stay scoped to the visible drawer or sheet.
|
||||
- GitLab and Jira currently depend on hover disclosure. Enlarging their coarse
|
||||
controls must not activate the row or create horizontal overflow.
|
||||
- Longer localized names and confirmation copy can expand a row, especially in
|
||||
the shared dropdown bottom-sheet presentation; the parent must remain the
|
||||
only scroll owner.
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
id: "01-shared-confirmation-shell"
|
||||
title: "Shared confirmation shell"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.2
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.3
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.4
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.6
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.7
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.8
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.10
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
|
||||
---
|
||||
|
||||
# Task 01: Shared Confirmation Shell
|
||||
|
||||
## Summary
|
||||
|
||||
Implement one presentation-only confirmation shell for named saved task views.
|
||||
It adapts the existing anchored and inline primitives, centralizes copy and
|
||||
boundary handling, and invokes a captured target ID only after confirmation.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add `SavedTaskViewDeleteConfirmation` and its target/prop contract.
|
||||
- Support fine-pointer popover and touch-density inline presentations.
|
||||
- Reuse the existing confirmation-boundary marker and provide a shared target
|
||||
predicate for nested parent surfaces.
|
||||
- Add localized title, consequence/reassurance, Cancel, Delete, and accessible
|
||||
names in all supported catalogs.
|
||||
- Test cancellation, Escape, focus, stale anchors, pending confirmation, and
|
||||
exactly-once target dispatch.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Wiring any saved-view surface or calling a store/provider mutation.
|
||||
- Changing `ActionConfirmPopover` or `InlineConfirmActions` behavior beyond the
|
||||
small boundary adapter required by this contract.
|
||||
- Adding persistence, retry, or error state.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Opening either presentation performs no mutation; Cancel, Escape, dismissal,
|
||||
and a disconnected anchor preserve the target and restore focus when possible.
|
||||
- The visible and accessible confirmation names the supplied label, uses a
|
||||
destructive final action, and dispatches the captured ID once after close.
|
||||
- Fine-pointer content exposes a nested-overlay boundary; inline content uses
|
||||
44px touch actions and contains long localized text.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @kandev/web test -- \
|
||||
components/confirmation/saved-task-view-delete-confirmation.test.tsx \
|
||||
components/confirmation/action-confirm-popover.test.tsx
|
||||
cd web
|
||||
pnpm run i18n:zh-hant
|
||||
pnpm run i18n:pseudo
|
||||
pnpm run i18n:check
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx`
|
||||
- `apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx`
|
||||
- `apps/web/components/confirmation/action-confirm-popover.tsx`
|
||||
- `apps/web/components/confirmation/action-confirm-popover.test.tsx`
|
||||
- `apps/web/src/locales/en/common.json`
|
||||
- `apps/web/src/locales/pt-pt/common.json`
|
||||
- `apps/web/src/locales/zh-cn/common.json`
|
||||
- `apps/web/src/locales/zh-hk/common.json`
|
||||
- `apps/web/src/locales/zh-tw/common.json`
|
||||
- `apps/web/src/locales/pseudo/common.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- A generic boundary predicate must not treat unrelated portal content as part
|
||||
of the confirmation.
|
||||
- `ActionConfirmPopover` and `InlineConfirmActions` close at different DOM
|
||||
locations; the adapter must preserve both primitives' existing focus rules.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirement acceptance criteria `.2`, `.3`, `.4`, `.6`, `.7`, `.8`, and `.10`.
|
||||
- System-design sections Shared confirmation shell, Data and contracts, Control
|
||||
flow, and Responsive and accessibility behavior.
|
||||
- Existing `WatcherDeleteAction`, `SavedLayoutDeleteConfirmation`,
|
||||
`ActionConfirmPopover`, and `InlineConfirmActions` patterns.
|
||||
|
||||
## Results
|
||||
|
||||
- Installed the frozen pnpm workspace dependencies for this worktree.
|
||||
- Added the shared fine-pointer/inline shell, captured-ID dispatch, and nested
|
||||
confirmation-boundary predicate.
|
||||
- Added synchronized `common` copy in all real locales and the pseudo locale;
|
||||
`i18n:check` passed.
|
||||
- Focused confirmation suites passed 15/15 and web typecheck passed.
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
id: "02-task-saved-view-surfaces"
|
||||
title: "Task saved-view surfaces"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-shared-confirmation-shell"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.1
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.2
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.3
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.4
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.5
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.6
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.7
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.8
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.9
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
|
||||
---
|
||||
|
||||
# Task 02: Task Saved-View Surfaces
|
||||
|
||||
## Summary
|
||||
|
||||
Connect the shared shell to task-sidebar and Threads saved-view deletion. Keep
|
||||
their existing guards, callbacks, fallback selection, persistence, rollback,
|
||||
and post-confirm parent-close behavior unchanged.
|
||||
|
||||
## In scope
|
||||
|
||||
- Capture the active sidebar view and initiating delete element before opening
|
||||
confirmation.
|
||||
- Keep the fine-pointer sidebar editor popover mounted around its anchored
|
||||
confirmation and use inline confirmation in the drawer header actions.
|
||||
- Carry a captured Threads target through the editor/action components.
|
||||
- Use an anchored confirmation in desktop Threads and inline confirmation in
|
||||
the mobile drawer, closing settings only after explicit confirmation.
|
||||
- Use the localized visible built-in name for each target.
|
||||
- Add focused component tests for open, cancel, confirm, guards, target changes,
|
||||
and parent-close behavior.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Sidebar or Threads store, settings payload, persistence queue, and rollback
|
||||
changes.
|
||||
- Saved-view creation, rename, duplicate, save, discard, query, sort, or column
|
||||
behavior.
|
||||
- Browser-level verification owned by Task 04.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Neither task surface invokes its delete action until the named confirmation's
|
||||
destructive action is selected, and Cancel/Escape/target changes are no-ops.
|
||||
- Fine-pointer confirmation preserves the editor and focus boundary; coarse
|
||||
confirmation remains inside the existing drawer with 44px actions.
|
||||
- Last-view guards and each surface's existing confirmed-delete fallback and
|
||||
parent-close behavior remain intact.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps
|
||||
pnpm --filter @kandev/web test -- \
|
||||
components/task/sidebar-filter/view-manager.test.tsx \
|
||||
components/task/sidebar-filter/sidebar-filter-popover.test.tsx \
|
||||
components/threads/threads-view-editor-actions.test.tsx \
|
||||
components/threads/threads-view-controls.test.tsx
|
||||
pnpm --filter @kandev/web typecheck
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/sidebar-filter/view-manager.tsx`
|
||||
- `apps/web/components/task/sidebar-filter/view-manager.test.tsx`
|
||||
- `apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx`
|
||||
- `apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx`
|
||||
- `apps/web/components/task/sidebar-filter/sidebar-view-editor.tsx`
|
||||
- `apps/web/components/threads/threads-view-controls.tsx`
|
||||
- `apps/web/components/threads/threads-view-controls.test.tsx`
|
||||
- `apps/web/components/threads/threads-view-editor.tsx`
|
||||
- `apps/web/components/threads/threads-view-editor-sections.tsx`
|
||||
- `apps/web/components/threads/threads-view-editor-actions.tsx`
|
||||
- `apps/web/components/threads/threads-view-editor-actions.test.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 (`SavedTaskViewDeleteConfirmation` and shared copy/boundary contract).
|
||||
|
||||
## Risks
|
||||
|
||||
- The active view can change while confirmation is open; confirm must use the
|
||||
captured target and stale candidate state must close safely.
|
||||
- Threads intentionally closes its settings surface after delete while the
|
||||
sidebar does not. Shared wiring must not erase that distinction.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`parallel-safe` with Task 03 after Task 01 completes.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirement acceptance criteria `.1` through `.9`.
|
||||
- System-design sections Surface adapters, Control flow, and Responsive and
|
||||
accessibility behavior.
|
||||
- Existing `SidebarFilterPopover`, `ViewHeaderRow`, `ThreadsViewControls`,
|
||||
`ThreadsViewEditor`, and optimistic saved-view actions.
|
||||
|
||||
## Results
|
||||
|
||||
- Added captured, named confirmation to task-sidebar and Threads deletion.
|
||||
- Kept desktop editors mounted around anchored confirmation and used inline
|
||||
touch actions inside the existing mobile drawers.
|
||||
- Preserved sidebar and Threads store actions, guards, fallback selection, and
|
||||
Threads parent-close behavior.
|
||||
- Focused task-surface tests passed 18/18; web typecheck passed.
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
---
|
||||
id: "03-integration-saved-query-surfaces"
|
||||
title: "Integration saved-query surfaces"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-shared-confirmation-shell"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.1
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.2
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.3
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.4
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.5
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.6
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.7
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.8
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.9
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
|
||||
---
|
||||
|
||||
# Task 03: Integration Saved-Query Surfaces
|
||||
|
||||
## Summary
|
||||
|
||||
Connect the shared confirmation shell to the shared integration scope bar,
|
||||
GitHub and GitLab mobile sidebars, and Jira saved views. Preserve every
|
||||
surface's existing callback, default-marker, built-in, and failure semantics.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add captured target and nested-boundary handling to `SavedMenu` so GitHub,
|
||||
GitLab, and Azure DevOps inherit fine/coarse confirmation.
|
||||
- Keep GitHub default actions and pending state isolated from delete; keep
|
||||
Cancel available if pending state begins after confirmation opens.
|
||||
- Replace the target row in GitHub and GitLab mobile sheets with touch-density
|
||||
inline confirmation.
|
||||
- Make GitLab and Jira coarse-pointer delete triggers visible, accessible, and
|
||||
at least 44px without changing row selection.
|
||||
- Add Jira custom-view confirmation while leaving built-ins nondeletable.
|
||||
- Add focused tests for every adapter, cancellation path, exact ID, pending
|
||||
guard, built-in guard, and event isolation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Integration settings models, saved-query hooks, workspace/user persistence,
|
||||
provider requests, and rollback behavior.
|
||||
- GitHub default-view semantics or the shared scope bar's selection behavior.
|
||||
- External-plugin-only UI and browser-level verification owned by Task 04.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every in-tree integration delete trigger opens a named confirmation and calls
|
||||
its original callback exactly once only after explicit confirmation.
|
||||
- Delete actions never select a saved query, toggle GitHub's default marker, or
|
||||
become available for Jira built-ins or during an existing pending mutation.
|
||||
- Fine-pointer parents remain mounted around the anchored confirmation; coarse
|
||||
rows expose visible 44px controls and inline content without overflow.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps
|
||||
pnpm --filter @kandev/web test -- \
|
||||
components/integrations/presets-scope-bar-base.test.tsx \
|
||||
components/github/my-github/presets-sidebar.test.tsx \
|
||||
components/gitlab/my-gitlab/presets-sidebar.test.tsx \
|
||||
components/jira/my-jira/list-toolbar.test.tsx
|
||||
pnpm --filter @kandev/web typecheck
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/integrations/presets-scope-bar-base.tsx`
|
||||
- `apps/web/components/integrations/presets-scope-bar-base.test.tsx`
|
||||
- `apps/web/components/github/my-github/presets-sidebar.tsx`
|
||||
- `apps/web/components/github/my-github/presets-sidebar.test.tsx`
|
||||
- `apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx`
|
||||
- `apps/web/components/gitlab/my-gitlab/presets-sidebar.test.tsx`
|
||||
- `apps/web/components/jira/my-jira/list-toolbar.tsx`
|
||||
- `apps/web/components/jira/my-jira/list-toolbar.test.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 (`SavedTaskViewDeleteConfirmation` and shared copy/boundary contract).
|
||||
|
||||
## Risks
|
||||
|
||||
- The shared scope bar runs inside Radix dropdown behavior that changes to a
|
||||
bottom sheet on coarse pointers; event prevention must work in both modes.
|
||||
- GitHub rows have three independent actions. Replacing or anchoring delete
|
||||
must not alter saved-query selection or default mutation state.
|
||||
- Jira uses one responsive popover rather than a dedicated mobile drawer, so
|
||||
inline replacement must fit its current scroll and width constraints.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`parallel-safe` with Task 02 after Task 01 completes.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirement acceptance criteria `.1` through `.9`.
|
||||
- System-design sections Surface inventory, Surface adapters, Control flow, and
|
||||
Responsive and accessibility behavior.
|
||||
- Existing shared scope-bar test, GitHub sidebar test, saved-layout nested
|
||||
confirmation pattern, and each integration's saved-query hook.
|
||||
|
||||
## Results
|
||||
|
||||
- Added captured confirmation to the shared GitHub, GitLab, and Azure DevOps
|
||||
scope menu while preserving menu selection and GitHub default mutation state.
|
||||
- Added inline, touch-sized confirmation to GitHub and GitLab mobile sidebars.
|
||||
- Added responsive Jira confirmation with built-in view protection and visible
|
||||
coarse-pointer deletion controls.
|
||||
- Focused integration-surface tests passed 15/15; the combined confirmation
|
||||
suite passed 50/50 and web typecheck passed.
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
---
|
||||
id: "04-cross-surface-browser-verification"
|
||||
title: "Cross-surface browser verification"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "02-task-saved-view-surfaces"
|
||||
- "03-integration-saved-query-surfaces"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.1
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.2
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.3
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.4
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.5
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.6
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.7
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.8
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.9
|
||||
- AC-UI-SAVED-TASK-VIEW-DELETION-001.10
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
|
||||
---
|
||||
|
||||
# Task 04: Cross-Surface Browser Verification
|
||||
|
||||
## Summary
|
||||
|
||||
Exercise the real desktop Radix nesting and every distinct mobile composition
|
||||
after the shared shell and all adapters are integrated. Prove cancellation is a
|
||||
no-op, explicit confirmation preserves existing deletion outcomes, and phone
|
||||
surfaces remain touch reachable and contained.
|
||||
|
||||
## In scope
|
||||
|
||||
- Update the sidebar filter page object so tests can begin, cancel, and confirm
|
||||
deletion explicitly.
|
||||
- Extend desktop task-sidebar, Threads, GitHub, GitLab, Azure DevOps, and Jira
|
||||
journeys with named confirmation and no-mutation-before-confirm assertions.
|
||||
- Extend mobile task-sidebar, Threads, GitHub, GitLab, and Azure DevOps journeys
|
||||
and add a Jira saved-view journey.
|
||||
- Assert 44px controls, parent surface retention, one overlay/scroll owner,
|
||||
safe-area reachability, focus/selection isolation, long-name containment, and
|
||||
zero document-level horizontal overflow.
|
||||
- Record exact focused runner results in this work order and the plan.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New production behavior, general responsive redesign, or broad E2E cleanup.
|
||||
- Full-suite verification or unrelated provider journeys.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every listed desktop and mobile surface proves that trigger and cancellation
|
||||
do not delete, while final confirmation invokes the existing outcome once.
|
||||
- Desktop tests prove anchored confirmation, parent-overlay retention, target
|
||||
naming, focus/event isolation, and nondeletable guards.
|
||||
- Mobile tests prove in-place confirmation with visible 44px actions, no nested
|
||||
drawer/modal, one scroll owner, safe-area reachability, and zero overflow.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm e2e:run --project chromium \
|
||||
tests/task/sidebar-filter.spec.ts \
|
||||
tests/task/threads-view.spec.ts \
|
||||
tests/github/github-scope-bar.spec.ts \
|
||||
tests/gitlab/gitlab-issue-milestone-filter.spec.ts \
|
||||
tests/integrations/azure-devops.spec.ts \
|
||||
tests/integrations/jira-status-filter.spec.ts
|
||||
pnpm e2e:run --project mobile-chrome \
|
||||
tests/task/mobile-sidebar-views.spec.ts \
|
||||
tests/task/mobile-threads-view.spec.ts \
|
||||
tests/github/mobile-github-sidebar.spec.ts \
|
||||
tests/gitlab/mobile-gitlab-issue-milestone-filter.spec.ts \
|
||||
tests/integrations/mobile-azure-devops.spec.ts \
|
||||
tests/integrations/mobile-jira-saved-view.spec.ts
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/pages/sidebar-filter-popover.ts`
|
||||
- `apps/web/e2e/tests/task/sidebar-filter.spec.ts`
|
||||
- `apps/web/e2e/tests/task/threads-view.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-threads-view.spec.ts`
|
||||
- `apps/web/e2e/tests/github/github-scope-bar.spec.ts`
|
||||
- `apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts`
|
||||
- `apps/web/e2e/tests/gitlab/gitlab-issue-milestone-filter.spec.ts`
|
||||
- `apps/web/e2e/tests/gitlab/mobile-gitlab-issue-milestone-filter.spec.ts`
|
||||
- `apps/web/e2e/tests/integrations/azure-devops.spec.ts`
|
||||
- `apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts`
|
||||
- `apps/web/e2e/tests/integrations/jira-status-filter.spec.ts`
|
||||
- `apps/web/e2e/tests/integrations/mobile-jira-saved-view.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 02 and 03, including their focused component tests.
|
||||
|
||||
## Risks
|
||||
|
||||
- Focused files still share server and seed state; scenarios must use existing
|
||||
fixture isolation and must not assume execution order.
|
||||
- Mobile pages can keep hidden desktop variants mounted, so geometry and action
|
||||
locators must be scoped to the visible parent surface.
|
||||
- The managed E2E runner must run sequentially by project to respect the
|
||||
repository's memory and worker guardrails.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- All requirement acceptance criteria and the system design's Observability and
|
||||
verification section.
|
||||
- Existing E2E fixture-state, cleanup, responsive geometry, and page-object
|
||||
conventions.
|
||||
- Completed unit-tested adapters from Tasks 02 and 03.
|
||||
|
||||
## Results
|
||||
|
||||
- Updated the task-sidebar page object and all six desktop plus six mobile
|
||||
surface journeys to separate trigger, cancellation, and final confirmation.
|
||||
- The exact desktop command passed 44/44 on `chromium`, covering anchored
|
||||
confirmation and each existing deletion outcome.
|
||||
- The exact mobile command passed 24/24 on `mobile-chrome`, covering inline
|
||||
confirmation, touch geometry, overlay containment, long names, and overflow.
|
||||
- Captured and inspected fresh desktop and phone confirmation screenshots from
|
||||
the task-sidebar journeys, then compressed both assets for PR publication.
|
||||
@@ -94,6 +94,7 @@ UI owns responsive behavior; other systems own behavior/state.
|
||||
- [Review File Status Cues](requirements/review-file-status.md)
|
||||
- [Review Markdown Preview](requirements/review-markdown-preview.md)
|
||||
- [Search/filter dropdown scroll reset](requirements/search-filter-scroll-reset.md)
|
||||
- [Saved Task View Deletion Confirmation](requirements/saved-task-view-deletion-confirmation.md)
|
||||
- [Selected option prominence](requirements/selected-option-picker-prominence.md)
|
||||
- [Session](requirements/session-start-composer-readiness.md)
|
||||
- [Session tab delete feedback](requirements/session-tab-delete-feedback.md)
|
||||
@@ -180,6 +181,7 @@ UI owns responsive behavior; other systems own behavior/state.
|
||||
- [Responsive Plan Formatting](system-design/responsive-plan-formatting.md)
|
||||
- [Confirmations](system-design/confirmation-warning-hierarchy.md)
|
||||
- [Resizable Markdown Table Columns](system-design/resizable-markdown-tables.md)
|
||||
- [Saved Task View Deletion Confirmation](system-design/saved-task-view-deletion-confirmation.md)
|
||||
- [Session](system-design/session-start-composer-readiness.md)
|
||||
- [Task Layout Profiles](system-design/task-layout-profiles.md)
|
||||
- [Task surface](system-design/task-surface-render-isolation.md)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
status: active
|
||||
system: ui
|
||||
created: 2026-09-06
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Saved Task View Deletion Confirmation Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Saved task views and saved integration queries are reusable user preferences,
|
||||
but their current delete actions remove them immediately. A mistaken click can
|
||||
therefore discard a useful filter definition without giving the user a chance
|
||||
to verify the target.
|
||||
|
||||
The UI system owns this independent, reusable confirmation contract. Each task
|
||||
surface continues to own its saved-view collection, persistence, active-view
|
||||
fallback, and failure recovery.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Saved task view:** A named Kandev-owned task filter, view, or integration
|
||||
query that a user can select again.
|
||||
- **Owning surface:** The task sidebar, Threads, Jira, GitHub, GitLab, or Azure
|
||||
DevOps UI that owns a saved task view and its delete action.
|
||||
- **Fine-pointer confirmation:** A compact confirmation anchored to the delete
|
||||
action that opened it.
|
||||
- **Coarse-pointer confirmation:** An in-place confirmation inside the current
|
||||
drawer, sheet, menu, or editor.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-UI-SAVED-TASK-VIEW-DELETION-001: Confirm saved task view deletion
|
||||
|
||||
**Intent:** Prevent accidental deletion of reusable task filters while keeping
|
||||
each surface's existing delete behavior intact after explicit confirmation.
|
||||
|
||||
**User story:** As a user with saved task views, I want to verify the named view
|
||||
before deleting it, so that an accidental action does not discard my filters.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.1:** When a user invokes any in-tree
|
||||
delete action for a task-sidebar view, Threads view, Jira saved view, or
|
||||
GitHub, GitLab, or Azure DevOps saved query, the UI shall show confirmation
|
||||
before invoking the owning surface's delete operation.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.2:** The confirmation shall identify the
|
||||
saved task view by its current visible name and explain that only its saved
|
||||
filters and view settings are removed; tasks and connected-service data
|
||||
remain unchanged.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.3:** Cancel, Escape, dismissal of the
|
||||
owning surface, or disappearance of the confirmation target shall preserve
|
||||
the saved task view and shall not invoke its delete operation. When the
|
||||
initiating surface remains open, focus shall return to the delete action.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.4:** Explicit confirmation shall use the
|
||||
semantic destructive action treatment and invoke the existing delete
|
||||
operation exactly once. Existing active-view fallback, persistence, sync
|
||||
error, and optimistic rollback behavior shall remain unchanged.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.5:** A delete action that is unavailable
|
||||
because a view is built in, is the protected last view, or is temporarily
|
||||
disabled by an existing mutation shall remain unavailable and shall not open
|
||||
confirmation.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.6:** With a fine pointer, confirmation
|
||||
shall be anchored to the initiating delete action, stay within the viewport,
|
||||
and keep its owning menu, popover, or editor available until the user decides.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.7:** With a coarse pointer, confirmation
|
||||
shall replace the relevant row or action region inside the existing surface.
|
||||
It shall not stack another drawer, sheet, or modal, and its actions shall be
|
||||
visible without hover and at least 44 by 44 CSS pixels.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.8:** The delete trigger and confirmation
|
||||
shall be keyboard and screen-reader operable. The target name shall be
|
||||
programmatically available, Cancel shall receive initial confirmation focus,
|
||||
and using delete controls shall not also select the saved view or toggle an
|
||||
adjacent default marker.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.9:** At phone widths and with long saved
|
||||
task view names or translated copy, the owning surface shall retain one
|
||||
vertical scroll owner, keep actions reachable above the safe area, and create
|
||||
no document-level horizontal overflow.
|
||||
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.10:** Confirmation copy and accessible
|
||||
names shall use the active locale in every supported Kandev locale.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing saved-view models, limits, active-view selection, persistence,
|
||||
optimistic updates, rollback, or provider APIs.
|
||||
- Deleting tasks, task data, provider records, or connected-service data.
|
||||
- Confirming deletion of saved layouts, action presets, review watches,
|
||||
run-status filters, or other saved artifacts that are not task views.
|
||||
- Adding confirmation to bespoke UI implemented only in an external plugin
|
||||
repository. Plugins that render Kandev's shared host controls inherit the
|
||||
host confirmation behavior.
|
||||
- Adding a preference that bypasses or changes this confirmation.
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
status: current
|
||||
system: ui
|
||||
requirements:
|
||||
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
|
||||
---
|
||||
|
||||
# Saved Task View Deletion Confirmation System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design adds one reusable confirmation presentation contract to all
|
||||
Kandev-owned saved task view and saved integration query delete actions. The UI
|
||||
system owns the pointer-responsive surface, copy hierarchy, accessibility, and
|
||||
overlay containment. Surface-specific stores and hooks remain authoritative for
|
||||
whether a view can be deleted and what happens after deletion.
|
||||
|
||||
No backend endpoint, settings schema, saved-view model, provider operation, or
|
||||
persistence queue changes. Confirmation state is ephemeral and never becomes a
|
||||
second saved-view source of truth.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-UI-SAVED-TASK-VIEW-DELETION-001` | [Shared confirmation shell](#shared-confirmation-shell), [Surface adapters](#surface-adapters), [Control flow](#control-flow), and [Responsive and accessibility behavior](#responsive-and-accessibility-behavior) |
|
||||
|
||||
## Surface inventory
|
||||
|
||||
| Owning surface | Existing delete entry | Existing mutation boundary |
|
||||
| --- | --- | --- |
|
||||
| Task sidebar | Active-view actions in `ViewHeaderRow` | `deleteSidebarView` |
|
||||
| Threads | `EditorActions` in the desktop editor or mobile drawer | `deleteThreadView` |
|
||||
| GitHub, GitLab, and Azure DevOps shared scope bar | Saved-query row in `IntegrationScopeBar` | Each wrapper's `onDeleteSaved` callback |
|
||||
| GitHub mobile filter sheet | Saved-query row in `PresetsSidebar` | GitHub `onDeleteSaved` callback |
|
||||
| GitLab mobile filter sheet | Saved-query row in `PresetsSidebar` | GitLab `onDeleteSaved` callback |
|
||||
| Jira ticket list | Custom row in `ViewsDropdown` | `useSavedViews.removeView` through `onDeleteView` |
|
||||
|
||||
The shared scope bar is a Kandev host component. A plugin that uses that host
|
||||
component receives the confirmation without defining another contract. UI that
|
||||
exists only inside an external plugin repository is outside this design.
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
### Shared confirmation shell
|
||||
|
||||
`apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx`
|
||||
adds a small presentation-only adapter over `ActionConfirmPopover` and
|
||||
`InlineConfirmActions`. Its stable input is the target visible to the user, not
|
||||
the surface's complete saved-view model:
|
||||
|
||||
```ts
|
||||
type SavedTaskViewDeleteTarget = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SavedTaskViewDeleteConfirmationProps = {
|
||||
target: SavedTaskViewDeleteTarget;
|
||||
presentation: "popover" | "inline";
|
||||
open: boolean;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
confirmDisabled?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (id: string) => void | Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
The component owns localized title, consequence, Cancel, and destructive Delete
|
||||
content. It passes the target ID back only after explicit confirmation. It does
|
||||
not decide eligibility, select a view, mutate a store, close a parent surface,
|
||||
show a persistence error, or retry a failed write.
|
||||
|
||||
For `popover`, the component enables `ActionConfirmPopover`'s confirmation
|
||||
boundary and accepts the owning surface as its focus boundary. A shared target
|
||||
predicate recognizes that boundary so nested Radix menu and popover parents can
|
||||
ignore focus/interact-outside events originating inside the confirmation. This
|
||||
follows the existing saved-layout deletion pattern instead of adding another
|
||||
overlay primitive.
|
||||
|
||||
For `inline`, the caller mounts the component where the saved row or editor
|
||||
actions normally render. `InlineConfirmActions` uses touch density, focuses
|
||||
Cancel on mount, stops row activation events, handles Escape locally, and
|
||||
closes before queueing the delete callback.
|
||||
|
||||
### Surface adapters
|
||||
|
||||
Each owning surface keeps at most one local delete candidate plus the initiating
|
||||
element reference. The candidate contains the stable ID and the exact localized
|
||||
or user-authored label already rendered by that surface.
|
||||
|
||||
- `SidebarFilterPopover` coordinates the active sidebar view candidate. The
|
||||
fine-pointer popover is anchored to `view-delete-button`; the drawer branch
|
||||
replaces the view-header action region with inline confirmation.
|
||||
- `ThreadsViewControls` coordinates the active Threads view candidate and
|
||||
carries it through `ThreadsViewEditor` to `EditorActions`. Confirm retains the
|
||||
current desktop/mobile behavior of closing the settings surface after the
|
||||
existing delete callback is invoked.
|
||||
- `SavedMenu` in `presets-scope-bar-base.tsx` coordinates saved-query rows for
|
||||
GitHub, GitLab, and Azure DevOps. The shared dropdown stays mounted around a
|
||||
fine-pointer confirmation; its coarse-pointer row becomes the inline surface.
|
||||
- GitHub and GitLab `PresetsSidebar` components keep their mobile-sheet
|
||||
candidate local and replace only that saved-query row. Their delete actions
|
||||
remain separate from row selection and GitHub's default marker.
|
||||
- Jira `ViewsDropdown` coordinates custom saved-view rows. Built-in rows never
|
||||
create a candidate. Its parent popover uses the same confirmation-boundary
|
||||
handling as the shared scope-bar menu.
|
||||
|
||||
The task sidebar, Threads, integration scope bar, and Jira adapters read pointer
|
||||
mode from `useResponsiveBreakpoint`. GitHub and GitLab mobile sidebars always
|
||||
use the coarse-pointer inline presentation because their Kandev-owned mount is
|
||||
the phone filter sheet.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
The confirmation target is transient component state. Its `id` remains opaque;
|
||||
its `label` is display content only. The shell never serializes either value,
|
||||
sends a network request, or derives deletion eligibility.
|
||||
|
||||
Shared copy lives in the `common` namespace with a `{{name}}` placeholder for
|
||||
the target title, a consequence/reassurance description, and a generic Delete
|
||||
action. English, Portuguese, Simplified Chinese, both Traditional Chinese
|
||||
catalogs, and the generated pseudo locale change together. The title and
|
||||
destructive action accessible name include the same visible target label.
|
||||
|
||||
Existing trigger test IDs remain stable. The shared shell adds stable IDs for
|
||||
the confirmation container and final destructive action; surface tests scope
|
||||
those IDs to the owning row or overlay when hidden responsive variants are also
|
||||
mounted.
|
||||
|
||||
## Control flow
|
||||
|
||||
1. The owning surface evaluates its existing eligibility and pending rules.
|
||||
2. Activating an available delete trigger records the target and anchor. It
|
||||
prevents row selection or default-toggle events and performs no mutation.
|
||||
3. A fine pointer opens the anchored confirmation. A coarse pointer replaces
|
||||
the relevant row or action region inside its existing parent surface.
|
||||
4. Cancel, Escape, parent dismissal, target disappearance, view switching, or
|
||||
unmount clears the candidate without invoking the delete callback.
|
||||
5. Explicit Delete first closes the confirmation, then invokes the owning
|
||||
callback once with the captured target ID.
|
||||
6. The existing surface action applies its current fallback, persistence,
|
||||
optimistic update, rollback, error, and parent-close behavior.
|
||||
|
||||
Capturing the target ID prevents a later active-view change from redirecting a
|
||||
confirmation to another view. The connected-anchor guard already provided by
|
||||
`ActionConfirmPopover` prevents a stale fine-pointer target from being deleted.
|
||||
The inline branch disappears with its keyed row and therefore also fails closed.
|
||||
|
||||
## Responsive and accessibility behavior
|
||||
|
||||
Fine-pointer triggers retain their compact row geometry. The confirmation is a
|
||||
viewport-contained non-modal popover anchored to the trigger. Its parent
|
||||
dropdown or popover stays mounted while focus moves between the two surfaces,
|
||||
and Cancel restores focus to the connected trigger.
|
||||
|
||||
Coarse-pointer triggers and both confirmation actions use a minimum 44-pixel
|
||||
hit area and are visible without hover. Inline content wraps long translated
|
||||
copy and names without widening its parent. It remains inside the current
|
||||
drawer, sheet, dropdown bottom sheet, or editor, so no second modal navigation
|
||||
layer is introduced. The existing parent remains the sole vertical scroll
|
||||
owner and keeps its current safe-area padding.
|
||||
|
||||
The confirmation is exposed as a named group or dialog. Cancel receives initial
|
||||
focus. Escape is a cancellation path, the destructive action is not an implicit
|
||||
Enter default, and pointer/key events within the confirmation do not activate
|
||||
the saved-view row beneath it.
|
||||
|
||||
The GitLab and Jira coarse-pointer delete triggers become explicitly visible
|
||||
and touch reachable; their fine-pointer variants may keep hover disclosure but
|
||||
must also reveal on keyboard focus.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
Closing or cancelling a confirmation is always a local no-op. If live data
|
||||
removes the target or disconnects its anchor, the shell closes without calling
|
||||
the stale callback.
|
||||
|
||||
After confirmation, each owner keeps its current failure contract. Backend
|
||||
user-settings failures continue to use existing optimistic rollback and sync
|
||||
feedback. Integration settings failures continue to use their existing local
|
||||
or provider-specific feedback. The confirmation shell neither suppresses nor
|
||||
duplicates those messages and adds no new retry state.
|
||||
|
||||
When an adjacent GitHub default mutation is pending, the existing disabled
|
||||
delete rule remains authoritative. If pending state begins while confirmation
|
||||
is open, the destructive action is disabled while Cancel remains available.
|
||||
|
||||
## Persistence
|
||||
|
||||
Confirmation open state, target, and anchor are never persisted. Sidebar and
|
||||
Threads views continue through backend user settings. Integration saved queries
|
||||
continue through their existing workspace or portable settings owners. There
|
||||
is no browser-storage fallback and no schema migration.
|
||||
|
||||
## Security
|
||||
|
||||
The shell can act only on a delete callback already provided by an authorized
|
||||
owning surface. It does not fetch by target ID, expand provider permissions, or
|
||||
log target names. Confirmation copy makes clear that the operation does not
|
||||
delete tasks or connected-service records.
|
||||
|
||||
## Observability and verification
|
||||
|
||||
No production log or metric is added for local confirmation state. Component
|
||||
tests verify that opening and every cancellation path perform no mutation, the
|
||||
named destructive action calls the captured ID once, disconnected targets fail
|
||||
closed, and focus/event isolation is preserved.
|
||||
|
||||
Surface tests cover each adapter, including built-in/last-view guards, GitHub
|
||||
default-mutation disabling, and delete versus row-selection isolation.
|
||||
Playwright covers the real Radix nesting on desktop and the task-sidebar,
|
||||
Threads, GitHub, GitLab, Azure DevOps, and Jira phone compositions. Mobile
|
||||
checks inspect 44-pixel actions, one overlay/scroll owner, reachable safe-area
|
||||
actions, long-name containment, and zero document-level horizontal overflow.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [Backend-owned Portable User Settings](../../../decisions/0041-backend-owned-portable-user-settings.md)
|
||||
- [Surface-owned Saved Task Views](../../../decisions/2026-08-31-surface-owned-saved-task-views.md)
|
||||
- [Task Confirmation Surface Requirements](../requirements/confirmation-warning-hierarchy.md)
|
||||
- [Threads Saved Views](threads-saved-views.md)
|
||||
Reference in New Issue
Block a user