From 5ee5161166edfc62a5e2dbaf4fa4b10fd73073be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Mon, 7 Sep 2026 16:14:58 +0100 Subject: [PATCH] feat(ui): confirm saved view deletion (#3461) Co-authored-by: Kandev Agent --- .../confirmation/action-confirm-popover.tsx | 4 + ...ved-task-view-delete-confirmation.test.tsx | 120 +++++++++ .../saved-task-view-delete-confirmation.tsx | 90 +++++++ ...use-saved-task-view-delete-confirmation.ts | 50 ++++ .../github/my-github/presets-sidebar.test.tsx | 16 +- .../github/my-github/presets-sidebar.tsx | 138 ++++++++--- .../gitlab/my-gitlab/presets-sidebar.test.tsx | 54 ++++ .../gitlab/my-gitlab/presets-sidebar.tsx | 77 ++++-- .../presets-scope-bar-base.test.tsx | 17 +- .../integrations/presets-scope-bar-base.tsx | 232 +++++++++++++----- .../jira/my-jira/list-toolbar.test.tsx | 75 ++++++ .../components/jira/my-jira/list-toolbar.tsx | 137 +++++++++-- .../sidebar-filter-popover.test.tsx | 68 ++++- .../sidebar-filter/sidebar-filter-popover.tsx | 152 ++++++++++-- .../task/sidebar-filter/view-manager.tsx | 23 +- .../threads/threads-view-controls.test.tsx | 74 +++++- .../threads/threads-view-controls.tsx | 22 +- .../threads-view-editor-actions.test.tsx | 44 ++++ .../threads/threads-view-editor-actions.tsx | 11 + .../threads/threads-view-editor-sections.tsx | 13 + .../threads/threads-view-editor.tsx | 176 +++++++++---- apps/web/e2e/pages/sidebar-filter-popover.ts | 18 +- .../e2e/tests/github/github-scope-bar.spec.ts | 20 ++ .../github/mobile-github-sidebar.spec.ts | 35 +++ .../gitlab-issue-milestone-filter.spec.ts | 13 +- ...bile-gitlab-issue-milestone-filter.spec.ts | 59 +++++ .../tests/integrations/azure-devops.spec.ts | 42 ++++ .../integrations/jira-status-filter.spec.ts | 49 ++++ .../integrations/mobile-azure-devops.spec.ts | 55 +++++ .../mobile-jira-saved-view.spec.ts | 73 ++++++ .../tests/task/mobile-sidebar-views.spec.ts | 57 +++++ .../tests/task/mobile-threads-view.spec.ts | 56 +++++ .../web/e2e/tests/task/sidebar-filter.spec.ts | 30 ++- apps/web/e2e/tests/task/threads-view.spec.ts | 60 +++++ .../scripts/convert-zh-cn-to-zh-hant.test.mjs | 6 + apps/web/scripts/lib/zh-hant-glossary.json | 5 + apps/web/scripts/lib/zh-hant-overrides.json | 12 +- apps/web/src/locales/en/common.json | 4 + apps/web/src/locales/pseudo/common.json | 8 +- apps/web/src/locales/pt-pt/common.json | 4 + apps/web/src/locales/zh-cn/common.json | 4 + apps/web/src/locales/zh-hk/common.json | 4 + apps/web/src/locales/zh-tw/common.json | 4 + .../plan.md | 158 ++++++++++++ .../task-01-shared-confirmation-shell.md | 115 +++++++++ .../task-02-task-saved-view-surfaces.md | 119 +++++++++ ...ask-03-integration-saved-query-surfaces.md | 119 +++++++++ ...k-04-cross-surface-browser-verification.md | 133 ++++++++++ docs/specs/ui/README.md | 2 + .../saved-task-view-deletion-confirmation.md | 94 +++++++ .../saved-task-view-deletion-confirmation.md | 223 +++++++++++++++++ 51 files changed, 2942 insertions(+), 232 deletions(-) create mode 100644 apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx create mode 100644 apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx create mode 100644 apps/web/components/confirmation/use-saved-task-view-delete-confirmation.ts create mode 100644 apps/web/components/gitlab/my-gitlab/presets-sidebar.test.tsx create mode 100644 apps/web/components/jira/my-jira/list-toolbar.test.tsx create mode 100644 apps/web/components/threads/threads-view-editor-actions.test.tsx create mode 100644 apps/web/e2e/tests/integrations/mobile-jira-saved-view.spec.ts create mode 100644 docs/plans/saved-task-view-deletion-confirmation/plan.md create mode 100644 docs/plans/saved-task-view-deletion-confirmation/task-01-shared-confirmation-shell.md create mode 100644 docs/plans/saved-task-view-deletion-confirmation/task-02-task-saved-view-surfaces.md create mode 100644 docs/plans/saved-task-view-deletion-confirmation/task-03-integration-saved-query-surfaces.md create mode 100644 docs/plans/saved-task-view-deletion-confirmation/task-04-cross-surface-browser-verification.md create mode 100644 docs/specs/ui/requirements/saved-task-view-deletion-confirmation.md create mode 100644 docs/specs/ui/system-design/saved-task-view-deletion-confirmation.md diff --git a/apps/web/components/confirmation/action-confirm-popover.tsx b/apps/web/components/confirmation/action-confirm-popover.tsx index 3dc1a14749..0cd755a3c9 100644 --- a/apps/web/components/confirmation/action-confirm-popover.tsx +++ b/apps/web/components/confirmation/action-confirm-popover.tsx @@ -35,6 +35,10 @@ export type ActionConfirmPopoverProps = { onConfirm: () => void | Promise; }; +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. * diff --git a/apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx b/apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx new file mode 100644 index 0000000000..21655bcc05 --- /dev/null +++ b/apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx @@ -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(null); + return ( + <> + + + + ); +} + +function ReplacingHarness() { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + + if (!open) { + return ( + + ); + } + + return ( + + ); +} + +describe("SavedTaskViewDeleteConfirmation", () => { + afterEach(cleanup); + + it("names the target and dispatches its captured id only after fine-pointer confirmation", async () => { + const onConfirm = vi.fn(); + render(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByRole("button", { name: "Cancel" }).hasAttribute("disabled")).toBe(false); + expect( + screen.getByRole("button", { name: "Delete Needs review" }).hasAttribute("disabled"), + ).toBe(true); + }); +}); diff --git a/apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx b/apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx new file mode 100644 index 0000000000..267029df86 --- /dev/null +++ b/apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx @@ -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; + focusBoundaryRef?: RefObject; + confirmDisabled?: boolean; + testId?: string; + confirmTestId?: string; + onOpenChange: (open: boolean) => void; + onConfirm: (id: string) => void | Promise; +}; + +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 ( + onConfirm(target.id)} + /> + ); + } + + if (!open) return null; + + return ( + + {title} + {description} + + } + 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)} + /> + ); +} diff --git a/apps/web/components/confirmation/use-saved-task-view-delete-confirmation.ts b/apps/web/components/confirmation/use-saved-task-view-delete-confirmation.ts new file mode 100644 index 0000000000..c751716d4c --- /dev/null +++ b/apps/web/components/confirmation/use-saved-task-view-delete-confirmation.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export type SavedTaskViewDeleteTarget = { + id: string; + label: string; +}; + +export function useSavedTaskViewDeleteConfirmation( + availableTargets: ReadonlyArray<{ id: string }>, +) { + const [requestedTarget, setRequestedTarget] = useState(null); + const anchorRef = useRef(null); + const anchorIdRef = useRef(null); + const anchorsRef = useRef(new Map()); + 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, + }; +} diff --git a/apps/web/components/github/my-github/presets-sidebar.test.tsx b/apps/web/components/github/my-github/presets-sidebar.test.tsx index 51f7e222f7..c974533d5c 100644 --- a/apps/web/components/github/my-github/presets-sidebar.test.tsx +++ b/apps/web/components/github/my-github/presets-sidebar.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, 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(); } diff --git a/apps/web/components/github/my-github/presets-sidebar.tsx b/apps/web/components/github/my-github/presets-sidebar.tsx index 7136a4e546..908da4d38d 100644 --- a/apps/web/components/github/my-github/presets-sidebar.tsx +++ b/apps/web/components/github/my-github/presets-sidebar.tsx @@ -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(saved); const defaultMutationPending = defaultMutationPendingId !== null; return ( <> @@ -166,43 +172,19 @@ function SavedSection({ {t("github:noSavedQueriesYet")} )} - {saved.map((s) => { - const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label }); - const accessibleDeleteLabel = defaultMutationPending - ? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel }) - : deleteLabel; - return ( - onSelect({ kind, source: "saved", id: s.id })} - trailing={ -
- void onToggleSavedDefault(s)} - /> - -
- } - /> - ); - })} + {saved.map((preset) => ( + onSelect({ kind, source: "saved", id: preset.id })} + onDelete={onDelete} + onToggleDefault={() => void onToggleSavedDefault(preset)} + /> + ))} + + } + /> + ); +} + export function PresetsSidebar({ selected, onSelect, diff --git a/apps/web/components/gitlab/my-gitlab/presets-sidebar.test.tsx b/apps/web/components/gitlab/my-gitlab/presets-sidebar.test.tsx new file mode 100644 index 0000000000..558b7525a7 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/presets-sidebar.test.tsx @@ -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( + , + ); + + 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(); + }); +}); diff --git a/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx index 42aa05da8d..3695ccb9b7 100644 --- a/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx +++ b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx @@ -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(saved); return ( <> @@ -179,36 +182,58 @@ function SavedSection({ {t("gitlab:noSavedQueriesYet")} )} - {saved.map((s) => ( - onSelect({ kind, source: "saved", id: s.id })} - trailing={ - - } - /> - ))} + {saved.map((s) => { + if (deletion.target?.id === s.id) { + return ( +
+ { + if (!open) deletion.close(); + }} + onConfirm={onDelete} + /> +
+ ); + } + const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label }); + return ( + onSelect({ kind, source: "saved", id: s.id })} + trailing={ + + } + /> + ); + })} + + ); +} + function SavedMenu({ testId, selected, @@ -135,69 +167,51 @@ function SavedMenu({ defaultMutationPendingId: string | null; }) { const { t } = useTranslation(); + const { isFinePointer } = useResponsiveBreakpoint(); + const [menuOpen, setMenuOpen] = useState(false); + const deletion = useSavedTaskViewDeleteConfirmation(saved); + const menuContentRef = useRef(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 ( - - - - - + { + setMenuOpen(open); + if (!open) deletion.close(); + }} + > + + { + if (isActionConfirmationTarget(event.target)) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (isActionConfirmationTarget(event.target)) event.preventDefault(); + }} + > {saved.length === 0 ? ( {t("integrations:noSavedQueriesYet")} ) : ( - saved.map((s) => { - const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label }); - const accessibleDeleteLabel = defaultMutationPending - ? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel }) - : deleteLabel; - return ( -
- onSelect({ kind: s.kind, source: "saved", id: s.id })} - className="min-w-0 flex-1 cursor-pointer gap-2" - > - - {s.label} - - {onToggleSavedDefault && ( - onToggleSavedDefault(s.id)} - /> - )} - {/* A peer Radix item is intentional: arrow navigation reaches delete, - and focus opacity reveals it without pointer hover. */} - { - 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} - > - - -
- ); - }) + saved.map((preset) => ( + onSelect({ kind: preset.kind, source: "saved", id: preset.id })} + onToggleDefault={ + onToggleSavedDefault ? () => onToggleSavedDefault(preset.id) : undefined + } + onDeleteSaved={onDeleteSaved} + /> + )) )} ({ {t("integrations:saveCurrentQuery")}
+ {isFinePointer && deletion.target ? ( + { + if (!open) deletion.close(); + }} + onConfirm={onDeleteSaved} + /> + ) : null}
); } +type SavedMenuDeletion = { + target: SavedTaskViewDeleteTarget | null; + anchorRef: RefObject; + 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; + 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 ( +
+ { + if (!open) deletion.close(); + }} + onConfirm={onDeleteSaved} + /> +
+ ); + } + const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: preset.label }); + const accessibleDeleteLabel = defaultMutationPending + ? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel }) + : deleteLabel; + return ( +
+ + + {preset.label} + + {onToggleDefault && ( + + )} + {/* A peer Radix item keeps delete keyboard-reachable without selecting the preset. */} + 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} + > + + +
+ ); +} + type SavedDefaultActionProps = | { /** Emits only the stable id; domain wrappers own richer saved-query data. */ diff --git a/apps/web/components/jira/my-jira/list-toolbar.test.tsx b/apps/web/components/jira/my-jira/list-toolbar.test.tsx new file mode 100644 index 0000000000..af633621c4 --- /dev/null +++ b/apps/web/components/jira/my-jira/list-toolbar.test.tsx @@ -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( + , + ), + }; +} + +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(); + }); +}); diff --git a/apps/web/components/jira/my-jira/list-toolbar.tsx b/apps/web/components/jira/my-jira/list-toolbar.tsx index 0f0a0051a1..a38c3739f7 100644 --- a/apps/web/components/jira/my-jira/list-toolbar.tsx +++ b/apps/web/components/jira/my-jira/list-toolbar.tsx @@ -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(null); const builtin = views.filter((v) => v.builtin); const custom = views.filter((v) => !v.builtin); + const deletion = useSavedTaskViewDeleteConfirmation(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 ( - + { + setOpen(nextOpen); + if (!nextOpen) deletion.close(); + }} + > - + { + if (isActionConfirmationTarget(event.target)) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (isActionConfirmationTarget(event.target)) event.preventDefault(); + }} + > {custom.length > 0 && ( <> @@ -205,28 +245,56 @@ function ViewsDropdown({ onSelect(id); setOpen(false); }} - onDelete={onDelete} + {...deleteProps} /> )} + {isFinePointer && deletion.target ? ( + { + if (!nextOpen) deletion.close(); + }} + onConfirm={onDelete} + /> + ) : null} ); } +type ViewDeletionProps = { + isFinePointer: boolean; + deleteTarget: SavedTaskViewDeleteTarget | null; + deleteAnchorRef: RefObject; + 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 (
@@ -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} /> ))}
@@ -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 ( +
+ +
+ ); + } + return ( -
+
{!view.builtin && ( diff --git a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx index 973d6a379a..3b29d07b97 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx @@ -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( + Open} + 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( + Open} + 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( { "border-t", ); }); +}); +describe("SidebarFilterPopover option details", () => { it("removes the popover primitive's default section gap", () => { render( void; }; +type SidebarFilterSurfaceProps = Pick & { + editor: React.ReactNode; + deletion: { + target: SavedTaskViewDeleteTarget | null; + anchorRef: RefObject; + 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(views); + const popoverContentRef = useRef(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 ? ( + { + if (!nextOpen) deletion.close(); + }} + onConfirm={deleteView} + /> + ) : undefined; const headerProps: ComponentProps = { 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 ( - - {trigger} - - {editor} - - + ); } return ( - + + ); +} + +function DesktopSidebarFilterSurface({ + trigger, + open, + onOpenChange, + editor, + deletion, + contentRef, + isFinePointer, + onDelete, +}: SidebarFilterSurfaceProps & { + contentRef: RefObject; + isFinePointer: boolean; + onDelete: (id: string) => void; +}) { + return ( + <> + { + if (!nextOpen) deletion.close(); + onOpenChange(nextOpen); + }} + > + {trigger} + { + if (deletion.target && isActionConfirmationTarget(event.target)) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (deletion.target && isActionConfirmationTarget(event.target)) event.preventDefault(); + }} + > + {editor} + + + {isFinePointer && deletion.target ? ( + { + if (!nextOpen) deletion.close(); + }} + onConfirm={onDelete} + /> + ) : null} + + ); +} + +function MobileSidebarFilterSurface({ + trigger, + open, + onOpenChange, + editor, + deletion, + title, +}: SidebarFilterSurfaceProps & { title: string }) { + return ( + { + if (!nextOpen) deletion.close(); + onOpenChange(nextOpen); + }} + > {trigger} - {t("task:sidebarFilters")} + {title}
void; onDiscard: () => void; onDelete: () => void; + deleteAnchorRef?: RefObject; + 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
{props.deleteConfirmation}
; + } + return (
@@ -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; + deleteDensity?: "compact" | "touch"; }) { const { t } = useTranslation(); const canOverwrite = hasDraft && !!activeView; @@ -286,10 +304,11 @@ function ViewActions({ )} {!hasDraft && activeView && canDelete && ( { + if (isActionConfirmationTarget(event.target)) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (isActionConfirmationTarget(event.target)) event.preventDefault(); + }} > 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} diff --git a/apps/web/components/threads/threads-view-editor-actions.test.tsx b/apps/web/components/threads/threads-view-editor-actions.test.tsx new file mode 100644 index 0000000000..35bdf02361 --- /dev/null +++ b/apps/web/components/threads/threads-view-editor-actions.test.tsx @@ -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( + , + ); + + fireEvent.click(screen.getByTestId("threads-view-delete")); + expect(onDelete).toHaveBeenCalledOnce(); + + rerender( + } + 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(); + }); +}); diff --git a/apps/web/components/threads/threads-view-editor-actions.tsx b/apps/web/components/threads/threads-view-editor-actions.tsx index 512383ffea..68d213dbd7 100644 --- a/apps/web/components/threads/threads-view-editor-actions.tsx +++ b/apps/web/components/threads/threads-view-editor-actions.tsx @@ -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; + deleteConfirmation?: ReactNode; }) { const { t } = useTranslation(); + + if (deleteConfirmation) { + return
{deleteConfirmation}
; + } + return (
{hasDraft && ( @@ -66,6 +76,7 @@ export function EditorActions({ )} {!hasDraft && canDelete && (