feat(ui): use native mobile confirmation sheets (#3621)
This commit is contained in:
@@ -40,6 +40,7 @@ function CommandDialog({
|
|||||||
className,
|
className,
|
||||||
showCloseButton = false,
|
showCloseButton = false,
|
||||||
overlayClassName,
|
overlayClassName,
|
||||||
|
contentProps,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -47,15 +48,21 @@ function CommandDialog({
|
|||||||
className?: string;
|
className?: string;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
overlayClassName?: string;
|
overlayClassName?: string;
|
||||||
|
/** Optional overrides for the owning dialog, such as an application-local confirmation step. */
|
||||||
|
contentProps?: React.ComponentProps<typeof DialogContent>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
{...contentProps}
|
||||||
className={cn("rounded-xl! p-0 overflow-hidden", className)}
|
className={cn("rounded-xl! p-0 overflow-hidden", className)}
|
||||||
showCloseButton={showCloseButton}
|
showCloseButton={showCloseButton}
|
||||||
overlayClassName={overlayClassName}
|
overlayClassName={overlayClassName}
|
||||||
>
|
>
|
||||||
<DialogHeader className="sr-only">
|
<DialogHeader
|
||||||
|
className="sr-only"
|
||||||
|
aria-hidden={contentProps?.["aria-labelledby"] ? true : undefined}
|
||||||
|
>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ import { Dialog } from "@kandev/ui/dialog";
|
|||||||
- CSS visibility classes can leave the other viewport tree mounted. Gate live effects such as polling, subscriptions, timers, and measurements with the same responsive or pointer condition, and test both initialization orders when readiness can precede or follow hydration.
|
- CSS visibility classes can leave the other viewport tree mounted. Gate live effects such as polling, subscriptions, timers, and measurements with the same responsive or pointer condition, and test both initialization orders when readiness can precede or follow hydration.
|
||||||
- Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. Apply the 44px minimum to coarse-pointer hit areas, touch rows, and mobile controls only; keep fine-pointer desktop controls at the surrounding design-system density, using 28px for ordinary buttons, inputs, and selectors and 24px only for deliberate compact inline controls, and do not reuse a touch-sized `h-11` class as the shared visual button size. See the [sizing guide](../../.agents/skills/mobile-parity/references/control-sizing.md) for exceptions.
|
- Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. Apply the 44px minimum to coarse-pointer hit areas, touch rows, and mobile controls only; keep fine-pointer desktop controls at the surrounding design-system density, using 28px for ordinary buttons, inputs, and selectors and 24px only for deliberate compact inline controls, and do not reuse a touch-sized `h-11` class as the shared visual button size. See the [sizing guide](../../.agents/skills/mobile-parity/references/control-sizing.md) for exceptions.
|
||||||
- Below 640px, shared Radix DropdownMenu/ContextMenu use inset, safe-area-aware bottom sheets in `app/globals.css`. Open `mobile-menu-root` content owns one decorative positioner backdrop matching Drawer dimming and blur; fade it with the sheet's exit motion. Never mark submenus; keep the backdrop outside scrolling content and pointer-transparent so Radix owns dismissal and non-modal interaction. Reuse these primitives and cover long/nested menus instead of adding parallel mobile menus.
|
- Below 640px, shared Radix DropdownMenu/ContextMenu use inset, safe-area-aware bottom sheets in `app/globals.css`. Open `mobile-menu-root` content owns one decorative positioner backdrop matching Drawer dimming and blur; fade it with the sheet's exit motion. Never mark submenus; keep the backdrop outside scrolling content and pointer-transparent so Radix owns dismissal and non-modal interaction. Reuse these primitives and cover long/nested menus instead of adding parallel mobile menus.
|
||||||
- Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements.
|
- Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements; read [confirmation guidance](components/confirmation/AGENTS.md) when adopting phone confirmation surfaces.
|
||||||
- Phone listing chrome uses `KanbanHeaderMobile` and `MobileListingContext` across Kanban, List, and Threads. Threads supplies its saved-view control and inline pagination in the title slot. Phone search, tools, and plugin actions live in `MobileListingMenuActions`; keep activity/connection cues on the persistent menu button and restore the actual opener unless focus is moving into a launched surface. Tablet/desktop composition stays separate.
|
- Phone listing chrome uses `KanbanHeaderMobile` and `MobileListingContext` across Kanban, List, and Threads. Threads supplies its saved-view control and inline pagination in the title slot. Phone search, tools, and plugin actions live in `MobileListingMenuActions`; keep activity/connection cues on the persistent menu button and restore the actual opener unless focus is moving into a launched surface. Tablet/desktop composition stays separate.
|
||||||
|
|
||||||
## Data Flow Pattern (Critical)
|
## Data Flow Pattern (Critical)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { IconBrandGitlab, IconMenu2 } from "@tabler/icons-react";
|
import { IconBrandGitlab, IconMenu2 } from "@tabler/icons-react";
|
||||||
import { Alert, AlertDescription } from "@kandev/ui/alert";
|
import { Alert, AlertDescription } from "@kandev/ui/alert";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
|
|
||||||
import { PageShell } from "@/components/page-shell";
|
import { PageShell } from "@/components/page-shell";
|
||||||
import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
|
import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
|
||||||
import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab";
|
import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab";
|
||||||
@@ -31,6 +30,7 @@ import {
|
|||||||
import { toGitLabTaskPreset } from "@/components/gitlab/my-gitlab/task-presets";
|
import { toGitLabTaskPreset } from "@/components/gitlab/my-gitlab/task-presets";
|
||||||
import { useAppStore } from "@/components/state-provider";
|
import { useAppStore } from "@/components/state-provider";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { IntegrationFiltersSheet } from "@/components/integrations/integration-filters-sheet";
|
||||||
|
|
||||||
type GitLabPageClientProps = {
|
type GitLabPageClientProps = {
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
@@ -298,25 +298,22 @@ function GitLabPageOverlays({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
<IntegrationFiltersSheet
|
||||||
<SheetContent
|
key={workspaceId}
|
||||||
side="right"
|
open={mobileSidebarOpen}
|
||||||
className="w-full sm:max-w-sm overflow-y-auto p-0"
|
onOpenChange={setMobileSidebarOpen}
|
||||||
data-testid="gitlab-mobile-sidebar"
|
title={t("gitlab:filters")}
|
||||||
>
|
testId="gitlab-mobile-sidebar"
|
||||||
<SheetHeader className="px-4 pt-4 pb-2">
|
>
|
||||||
<SheetTitle>{t("gitlab:filters")}</SheetTitle>
|
<PresetsSidebar
|
||||||
</SheetHeader>
|
selected={state.selection}
|
||||||
<PresetsSidebar
|
onSelect={onMobileSidebarSelect}
|
||||||
selected={state.selection}
|
savedPresets={state.savedPresets}
|
||||||
onSelect={onMobileSidebarSelect}
|
onDeleteSaved={state.onDeleteSaved}
|
||||||
savedPresets={state.savedPresets}
|
canSaveCurrent={state.canSaveCurrent}
|
||||||
onDeleteSaved={state.onDeleteSaved}
|
onSaveCurrent={onMobileSaveCurrent}
|
||||||
canSaveCurrent={state.canSaveCurrent}
|
/>
|
||||||
onSaveCurrent={onMobileSaveCurrent}
|
</IntegrationFiltersSheet>
|
||||||
/>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
<SavePresetDialog
|
<SavePresetDialog
|
||||||
open={state.saveDialogOpen}
|
open={state.saveDialogOpen}
|
||||||
onOpenChange={state.setSaveDialogOpen}
|
onOpenChange={state.setSaveDialogOpen}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let finePointer = true;
|
let finePointer = true;
|
||||||
|
let isMobile = false;
|
||||||
|
|
||||||
vi.mock("@/components/toast-provider", () => ({
|
vi.mock("@/components/toast-provider", () => ({
|
||||||
useToast: () => ({ toast: mocks.toast }),
|
useToast: () => ({ toast: mocks.toast }),
|
||||||
@@ -29,7 +30,7 @@ vi.mock("@/hooks/domains/azure-devops/use-azure-devops-projects", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer }),
|
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer, isMobile }),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/api/domains/azure-devops-api", () => ({
|
vi.mock("@/lib/api/domains/azure-devops-api", () => ({
|
||||||
deleteAzureDevOpsConfig: mocks.deleteConfig,
|
deleteAzureDevOpsConfig: mocks.deleteConfig,
|
||||||
@@ -86,6 +87,7 @@ const config: AzureDevOpsConfig = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
finePointer = true;
|
finePointer = true;
|
||||||
|
isMobile = false;
|
||||||
mocks.getConfig.mockResolvedValue(config);
|
mocks.getConfig.mockResolvedValue(config);
|
||||||
mocks.setConfig.mockResolvedValue({
|
mocks.setConfig.mockResolvedValue({
|
||||||
...config,
|
...config,
|
||||||
@@ -205,6 +207,23 @@ describe("AzureDevOpsConnectionSection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AzureDevOpsConnectionSection removal", () => {
|
describe("AzureDevOpsConnectionSection removal", () => {
|
||||||
|
it("keeps phone removal in a sheet and restores the same button on Cancel", async () => {
|
||||||
|
isMobile = true;
|
||||||
|
finePointer = false;
|
||||||
|
render(
|
||||||
|
<SettingsSaveProvider>
|
||||||
|
<AzureDevOpsConnectionSection workspaceId={WORKSPACE_ID} />
|
||||||
|
</SettingsSaveProvider>,
|
||||||
|
);
|
||||||
|
const trigger = await screen.findByTestId(DELETE_BUTTON_TEST_ID);
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const sheet = screen.getByRole("dialog");
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(trigger.isConnected).toBe(true);
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(mocks.deleteConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
it("uses local fine-pointer confirmation and calls removal once after confirmation", async () => {
|
it("uses local fine-pointer confirmation and calls removal once after confirmation", async () => {
|
||||||
const nativeConfirm = vi.fn(() => false);
|
const nativeConfirm = vi.fn(() => false);
|
||||||
vi.stubGlobal("confirm", nativeConfirm);
|
vi.stubGlobal("confirm", nativeConfirm);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Separator } from "@kandev/ui/separator";
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import {
|
import {
|
||||||
IntegrationAuthStatusBanner,
|
IntegrationAuthStatusBanner,
|
||||||
type IntegrationAuthHealth,
|
type IntegrationAuthHealth,
|
||||||
@@ -441,10 +442,11 @@ function saveButtonLabel(t: (key: string) => string, state: SettingsState): stri
|
|||||||
|
|
||||||
function ConnectionActions({ state, disabled }: { state: SettingsState; disabled: boolean }) {
|
function ConnectionActions({ state, disabled }: { state: SettingsState; disabled: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
||||||
const removeAnchorRef = useRef<HTMLButtonElement>(null);
|
const removeAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const removeConfirmation = t("azuredevops:removeConfigurationConfirm");
|
const removeConfirmation = t("azuredevops:removeConfigurationConfirm");
|
||||||
|
const removeLabel = t("azuredevops:remove");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.config && confirmingRemove) setConfirmingRemove(false);
|
if (!state.config && confirmingRemove) setConfirmingRemove(false);
|
||||||
@@ -473,7 +475,7 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
|
|||||||
<IconDeviceFloppy className="h-4 w-4" />
|
<IconDeviceFloppy className="h-4 w-4" />
|
||||||
{saveButtonLabel(t, state)}
|
{saveButtonLabel(t, state)}
|
||||||
</Button>
|
</Button>
|
||||||
{state.config && (isFinePointer || !confirmingRemove) && (
|
{state.config && (isMobile || isFinePointer || !confirmingRemove) && (
|
||||||
<Button
|
<Button
|
||||||
ref={removeAnchorRef}
|
ref={removeAnchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -483,39 +485,53 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
|
|||||||
data-testid="azure-devops-delete-button"
|
data-testid="azure-devops-delete-button"
|
||||||
>
|
>
|
||||||
<IconTrash className="h-4 w-4" />
|
<IconTrash className="h-4 w-4" />
|
||||||
{t("azuredevops:remove")}
|
{removeLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{state.config && !isFinePointer && confirmingRemove ? (
|
<MobileActionConfirmation
|
||||||
<InlineConfirmActions
|
open={!!state.config && confirmingRemove}
|
||||||
density="touch"
|
targetKey={`${state.config?.workspaceId}:${state.config?.organizationUrl}`}
|
||||||
testId="azure-devops-remove-inline-confirmation"
|
title={removeConfirmation}
|
||||||
ariaLabel={removeConfirmation}
|
subject={state.config?.organizationUrl}
|
||||||
description={removeConfirmation}
|
cancelLabel={t("common:cancel")}
|
||||||
cancelLabel={t("common:cancel")}
|
confirmLabel={removeLabel}
|
||||||
confirmLabel={t("azuredevops:remove")}
|
confirmAriaLabel={removeConfirmation}
|
||||||
confirmAriaLabel={removeConfirmation}
|
confirmTestId="azure-devops-remove-confirm"
|
||||||
confirmTestId="azure-devops-remove-confirm"
|
onOpenChange={setConfirmingRemove}
|
||||||
onCancel={() => setConfirmingRemove(false)}
|
focusReturnRef={removeAnchorRef}
|
||||||
onClose={() => setConfirmingRemove(false)}
|
onConfirm={() => void state.remove()}
|
||||||
onConfirm={() => void state.remove()}
|
fallback={
|
||||||
/>
|
!isFinePointer ? (
|
||||||
) : null}
|
<InlineConfirmActions
|
||||||
{state.config && isFinePointer ? (
|
density="touch"
|
||||||
<ActionConfirmPopover
|
testId="azure-devops-remove-inline-confirmation"
|
||||||
open={confirmingRemove}
|
ariaLabel={removeConfirmation}
|
||||||
anchorRef={removeAnchorRef}
|
description={removeConfirmation}
|
||||||
title={removeConfirmation}
|
cancelLabel={t("common:cancel")}
|
||||||
cancelLabel={t("common:cancel")}
|
confirmLabel={removeLabel}
|
||||||
confirmLabel={t("azuredevops:remove")}
|
confirmAriaLabel={removeConfirmation}
|
||||||
confirmAriaLabel={removeConfirmation}
|
confirmTestId="azure-devops-remove-confirm"
|
||||||
confirmTestId="azure-devops-remove-confirm"
|
onCancel={() => setConfirmingRemove(false)}
|
||||||
testId="azure-devops-remove-confirm-popover"
|
onClose={() => setConfirmingRemove(false)}
|
||||||
onOpenChange={setConfirmingRemove}
|
onConfirm={() => void state.remove()}
|
||||||
onCancel={() => setConfirmingRemove(false)}
|
/>
|
||||||
onConfirm={() => void state.remove()}
|
) : (
|
||||||
/>
|
<ActionConfirmPopover
|
||||||
) : null}
|
open={confirmingRemove}
|
||||||
|
anchorRef={removeAnchorRef}
|
||||||
|
title={removeConfirmation}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
confirmLabel={removeLabel}
|
||||||
|
confirmAriaLabel={removeConfirmation}
|
||||||
|
confirmTestId="azure-devops-remove-confirm"
|
||||||
|
testId="azure-devops-remove-confirm-popover"
|
||||||
|
onOpenChange={setConfirmingRemove}
|
||||||
|
onCancel={() => setConfirmingRemove(false)}
|
||||||
|
onConfirm={() => void state.remove()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ function WatchActions({
|
|||||||
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
|
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
|
||||||
</Button>
|
</Button>
|
||||||
<WatcherDeleteAction
|
<WatcherDeleteAction
|
||||||
|
targetKey={`${watch.workspaceId}:${kind}:${watch.id}`}
|
||||||
|
subject={"wiql" in watch ? watch.wiql : watch.projectId}
|
||||||
title={t("azuredevops:deleteWatchConfirm")}
|
title={t("azuredevops:deleteWatchConfirm")}
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
confirmLabel={t("azuredevops:delete")}
|
confirmLabel={t("azuredevops:delete")}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { CommandPanelDialog } from "./command-panel-dialog";
|
||||||
|
import { MobileActionConfirmation } from "./confirmation/mobile-action-confirmation";
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
|
useResponsiveBreakpoint: () => ({ isMobile: true }),
|
||||||
|
}));
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("keeps the command dialog and its query while presenting one explicit decision step", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const onKeyDown = vi.fn();
|
||||||
|
function Panel() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<CommandPanelDialog open>
|
||||||
|
<div onKeyDown={onKeyDown}>
|
||||||
|
<input aria-label="Query" defaultValue="archive" />
|
||||||
|
<button ref={trigger} onClick={() => setOpen(true)}>
|
||||||
|
Archive command
|
||||||
|
</button>
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
targetKey="task"
|
||||||
|
title="Archive task?"
|
||||||
|
subject="Selected task"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Archive"
|
||||||
|
variant="default"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
focusReturnRef={trigger}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandPanelDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Panel />);
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
const query = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(query, { target: { value: "my query" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Archive command" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "Archive task?" })).toBe(dialog);
|
||||||
|
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
|
||||||
|
expect(screen.getAllByRole("heading")).toHaveLength(1);
|
||||||
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
|
fireEvent.keyDown(screen.getByRole("group"), { key: "Enter" });
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
expect(onKeyDown).not.toHaveBeenCalled();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(screen.getByRole("textbox")).toBe(query);
|
||||||
|
expect((query as HTMLInputElement).value).toBe("my query");
|
||||||
|
expect(screen.getByRole("dialog")).toBe(dialog);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { CommandDialog } from "@kandev/ui/command";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MobileConfirmationHost,
|
||||||
|
MobileConfirmationHostBody,
|
||||||
|
} from "./confirmation/mobile-confirmation-host";
|
||||||
|
|
||||||
|
export function CommandPanelDialog(props: ComponentProps<typeof CommandDialog>) {
|
||||||
|
return (
|
||||||
|
<MobileConfirmationHost open={Boolean(props.open)}>
|
||||||
|
{({ active, contentProps }) => (
|
||||||
|
<CommandDialog
|
||||||
|
{...props}
|
||||||
|
className={cn(props.className, active && "flex max-h-[calc(100dvh-2rem)] flex-col")}
|
||||||
|
contentProps={{ ...contentProps, enterConfirms: !active }}
|
||||||
|
>
|
||||||
|
<MobileConfirmationHostBody>{props.children}</MobileConfirmationHostBody>
|
||||||
|
</CommandDialog>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,13 +3,8 @@
|
|||||||
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import {
|
import { Command, CommandEmpty, CommandInput, CommandList } from "@kandev/ui/command";
|
||||||
Command,
|
import { CommandPanelDialog } from "./command-panel-dialog";
|
||||||
CommandDialog,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
} from "@kandev/ui/command";
|
|
||||||
import { Kbd, KbdGroup } from "@kandev/ui/kbd";
|
import { Kbd, KbdGroup } from "@kandev/ui/kbd";
|
||||||
import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types";
|
import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types";
|
||||||
import type { Task } from "@/lib/types/http";
|
import type { Task } from "@/lib/types/http";
|
||||||
@@ -322,7 +317,7 @@ export function CommandPanelView(props: CommandPanelViewProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog
|
<CommandPanelDialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
|
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
|
||||||
@@ -351,6 +346,6 @@ export function CommandPanelView(props: CommandPanelViewProps) {
|
|||||||
<CommandPanelResultList {...renderedProps} />
|
<CommandPanelResultList {...renderedProps} />
|
||||||
<CommandPanelFooter mode={renderedProps.mode} />
|
<CommandPanelFooter mode={renderedProps.mode} />
|
||||||
</Command>
|
</Command>
|
||||||
</CommandDialog>
|
</CommandPanelDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Confirmation surfaces
|
||||||
|
|
||||||
|
Phone confirmation adapters use `mobile-action-confirmation.tsx` with an explicit
|
||||||
|
localized title/target and a stable owner outside transient menus. Keep the
|
||||||
|
responsive adapter mounted with its non-phone `fallback` so boundary changes
|
||||||
|
cancel pending decisions.
|
||||||
|
|
||||||
|
Opt existing drawers into `MobileConfirmationHost`/`MobileConfirmationHostBody`
|
||||||
|
(pickers: `confirmationHost`); use `surface="drawer"` and spread its
|
||||||
|
`contentProps` on the owning surface. Active drawer steps fit the confirmation
|
||||||
|
content; the hidden origin keeps its measured dimensions outside layout so
|
||||||
|
Cancel can restore its normal size and scroll. Do not override the host's
|
||||||
|
active sizing with a fixed-height style. Centered dialog hosts omit `surface`.
|
||||||
|
Drawer roots use `overflow: clip` so focus cannot scroll the outer shell; only
|
||||||
|
the inner list or confirmation body owns scrolling. Initial Cancel focus uses
|
||||||
|
`preventScroll` to keep the title and actions in view.
|
||||||
|
A hosted step keeps the origin mounted/inert and opens no second modal. Form
|
||||||
|
dialog hosts also disable `enterConfirms` while the step is active.
|
||||||
|
|
||||||
|
Domain callbacks still own transport and error behavior. Ordinary actions close
|
||||||
|
before dispatch. Retryable form owners explicitly use
|
||||||
|
`completionPolicy="await-with-retry"` and reject on failure; this keeps their
|
||||||
|
confirmation busy during the request and available for retry afterward.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor, act } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { MobileActionConfirmation } from "./mobile-action-confirmation";
|
||||||
|
|
||||||
|
const viewport = vi.hoisted(() => ({ isMobile: true }));
|
||||||
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => viewport }));
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
viewport.isMobile = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function Harness({
|
||||||
|
onConfirm = vi.fn(),
|
||||||
|
completionPolicy,
|
||||||
|
targetKey = "A",
|
||||||
|
}: {
|
||||||
|
onConfirm?: () => void | Promise<void>;
|
||||||
|
completionPolicy?: "await-with-retry";
|
||||||
|
targetKey?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button ref={trigger} onClick={() => setOpen(true)}>
|
||||||
|
Remove A
|
||||||
|
</button>
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
targetKey={targetKey}
|
||||||
|
completionPolicy={completionPolicy}
|
||||||
|
title="Delete item?"
|
||||||
|
subject="A"
|
||||||
|
description="This cannot be undone."
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
focusReturnRef={trigger}
|
||||||
|
fallback={open ? <div>Desktop confirmation</div> : null}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("uses a standalone drawer and returns focus on cancellation", async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const trigger = screen.getByRole("button", { name: "Remove A" });
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(screen.getByRole("dialog", { name: "Delete item?" }).getAttribute("data-slot")).toBe(
|
||||||
|
"drawer-content",
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Cancel" })),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes before dispatch, prevents duplicate activation, and does not reopen on rejection", async () => {
|
||||||
|
let reject!: (reason: Error) => void;
|
||||||
|
let closedAtDispatch = false;
|
||||||
|
const onConfirm = vi.fn(() => {
|
||||||
|
closedAtDispatch = screen.queryByRole("group") === null;
|
||||||
|
return new Promise<void>((_, fail) => {
|
||||||
|
reject = fail;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
render(<Harness onConfirm={onConfirm} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
const button = screen.getByRole("button", { name: "Delete" });
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
||||||
|
expect(closedAtDispatch).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
reject(new Error("delete failed"));
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels crossing the phone boundary and leaves the non-phone fallback unchanged", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const { rerender } = render(<Harness onConfirm={onConfirm} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
expect(screen.getByRole("group")).toBeTruthy();
|
||||||
|
viewport.isMobile = false;
|
||||||
|
rerender(<Harness onConfirm={onConfirm} />);
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
expect(screen.queryByText("Desktop confirmation")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
expect(screen.getByText("Desktop confirmation")).toBeTruthy();
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a newly opened request to supply its target after the idle state", () => {
|
||||||
|
function SelectedTarget() {
|
||||||
|
const [target, setTarget] = useState<string | null>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setTarget("A")}>Choose A</button>
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={target !== null}
|
||||||
|
targetKey={target ?? ""}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTarget(null);
|
||||||
|
}}
|
||||||
|
title="Delete item?"
|
||||||
|
subject={target}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<SelectedTarget />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Choose A" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "Delete item?" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an opted-in request busy and retries rejection without duplicate dispatch", async () => {
|
||||||
|
let reject!: () => void;
|
||||||
|
const onConfirm = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((_, fail) => {
|
||||||
|
reject = fail;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
render(<Harness onConfirm={onConfirm} completionPolicy="await-with-retry" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
const button = screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement;
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
|
||||||
|
expect(screen.getByRole("dialog", { name: "Delete item?" })).toBeTruthy();
|
||||||
|
expect(button.disabled).toBe(true);
|
||||||
|
await act(async () => reject());
|
||||||
|
await waitFor(() => expect(button.disabled).toBe(false));
|
||||||
|
fireEvent.click(button);
|
||||||
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(2));
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an old awaited rejection replace a newer target", async () => {
|
||||||
|
let reject!: () => void;
|
||||||
|
const onConfirm = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((_, fail) => {
|
||||||
|
reject = fail;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { rerender } = render(
|
||||||
|
<Harness onConfirm={onConfirm} completionPolicy="await-with-retry" />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
||||||
|
rerender(<Harness onConfirm={onConfirm} completionPolicy="await-with-retry" targetKey="B" />);
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
await act(async () => reject());
|
||||||
|
expect(screen.getAllByRole("group")).toHaveLength(1);
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@kandev/ui/drawer";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
import {
|
||||||
|
MobileConfirmationContent,
|
||||||
|
type MobileConfirmationContentProps,
|
||||||
|
} from "./mobile-confirmation-content";
|
||||||
|
import { useMobileConfirmationHost } from "./mobile-confirmation-host";
|
||||||
|
import {
|
||||||
|
useMobileConfirmationSubmit,
|
||||||
|
type ConfirmationCompletionPolicy,
|
||||||
|
} from "./use-mobile-confirmation-submit";
|
||||||
|
|
||||||
|
export type MobileActionConfirmationProps = Omit<
|
||||||
|
MobileConfirmationContentProps,
|
||||||
|
"onConfirm" | "onCancel"
|
||||||
|
> & {
|
||||||
|
open: boolean;
|
||||||
|
targetKey: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
/** Local owner dismissal before dispatch, when it differs from cancellation. */
|
||||||
|
onClose?: () => void;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
completionPolicy?: ConfirmationCompletionPolicy;
|
||||||
|
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keep this adapter mounted across responsive branches so a boundary change cancels the request. */
|
||||||
|
export function MobileActionConfirmation(props: MobileActionConfirmationProps) {
|
||||||
|
const { isMobile, changed } = useConfirmationBoundary(
|
||||||
|
props.open,
|
||||||
|
props.targetKey,
|
||||||
|
props.onOpenChange,
|
||||||
|
);
|
||||||
|
if (changed || !props.open) return null;
|
||||||
|
if (!isMobile) return props.fallback ?? null;
|
||||||
|
return <OpenMobileConfirmation {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfirmationBoundary(
|
||||||
|
open: boolean,
|
||||||
|
targetKey: string,
|
||||||
|
onOpenChange: (open: boolean) => void,
|
||||||
|
) {
|
||||||
|
const viewport = useResponsiveBreakpoint();
|
||||||
|
const { isMobile } = viewport;
|
||||||
|
const previous = useRef({ isMobile, targetKey, open });
|
||||||
|
const changed =
|
||||||
|
previous.current.open &&
|
||||||
|
open &&
|
||||||
|
(previous.current.isMobile !== isMobile || previous.current.targetKey !== targetKey);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
previous.current = { isMobile, targetKey, open };
|
||||||
|
if (changed && open) onOpenChange(false);
|
||||||
|
});
|
||||||
|
return { ...viewport, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenMobileConfirmation({
|
||||||
|
onOpenChange,
|
||||||
|
onCancel,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
completionPolicy = "close-before-dispatch",
|
||||||
|
focusReturnRef,
|
||||||
|
fallback: _fallback,
|
||||||
|
open: _open,
|
||||||
|
targetKey: _targetKey,
|
||||||
|
...content
|
||||||
|
}: MobileActionConfirmationProps) {
|
||||||
|
const host = useMobileConfirmationHost();
|
||||||
|
const id = useId();
|
||||||
|
const token = useRef(Symbol());
|
||||||
|
const [closed, setClosed] = useState(false);
|
||||||
|
const { submit, submitted, pending } = useMobileConfirmationSubmit({
|
||||||
|
closed,
|
||||||
|
disabled: !!(content.disabled || content.confirmDisabled),
|
||||||
|
onConfirm,
|
||||||
|
completionPolicy,
|
||||||
|
close: () => {
|
||||||
|
setClosed(true);
|
||||||
|
if (onClose) onClose();
|
||||||
|
else onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const initialFocus = useRef(document.activeElement as HTMLElement | null);
|
||||||
|
const latest = useRef({ onOpenChange, onCancel, disabled: content.disabled });
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
latest.current = { onOpenChange, onCancel, disabled: content.disabled };
|
||||||
|
});
|
||||||
|
const titleId = `${id}-title`;
|
||||||
|
const descriptionId = `${id}-description`;
|
||||||
|
|
||||||
|
const cancel = useRef((invalidated = false) => {
|
||||||
|
if (!invalidated && (submitted.current || latest.current.disabled)) return;
|
||||||
|
setClosed(true);
|
||||||
|
latest.current.onCancel?.();
|
||||||
|
latest.current.onOpenChange(false);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const target = focusReturnRef?.current ?? initialFocus.current;
|
||||||
|
if (host) host.restoreFocus(target);
|
||||||
|
else if (target?.isConnected && !target.closest("[inert]"))
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}).current;
|
||||||
|
const register = host?.register;
|
||||||
|
const release = host?.release;
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!register || !release || closed) return;
|
||||||
|
const requestToken = token.current;
|
||||||
|
register({ token: requestToken, titleId, descriptionId, cancel });
|
||||||
|
return () => release(requestToken);
|
||||||
|
}, [register, release, closed, titleId, descriptionId, cancel]);
|
||||||
|
|
||||||
|
const body = closed ? null : (
|
||||||
|
<MobileConfirmationContent
|
||||||
|
{...content}
|
||||||
|
disabled={content.disabled || pending}
|
||||||
|
titleId={titleId}
|
||||||
|
descriptionId={descriptionId}
|
||||||
|
onBack={host ? () => cancel() : undefined}
|
||||||
|
onCancel={() => cancel()}
|
||||||
|
onConfirm={submit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (host)
|
||||||
|
return host.outlet && host.request?.token === token.current
|
||||||
|
? createPortal(body, host.outlet)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={!closed}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) cancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerContent
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
className="data-[vaul-drawer-direction=bottom]:max-h-[calc(100dvh-1rem)] data-[vaul-drawer-direction=bottom]:mt-0 overflow-hidden"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerTitle asChild>
|
||||||
|
<span hidden>{content.title}</span>
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription asChild>
|
||||||
|
<span hidden>{content.subject ?? content.title}</span>
|
||||||
|
</DrawerDescription>
|
||||||
|
{body}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { MobileConfirmationContent } from "./mobile-confirmation-content";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates confirmation mouse events from row selection owners", () => {
|
||||||
|
const onMouseDown = vi.fn();
|
||||||
|
render(
|
||||||
|
<div onMouseDown={onMouseDown}>
|
||||||
|
<MobileConfirmationContent
|
||||||
|
title="Delete file?"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
fireEvent.mouseDown(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onMouseDown).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates Back with the active locale without reopening", async () => {
|
||||||
|
render(
|
||||||
|
<MobileConfirmationContent
|
||||||
|
title="Delete file?"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const back = screen.getByRole("button", { name: "Back" });
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await i18n.changeLanguage("pt-pt");
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Voltar" })).toBe(back);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the target, focuses Cancel, and requires explicit action activation", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const focus = vi.spyOn(HTMLElement.prototype, "focus");
|
||||||
|
render(
|
||||||
|
<MobileConfirmationContent
|
||||||
|
title="Delete file?"
|
||||||
|
subject="notes.txt"
|
||||||
|
description="This file will be deleted."
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const group = screen.getByRole("group", { name: "Delete file?" });
|
||||||
|
expect(screen.getByText("notes.txt")).toBeTruthy();
|
||||||
|
expect(group.getAttribute("aria-describedby")).toBeTruthy();
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
fireEvent.keyDown(group, { key: "Enter" });
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps cancellation available when the action is disabled", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<MobileConfirmationContent
|
||||||
|
title="Archive task?"
|
||||||
|
subject="Task A"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Archive"
|
||||||
|
variant="default"
|
||||||
|
confirmDisabled
|
||||||
|
onCancel={onCancel}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const confirm = screen.getByRole("button", { name: "Archive" });
|
||||||
|
expect(confirm.getAttribute("data-variant")).toBe("default");
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, useLayoutEffect, useRef, type ReactNode, type KeyboardEvent } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
import { Button } from "@kandev/ui/button";
|
||||||
|
|
||||||
|
export type MobileConfirmationContentProps = {
|
||||||
|
title: ReactNode;
|
||||||
|
subject?: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
titleId?: string;
|
||||||
|
descriptionId?: string;
|
||||||
|
cancelLabel: ReactNode;
|
||||||
|
confirmLabel: ReactNode;
|
||||||
|
confirmAriaLabel?: string;
|
||||||
|
confirmTestId?: string;
|
||||||
|
testId?: string;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
disabled?: boolean;
|
||||||
|
confirmDisabled?: boolean;
|
||||||
|
onBack?: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cancelOnEscape(event: KeyboardEvent, disabled: boolean | undefined, cancel: () => void) {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!disabled) cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileConfirmationContent({
|
||||||
|
title,
|
||||||
|
subject,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
titleId,
|
||||||
|
descriptionId,
|
||||||
|
cancelLabel,
|
||||||
|
confirmLabel,
|
||||||
|
confirmAriaLabel,
|
||||||
|
confirmTestId,
|
||||||
|
testId,
|
||||||
|
variant = "destructive",
|
||||||
|
disabled,
|
||||||
|
confirmDisabled,
|
||||||
|
onBack,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: MobileConfirmationContentProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const id = useId();
|
||||||
|
const headingId = titleId ?? `${id}-title`;
|
||||||
|
const bodyId = descriptionId ?? `${id}-description`;
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
cancelRef.current?.focus({ preventScroll: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
role="group"
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
aria-describedby={bodyId}
|
||||||
|
data-testid={testId ?? "mobile-action-confirmation"}
|
||||||
|
data-confirmation-boundary=""
|
||||||
|
className="flex min-h-0 min-w-0 flex-1 flex-col text-left text-sm text-foreground"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyDownCapture={(event) => cancelOnEscape(event, disabled, onCancel)}
|
||||||
|
>
|
||||||
|
<header className="shrink-0 px-4 pb-3 pt-2">
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onBack}
|
||||||
|
className="-ml-3 mb-1 min-h-11 cursor-pointer px-3 text-sm"
|
||||||
|
>
|
||||||
|
<IconArrowLeft className="size-4" aria-hidden="true" />
|
||||||
|
{t("common:back")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<h2 id={headingId} className="text-lg font-semibold [overflow-wrap:anywhere]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
data-testid="mobile-confirmation-body"
|
||||||
|
className="min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-4 text-sm leading-6 [overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
<div id={bodyId} className="space-y-3">
|
||||||
|
{subject && <p className="font-medium">{subject}</p>}
|
||||||
|
{description && <div>{description}</div>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<footer className="flex shrink-0 flex-col gap-2 px-4 pt-5 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
disabled={disabled || confirmDisabled}
|
||||||
|
aria-label={confirmAriaLabel}
|
||||||
|
data-testid={confirmTestId}
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="h-auto min-h-12 w-full cursor-pointer whitespace-normal px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
ref={cancelRef}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onCancel}
|
||||||
|
className="h-auto min-h-12 w-full cursor-pointer whitespace-normal px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { MobileConfirmationHost, MobileConfirmationHostBody } from "./mobile-confirmation-host";
|
||||||
|
import { MobileActionConfirmation } from "./mobile-action-confirmation";
|
||||||
|
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
|
useResponsiveBreakpoint: () => ({ isMobile: true }),
|
||||||
|
}));
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function Harness({
|
||||||
|
showSource = true,
|
||||||
|
showTrigger = true,
|
||||||
|
parentOpen = true,
|
||||||
|
target = "A",
|
||||||
|
onConfirm = vi.fn(),
|
||||||
|
disabled = false,
|
||||||
|
onCancel = vi.fn(),
|
||||||
|
surface = "dialog" as "drawer" | "dialog",
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<MobileConfirmationHost open={parentOpen} surface={surface}>
|
||||||
|
{({ contentProps }) => (
|
||||||
|
<div role="dialog" {...contentProps}>
|
||||||
|
<MobileConfirmationHostBody>
|
||||||
|
<input aria-label="Filter" defaultValue="unfinished draft" />
|
||||||
|
<div data-testid="scroll-owner" style={{ overflowY: "auto", height: 100 }}>
|
||||||
|
{showTrigger && (
|
||||||
|
<button ref={trigger} onClick={() => setOpen(true)}>
|
||||||
|
Remove {target}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showSource && (
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={open}
|
||||||
|
disabled={disabled}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
targetKey={target}
|
||||||
|
title="Delete item?"
|
||||||
|
subject={target}
|
||||||
|
description="Deletes the selected item."
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
focusReturnRef={trigger}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHostBody>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(["drawer", "dialog"] as const)(
|
||||||
|
"%s restores the mounted draft, scroll and initiating focus",
|
||||||
|
async (surface) => {
|
||||||
|
render(<Harness surface={surface} />);
|
||||||
|
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||||
|
const scroll = screen.getByTestId("scroll-owner");
|
||||||
|
scroll.scrollTop = 48;
|
||||||
|
fireEvent.change(input, { target: { value: "my filter" } });
|
||||||
|
const trigger = screen.getByRole("button", { name: "Remove A" });
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(screen.getAllByRole("dialog")).toHaveLength(1);
|
||||||
|
expect(screen.getByRole("dialog").getAttribute("aria-labelledby")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("group", { name: "Delete item?" }).closest("[inert]")).toBeNull();
|
||||||
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
|
expect(input.isConnected).toBe(true);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
expect(screen.getByRole("textbox")).toBe(input);
|
||||||
|
expect(input.value).toBe("my filter");
|
||||||
|
expect(scroll.scrollTop).toBe(48);
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Escape cancels only the step without invoking the action", () => {
|
||||||
|
const outsideKey = vi.fn();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(
|
||||||
|
<div onKeyDown={outsideKey}>
|
||||||
|
<Harness onConfirm={onConfirm} />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
fireEvent.keyDown(screen.getByRole("button", { name: "Cancel" }), { key: "Escape" });
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||||
|
expect(outsideKey).not.toHaveBeenCalled();
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns focus to a visible host control if its initiating control disappears", async () => {
|
||||||
|
const { rerender } = render(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
rerender(<Harness showTrigger={false} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("textbox")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates the decision when its source unmounts", () => {
|
||||||
|
const { rerender } = render(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
expect(screen.getByRole("group")).toBeTruthy();
|
||||||
|
rerender(<Harness showSource={false} />);
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resurrect a decision after closing and reopening its parent", () => {
|
||||||
|
const { rerender } = render(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
rerender(<Harness parentOpen={false} />);
|
||||||
|
rerender(<Harness parentOpen />);
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates even a disabled decision when its parent is dismissed", () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const { rerender } = render(<Harness disabled onCancel={onCancel} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
rerender(<Harness disabled parentOpen={false} onCancel={onCancel} />);
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce();
|
||||||
|
rerender(<Harness disabled onCancel={onCancel} />);
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not redirect a pending decision to a replacement target", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const { rerender } = render(<Harness onConfirm={onConfirm} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
|
||||||
|
expect(screen.getByRole("group")).toBeTruthy();
|
||||||
|
rerender(<Harness target="B" onConfirm={onConfirm} />);
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a picker opt into one hosted step without changing its normal title or list", () => {
|
||||||
|
function Picker() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<MobilePickerSheet open onOpenChange={vi.fn()} title="Terminals" confirmationHost>
|
||||||
|
<button onClick={() => setOpen(true)}>Close shell</button>
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
targetKey="shell"
|
||||||
|
title="Close terminal?"
|
||||||
|
subject="Shell"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Close"
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MobilePickerSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Picker />);
|
||||||
|
const picker = screen.getByRole("dialog", { name: "Terminals" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close shell" }));
|
||||||
|
expect(screen.getAllByRole("dialog")).toHaveLength(1);
|
||||||
|
expect(screen.getByRole("dialog", { name: "Close terminal?" })).toBe(picker);
|
||||||
|
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(screen.getByRole("dialog", { name: "Terminals" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("group")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an older request's cleanup cannot remove the replacement request", () => {
|
||||||
|
function Requests({ second }: { second: boolean }) {
|
||||||
|
const [first, setFirst] = useState(true);
|
||||||
|
return (
|
||||||
|
<MobileConfirmationHost open>
|
||||||
|
{({ contentProps }) => (
|
||||||
|
<div role="dialog" {...contentProps}>
|
||||||
|
<MobileConfirmationHostBody>
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={first}
|
||||||
|
onOpenChange={setFirst}
|
||||||
|
targetKey="first"
|
||||||
|
title="First?"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={second}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
targetKey="second"
|
||||||
|
title="Second?"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MobileConfirmationHostBody>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { rerender } = render(<Requests second={false} />);
|
||||||
|
expect(screen.getByRole("group", { name: "First?" })).toBeTruthy();
|
||||||
|
rerender(<Requests second />);
|
||||||
|
expect(screen.getByRole("group", { name: "Second?" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("group", { name: "First?" })).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type Request = {
|
||||||
|
token: symbol;
|
||||||
|
titleId: string;
|
||||||
|
descriptionId: string;
|
||||||
|
cancel: (invalidated?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HostContext = {
|
||||||
|
request: Request | null;
|
||||||
|
outlet: HTMLDivElement | null;
|
||||||
|
setOutlet: (node: HTMLDivElement | null) => void;
|
||||||
|
register: (request: Request) => void;
|
||||||
|
release: (token: symbol) => void;
|
||||||
|
originRef: RefObject<HTMLDivElement | null>;
|
||||||
|
originSize: { width: number; height: number } | null;
|
||||||
|
restoreFocus: (preferred: HTMLElement | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Context = createContext<HostContext | null>(null);
|
||||||
|
export const useMobileConfirmationHost = () => useContext(Context);
|
||||||
|
|
||||||
|
type HostState = {
|
||||||
|
active: boolean;
|
||||||
|
contentProps: {
|
||||||
|
"aria-labelledby"?: string;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
||||||
|
style?: CSSProperties;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Owns one step, not another modal. The actual Drawer/Dialog receives contentProps. */
|
||||||
|
export function MobileConfirmationHost({
|
||||||
|
open,
|
||||||
|
surface = "dialog",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
surface?: "drawer" | "dialog";
|
||||||
|
children: (state: HostState) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const current = useRef<Request | null>(null);
|
||||||
|
const originRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [request, setRequest] = useState<Request | null>(null);
|
||||||
|
const [outlet, setOutlet] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [originSize, setOriginSize] = useState<HostContext["originSize"]>(null);
|
||||||
|
const restoreFocus = useCallback((preferred: HTMLElement | null) => {
|
||||||
|
if (current.current || !originRef.current?.isConnected) return;
|
||||||
|
if (preferred && originRef.current.contains(preferred)) {
|
||||||
|
preferred.focus({ preventScroll: true });
|
||||||
|
if (document.activeElement === preferred) return;
|
||||||
|
}
|
||||||
|
const controls = originRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], [tabindex="0"]',
|
||||||
|
);
|
||||||
|
const fallback = Array.from(controls).find(
|
||||||
|
(control) => !control.closest('[hidden], [inert], [aria-hidden="true"]'),
|
||||||
|
);
|
||||||
|
fallback?.focus({ preventScroll: true });
|
||||||
|
}, []);
|
||||||
|
const release = useCallback((token: symbol) => {
|
||||||
|
if (current.current?.token !== token) return;
|
||||||
|
current.current = null;
|
||||||
|
setRequest(null);
|
||||||
|
setOriginSize(null);
|
||||||
|
}, []);
|
||||||
|
const register = useCallback(
|
||||||
|
(next: Request) => {
|
||||||
|
if (current.current?.token === next.token) return;
|
||||||
|
if (surface === "drawer" && originRef.current) {
|
||||||
|
const { width, height } = originRef.current.getBoundingClientRect();
|
||||||
|
setOriginSize({ width, height });
|
||||||
|
}
|
||||||
|
const previous = current.current;
|
||||||
|
current.current = next;
|
||||||
|
previous?.cancel(true);
|
||||||
|
setRequest(next);
|
||||||
|
},
|
||||||
|
[surface],
|
||||||
|
);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open || !current.current) return;
|
||||||
|
const previous = current.current;
|
||||||
|
release(previous.token);
|
||||||
|
previous.cancel(true);
|
||||||
|
}, [open, request, release]);
|
||||||
|
const context = useMemo(
|
||||||
|
() => ({ request, outlet, setOutlet, register, release, originRef, originSize, restoreFocus }),
|
||||||
|
[request, outlet, register, release, originSize, restoreFocus],
|
||||||
|
);
|
||||||
|
// Only the inner list/body scrolls; focus must not scroll Vaul's outer shell.
|
||||||
|
const style: CSSProperties | undefined =
|
||||||
|
surface === "drawer"
|
||||||
|
? {
|
||||||
|
overflow: "clip",
|
||||||
|
...(request ? { height: "auto", maxHeight: "calc(100dvh - 1rem)", marginTop: 0 } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const contentProps: HostState["contentProps"] = request
|
||||||
|
? {
|
||||||
|
"aria-labelledby": request.titleId,
|
||||||
|
"aria-describedby": request.descriptionId,
|
||||||
|
style,
|
||||||
|
onEscapeKeyDown: (event: KeyboardEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
request.cancel();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { style };
|
||||||
|
return (
|
||||||
|
<Context.Provider value={context}>
|
||||||
|
{children({ active: request !== null, contentProps })}
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hidden origin retains its scroll geometry without sizing an active drawer step. */
|
||||||
|
export function MobileConfirmationHostBody({ children }: { children: ReactNode }) {
|
||||||
|
const host = useMobileConfirmationHost();
|
||||||
|
if (!host) return children;
|
||||||
|
const active = host.request !== null;
|
||||||
|
const originStyle: CSSProperties = {};
|
||||||
|
if (active) {
|
||||||
|
originStyle.visibility = "hidden";
|
||||||
|
if (host.originSize) {
|
||||||
|
Object.assign(originStyle, host.originSize, { position: "absolute", top: 0, left: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="relative grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-1">
|
||||||
|
<div
|
||||||
|
ref={host.originRef}
|
||||||
|
aria-hidden={active || undefined}
|
||||||
|
inert={active || undefined}
|
||||||
|
style={originStyle}
|
||||||
|
className="col-start-1 row-start-1 flex min-h-0 min-w-0 flex-col"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={host.setOutlet}
|
||||||
|
className="col-start-1 row-start-1 flex min-h-0 min-w-0 flex-col"
|
||||||
|
hidden={!active}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,7 +67,23 @@ function ReplacingHarness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SavedTaskViewDeleteConfirmation", () => {
|
describe("SavedTaskViewDeleteConfirmation", () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["inline", "popover"] as const)(
|
||||||
|
"uses a phone sheet before the %s presentation hint",
|
||||||
|
async (presentation) => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(<Harness presentation={presentation} onConfirm={onConfirm} />);
|
||||||
|
const dialog = screen.getByRole("dialog", { name: TARGET_TITLE });
|
||||||
|
expect(dialog.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete Needs review" }));
|
||||||
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledExactlyOnceWith(TARGET.id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("names the target and dispatches its captured id only after fine-pointer confirmation", async () => {
|
it("names the target and dispatches its captured id only after fine-pointer confirmation", async () => {
|
||||||
const onConfirm = vi.fn();
|
const onConfirm = vi.fn();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ActionConfirmPopover } from "./action-confirm-popover";
|
import { ActionConfirmPopover } from "./action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "./inline-confirm-actions";
|
import { InlineConfirmActions } from "./inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "./mobile-action-confirmation";
|
||||||
import type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
|
import type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
|
||||||
|
|
||||||
export type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
|
export type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
|
||||||
@@ -38,53 +39,56 @@ export function SavedTaskViewDeleteConfirmation({
|
|||||||
const title = t("common:deleteSavedTaskViewTitle", { name: target.label });
|
const title = t("common:deleteSavedTaskViewTitle", { name: target.label });
|
||||||
const description = t("common:deleteSavedTaskViewDescription");
|
const description = t("common:deleteSavedTaskViewDescription");
|
||||||
|
|
||||||
if (presentation === "popover") {
|
const actions = {
|
||||||
return (
|
description,
|
||||||
|
cancelLabel: t("common:cancel"),
|
||||||
|
confirmLabel: t("common:delete"),
|
||||||
|
confirmAriaLabel: t("common:deleteSavedTaskViewAction", { name: target.label }),
|
||||||
|
confirmDisabled,
|
||||||
|
testId,
|
||||||
|
confirmTestId,
|
||||||
|
onConfirm: () => onConfirm(target.id),
|
||||||
|
};
|
||||||
|
const fallback =
|
||||||
|
presentation === "popover" ? (
|
||||||
<ActionConfirmPopover
|
<ActionConfirmPopover
|
||||||
|
{...actions}
|
||||||
open={open}
|
open={open}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
focusBoundaryRef={focusBoundaryRef}
|
focusBoundaryRef={focusBoundaryRef}
|
||||||
title={title}
|
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
|
confirmationBoundary
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
onConfirm={() => onConfirm(target.id)}
|
/>
|
||||||
|
) : (
|
||||||
|
<InlineConfirmActions
|
||||||
|
{...actions}
|
||||||
|
density="touch"
|
||||||
|
ariaLabel={title}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<span className="block font-medium text-foreground">{title}</span>
|
||||||
|
<span className="mt-1 block">{description}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onCancel={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (anchorRef.current?.isConnected) anchorRef.current.focus();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InlineConfirmActions
|
<MobileActionConfirmation
|
||||||
density="touch"
|
{...actions}
|
||||||
testId={testId}
|
open={open}
|
||||||
ariaLabel={title}
|
title={title}
|
||||||
description={
|
targetKey={target.id}
|
||||||
<>
|
focusReturnRef={anchorRef}
|
||||||
<span className="block font-medium text-foreground">{title}</span>
|
onOpenChange={onOpenChange}
|
||||||
<span className="mt-1 block">{description}</span>
|
fallback={fallback}
|
||||||
</>
|
|
||||||
}
|
|
||||||
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,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type ConfirmationCompletionPolicy = "close-before-dispatch" | "await-with-retry";
|
||||||
|
|
||||||
|
/** Only owners with retryable forms opt into waiting; ordinary actions dismiss before dispatch. */
|
||||||
|
export function useMobileConfirmationSubmit({
|
||||||
|
closed,
|
||||||
|
disabled,
|
||||||
|
close,
|
||||||
|
onConfirm,
|
||||||
|
completionPolicy,
|
||||||
|
}: {
|
||||||
|
closed: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
close: () => void;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
completionPolicy: ConfirmationCompletionPolicy;
|
||||||
|
}) {
|
||||||
|
const submitted = useRef(false);
|
||||||
|
const live = useRef(true);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
live.current = !closed;
|
||||||
|
return () => {
|
||||||
|
live.current = false;
|
||||||
|
};
|
||||||
|
}, [closed]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (submitted.current || closed || disabled) return;
|
||||||
|
submitted.current = true;
|
||||||
|
if (completionPolicy === "close-before-dispatch") {
|
||||||
|
close();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(onConfirm)
|
||||||
|
.catch(() => undefined);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(onConfirm)
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
if (live.current) close();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!live.current) return;
|
||||||
|
submitted.current = false;
|
||||||
|
setPending(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return { submit, submitted, pending };
|
||||||
|
}
|
||||||
@@ -1,12 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useConfirmationBoundary } from "./mobile-action-confirmation";
|
||||||
|
|
||||||
export type SavedTaskViewDeleteTarget = {
|
export type SavedTaskViewDeleteTarget = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Transient menus close before a phone sheet takes focus. */
|
||||||
|
export function useSavedTaskViewDeleteMenu<T extends HTMLElement = HTMLElement>(
|
||||||
|
availableTargets: ReadonlyArray<{ id: string }>,
|
||||||
|
isMobile: boolean,
|
||||||
|
) {
|
||||||
|
const deletion = useSavedTaskViewDeleteConfirmation<T>(availableTargets);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const pendingTarget = useRef<SavedTaskViewDeleteTarget | null>(null);
|
||||||
|
const onOpenChange = (nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) deletion.close();
|
||||||
|
};
|
||||||
|
const request = (target: SavedTaskViewDeleteTarget) => {
|
||||||
|
if (!isMobile) return deletion.request(target);
|
||||||
|
pendingTarget.current = target;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onCloseAutoFocus = (event: Event) => {
|
||||||
|
const target = pendingTarget.current;
|
||||||
|
pendingTarget.current = null;
|
||||||
|
if (!target || !isMobile) return;
|
||||||
|
event.preventDefault();
|
||||||
|
deletion.request(target);
|
||||||
|
};
|
||||||
|
return { ...deletion, request, open, onOpenChange, onCloseAutoFocus };
|
||||||
|
}
|
||||||
|
|
||||||
export function useSavedTaskViewDeleteConfirmation<T extends HTMLElement = HTMLElement>(
|
export function useSavedTaskViewDeleteConfirmation<T extends HTMLElement = HTMLElement>(
|
||||||
availableTargets: ReadonlyArray<{ id: string }>,
|
availableTargets: ReadonlyArray<{ id: string }>,
|
||||||
) {
|
) {
|
||||||
@@ -18,6 +46,7 @@ export function useSavedTaskViewDeleteConfirmation<T extends HTMLElement = HTMLE
|
|||||||
requestedTarget && availableTargets.some((item) => item.id === requestedTarget.id)
|
requestedTarget && availableTargets.some((item) => item.id === requestedTarget.id)
|
||||||
? requestedTarget
|
? requestedTarget
|
||||||
: null;
|
: null;
|
||||||
|
useConfirmationBoundary(Boolean(target), target?.id ?? "", () => setRequestedTarget(null));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requestedTarget && !target) setRequestedTarget(null);
|
if (requestedTarget && !target) setRequestedTarget(null);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
} from "@kandev/ui/drawer";
|
} from "@kandev/ui/drawer";
|
||||||
import { PresetsSidebar, type PresetsSidebarProps } from "./presets-sidebar";
|
import { PresetsSidebar, type PresetsSidebarProps } from "./presets-sidebar";
|
||||||
|
import {
|
||||||
|
MobileConfirmationHost,
|
||||||
|
MobileConfirmationHostBody,
|
||||||
|
} from "@/components/confirmation/mobile-confirmation-host";
|
||||||
|
|
||||||
export function MobileViewsPicker({
|
export function MobileViewsPicker({
|
||||||
title,
|
title,
|
||||||
@@ -30,51 +34,58 @@ export function MobileViewsPicker({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<MobileConfirmationHost open={open} surface="drawer">
|
||||||
<DrawerTrigger asChild>
|
{({ contentProps }) => (
|
||||||
<Button
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
variant="outline"
|
<DrawerTrigger asChild>
|
||||||
className="h-11 min-w-0 w-full justify-between gap-2 text-sm font-normal"
|
<Button
|
||||||
data-testid="github-mobile-menu-button"
|
variant="outline"
|
||||||
aria-label={t("github:viewPicker", { view: title })}
|
className="h-11 min-w-0 w-full justify-between gap-2 text-sm font-normal"
|
||||||
>
|
data-testid="github-mobile-menu-button"
|
||||||
<span className="min-w-0 truncate">
|
aria-label={t("github:viewPicker", { view: title })}
|
||||||
<span className="text-muted-foreground">{t("github:views")}:</span>{" "}
|
>
|
||||||
<span className="font-medium" data-testid="github-list-toolbar-title">
|
<span className="min-w-0 truncate">
|
||||||
{title}
|
<span className="text-muted-foreground">{t("github:views")}:</span>{" "}
|
||||||
</span>
|
<span className="font-medium" data-testid="github-list-toolbar-title">
|
||||||
</span>
|
{title}
|
||||||
<IconChevronDown className="h-4 w-4 shrink-0" />
|
</span>
|
||||||
</Button>
|
</span>
|
||||||
</DrawerTrigger>
|
<IconChevronDown className="h-4 w-4 shrink-0" />
|
||||||
<DrawerContent
|
</Button>
|
||||||
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
</DrawerTrigger>
|
||||||
data-testid="github-mobile-sidebar"
|
<DrawerContent
|
||||||
aria-describedby={undefined}
|
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
||||||
onCloseAutoFocus={() => {
|
data-testid="github-mobile-sidebar"
|
||||||
if (!saveAfterClose.current) return;
|
aria-describedby={undefined}
|
||||||
saveAfterClose.current = false;
|
{...contentProps}
|
||||||
saveFrame.current = requestAnimationFrame(onSaveCurrent);
|
onCloseAutoFocus={() => {
|
||||||
}}
|
if (!saveAfterClose.current) return;
|
||||||
>
|
saveAfterClose.current = false;
|
||||||
<DrawerHeader className="flex-row shrink-0 items-center justify-between px-3 py-2">
|
saveFrame.current = requestAnimationFrame(onSaveCurrent);
|
||||||
<DrawerTitle>{t("github:views")}</DrawerTitle>
|
}}
|
||||||
<DrawerClose asChild>
|
>
|
||||||
<Button variant="ghost">{t("task:done")}</Button>
|
<MobileConfirmationHostBody>
|
||||||
</DrawerClose>
|
<DrawerHeader className="flex-row shrink-0 items-center justify-between px-3 py-2">
|
||||||
</DrawerHeader>
|
<DrawerTitle>{t("github:views")}</DrawerTitle>
|
||||||
<PresetsSidebar
|
<DrawerClose asChild>
|
||||||
{...props}
|
<Button variant="ghost">{t("task:done")}</Button>
|
||||||
onSelect={(selection) => {
|
</DrawerClose>
|
||||||
onSelect(selection);
|
</DrawerHeader>
|
||||||
if (selection.source !== "kind-switch") setOpen(false);
|
<PresetsSidebar
|
||||||
}}
|
{...props}
|
||||||
onSaveCurrent={() => {
|
onSelect={(selection) => {
|
||||||
saveAfterClose.current = true;
|
onSelect(selection);
|
||||||
setOpen(false);
|
if (selection.source !== "kind-switch") setOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
onSaveCurrent={() => {
|
||||||
</DrawerContent>
|
saveAfterClose.current = true;
|
||||||
</Drawer>
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</MobileConfirmationHostBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { SavedPreset } from "./saved-preset-model";
|
import type { SavedPreset } from "./saved-preset-model";
|
||||||
import { PresetsSidebar } from "./presets-sidebar";
|
import { PresetsSidebar } from "./presets-sidebar";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
const savedPreset: SavedPreset = {
|
const savedPreset: SavedPreset = {
|
||||||
id: "saved-pr",
|
id: "saved-pr",
|
||||||
@@ -15,7 +18,8 @@ const savedPreset: SavedPreset = {
|
|||||||
isDefault: false,
|
isDefault: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function expectDeleteDoesNotSelect() {
|
async function expectDeleteDoesNotSelect(phone = false) {
|
||||||
|
if (phone) Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const onDeleteSaved = vi.fn();
|
const onDeleteSaved = vi.fn();
|
||||||
render(
|
render(
|
||||||
@@ -33,7 +37,14 @@ async function expectDeleteDoesNotSelect() {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
|
const trigger = screen.getByRole("button", { name: "Delete Kandev PRs saved query" });
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
if (phone) {
|
||||||
|
expect(trigger.isConnected).toBe(true);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: "Delete Kandev PRs?" }).getAttribute("data-slot"),
|
||||||
|
).toBe("drawer-content");
|
||||||
|
}
|
||||||
|
|
||||||
expect(onDeleteSaved).not.toHaveBeenCalled();
|
expect(onDeleteSaved).not.toHaveBeenCalled();
|
||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
@@ -121,5 +132,7 @@ describe("PresetsSidebar saved defaults", () => {
|
|||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes a saved query without selecting it", expectDeleteDoesNotSelect);
|
it("deletes a saved query without selecting it", () => expectDeleteDoesNotSelect());
|
||||||
|
it("keeps a phone saved query row mounted during confirmation", () =>
|
||||||
|
expectDeleteDoesNotSelect(true));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar";
|
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar";
|
||||||
import type { SavedPreset } from "./saved-preset-model";
|
import type { SavedPreset } from "./saved-preset-model";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { SavedQueryDefaultButton } from "@/components/integrations/saved-query-default-button";
|
import { SavedQueryDefaultButton } from "@/components/integrations/saved-query-default-button";
|
||||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||||
import {
|
import {
|
||||||
@@ -165,6 +166,7 @@ function SavedSection({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
|
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
const defaultMutationPending = defaultMutationPendingId !== null;
|
const defaultMutationPending = defaultMutationPendingId !== null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -188,6 +190,19 @@ function SavedSection({
|
|||||||
onToggleDefault={() => void onToggleSavedDefault(preset)}
|
onToggleDefault={() => void onToggleSavedDefault(preset)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{isMobile && deletion.target && (
|
||||||
|
<SavedTaskViewDeleteConfirmation
|
||||||
|
target={deletion.target}
|
||||||
|
presentation="inline"
|
||||||
|
open
|
||||||
|
anchorRef={deletion.anchorRef}
|
||||||
|
confirmDisabled={defaultMutationPending}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) deletion.close();
|
||||||
|
}}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,7 +235,8 @@ function GitHubSavedPresetEntry({
|
|||||||
onToggleDefault: () => void;
|
onToggleDefault: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (deletion.target?.id === preset.id) {
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
if (!isMobile && deletion.target?.id === preset.id) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-1 min-w-0 p-1">
|
<div className="mx-1 min-w-0 p-1">
|
||||||
<SavedTaskViewDeleteConfirmation
|
<SavedTaskViewDeleteConfirmation
|
||||||
|
|||||||
@@ -16,39 +16,52 @@ const SAVED: SavedPreset = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("GitLab PresetsSidebar", () => {
|
describe("GitLab PresetsSidebar", () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
it("uses visible touch controls and deletes without selecting only after confirmation", async () => {
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"uses visible controls and confirms without selecting (phone: %s)",
|
||||||
|
async (phone) => {
|
||||||
|
if (phone) Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
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);
|
||||||
|
if (phone) {
|
||||||
|
expect(deleteButton.isConnected).toBe(true);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: "Delete Ready MRs?" }).getAttribute("data-slot"),
|
||||||
|
).toBe("drawer-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { MR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./presets";
|
import { MR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./presets";
|
||||||
import type { SavedPreset } from "./use-saved-presets";
|
import type { SavedPreset } from "./use-saved-presets";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||||
import { useSavedTaskViewDeleteConfirmation } from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
import { useSavedTaskViewDeleteConfirmation } from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ function SavedSection({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
|
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader id="saved" title={t("gitlab:saved")} />
|
<SectionHeader id="saved" title={t("gitlab:saved")} />
|
||||||
@@ -183,7 +185,7 @@ function SavedSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{saved.map((s) => {
|
{saved.map((s) => {
|
||||||
if (deletion.target?.id === s.id) {
|
if (!isMobile && deletion.target?.id === s.id) {
|
||||||
return (
|
return (
|
||||||
<div key={s.id} className="mx-1 min-w-0 p-1">
|
<div key={s.id} className="mx-1 min-w-0 p-1">
|
||||||
<SavedTaskViewDeleteConfirmation
|
<SavedTaskViewDeleteConfirmation
|
||||||
@@ -228,26 +230,45 @@ function SavedSection({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
{isMobile && deletion.target && (
|
||||||
type="button"
|
<SavedTaskViewDeleteConfirmation
|
||||||
onClick={onSaveCurrent}
|
target={deletion.target}
|
||||||
disabled={!canSaveCurrent}
|
presentation="inline"
|
||||||
className={cn(
|
open
|
||||||
"mx-1 mt-1 flex min-h-11 items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
|
anchorRef={deletion.anchorRef}
|
||||||
canSaveCurrent
|
onOpenChange={(open) => {
|
||||||
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
|
if (!open) deletion.close();
|
||||||
: "text-muted-foreground/50 cursor-not-allowed",
|
}}
|
||||||
)}
|
onConfirm={onDelete}
|
||||||
title={canSaveCurrent ? t("gitlab:saveCurrentQuery") : t("gitlab:typeACustomQueryFirst")}
|
/>
|
||||||
data-testid="gitlab-save-current-query"
|
)}
|
||||||
>
|
<SaveQueryButton enabled={canSaveCurrent} onClick={onSaveCurrent} />
|
||||||
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
|
|
||||||
<span>{t("gitlab:saveCurrentQuery")}</span>
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SaveQueryButton({ enabled, onClick }: { enabled: boolean; onClick: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={!enabled}
|
||||||
|
className={cn(
|
||||||
|
"mx-1 mt-1 flex min-h-11 items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||||
|
enabled
|
||||||
|
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
|
||||||
|
: "text-muted-foreground/50 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
title={enabled ? t("gitlab:saveCurrentQuery") : t("gitlab:typeACustomQueryFirst")}
|
||||||
|
data-testid="gitlab-save-current-query"
|
||||||
|
>
|
||||||
|
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{t("gitlab:saveCurrentQuery")}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PresetsSidebar({
|
export function PresetsSidebar({
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
|
||||||
|
import {
|
||||||
|
MobileConfirmationHost,
|
||||||
|
MobileConfirmationHostBody,
|
||||||
|
} from "@/components/confirmation/mobile-confirmation-host";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
|
||||||
|
/** Provider state stays with the caller; this shell owns only responsive presentation. */
|
||||||
|
export function IntegrationFiltersSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
testId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
testId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
return (
|
||||||
|
<MobileConfirmationHost open={open} surface={isMobile ? "drawer" : "dialog"}>
|
||||||
|
{({ active, contentProps }) =>
|
||||||
|
isMobile ? (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DrawerContent
|
||||||
|
className="overflow-hidden data-[vaul-drawer-direction=bottom]:max-h-[80dvh]"
|
||||||
|
data-testid={testId}
|
||||||
|
aria-describedby={undefined}
|
||||||
|
{...contentProps}
|
||||||
|
>
|
||||||
|
<MobileConfirmationHostBody>
|
||||||
|
<DrawerHeader className="shrink-0 text-left pb-2">
|
||||||
|
<DrawerTitle>{title}</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div
|
||||||
|
data-testid="integration-filters-scroll"
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(1rem+env(safe-area-inset-bottom))]"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</MobileConfirmationHostBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
) : (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="w-full sm:max-w-sm overflow-hidden p-0"
|
||||||
|
data-testid={testId}
|
||||||
|
showCloseButton={!active}
|
||||||
|
aria-describedby={undefined}
|
||||||
|
{...contentProps}
|
||||||
|
>
|
||||||
|
<MobileConfirmationHostBody>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<SheetHeader className="px-4 pt-4 pb-2">
|
||||||
|
<SheetTitle>{title}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</MobileConfirmationHostBody>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</MobileConfirmationHost>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type RefObject, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, type RefObject, type ReactNode } from "react";
|
||||||
import { IconBookmark, IconChevronDown, IconDeviceFloppy, IconX } from "@tabler/icons-react";
|
import { IconBookmark, IconChevronDown, IconDeviceFloppy, IconX } from "@tabler/icons-react";
|
||||||
import type { Icon } from "@tabler/icons-react";
|
import type { Icon } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,7 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
|||||||
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
|
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||||
import {
|
import {
|
||||||
useSavedTaskViewDeleteConfirmation,
|
useSavedTaskViewDeleteMenu,
|
||||||
type SavedTaskViewDeleteTarget,
|
type SavedTaskViewDeleteTarget,
|
||||||
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||||
|
|
||||||
@@ -124,15 +124,18 @@ function SavedMenuTrigger({
|
|||||||
testId,
|
testId,
|
||||||
active,
|
active,
|
||||||
label,
|
label,
|
||||||
|
triggerRef,
|
||||||
}: {
|
}: {
|
||||||
testId: string;
|
testId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
|
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
className={cn(PILL_BASE, active ? PILL_ACTIVE : PILL_IDLE)}
|
className={cn(PILL_BASE, active ? PILL_ACTIVE : PILL_IDLE)}
|
||||||
@@ -167,6 +170,10 @@ function useSavedMenuSave(onSaveCurrent: () => void) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preserveConfirmationInteraction(event: Event) {
|
||||||
|
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function SavedMenu<K extends string>({
|
function SavedMenu<K extends string>({
|
||||||
testId,
|
testId,
|
||||||
selected,
|
selected,
|
||||||
@@ -191,34 +198,32 @@ function SavedMenu<K extends string>({
|
|||||||
savedStatus?: ReactNode;
|
savedStatus?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const deletion = useSavedTaskViewDeleteMenu(saved, isMobile);
|
||||||
const save = useSavedMenuSave(onSaveCurrent);
|
const save = useSavedMenuSave(onSaveCurrent);
|
||||||
const deletion = useSavedTaskViewDeleteConfirmation(saved);
|
|
||||||
const menuContentRef = useRef<HTMLDivElement>(null);
|
const menuContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const defaultMutationPending = defaultMutationPendingId !== null;
|
const defaultMutationPending = defaultMutationPendingId !== null;
|
||||||
const activeSaved = selected.source === "saved";
|
const activeSaved = selected.source === "saved";
|
||||||
const activeLabel = activeSaved ? (saved.find((s) => s.id === selected.id)?.label ?? null) : null;
|
const activeLabel = activeSaved ? (saved.find((s) => s.id === selected.id)?.label ?? null) : null;
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu open={deletion.open} onOpenChange={deletion.onOpenChange}>
|
||||||
open={menuOpen}
|
<SavedMenuTrigger
|
||||||
onOpenChange={(open) => {
|
testId={testId}
|
||||||
setMenuOpen(open);
|
active={activeSaved}
|
||||||
if (!open) deletion.close();
|
label={activeLabel}
|
||||||
}}
|
triggerRef={triggerRef}
|
||||||
>
|
/>
|
||||||
<SavedMenuTrigger testId={testId} active={activeSaved} label={activeLabel} />
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
ref={menuContentRef}
|
ref={menuContentRef}
|
||||||
align="end"
|
align="end"
|
||||||
className="w-56"
|
className="w-56"
|
||||||
onCloseAutoFocus={save.onCloseAutoFocus}
|
onCloseAutoFocus={(event) => {
|
||||||
onFocusOutside={(event) => {
|
deletion.onCloseAutoFocus(event);
|
||||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
save.onCloseAutoFocus();
|
||||||
}}
|
|
||||||
onInteractOutside={(event) => {
|
|
||||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
|
||||||
}}
|
}}
|
||||||
|
onFocusOutside={preserveConfirmationInteraction}
|
||||||
|
onInteractOutside={preserveConfirmationInteraction}
|
||||||
>
|
>
|
||||||
{savedStatus}
|
{savedStatus}
|
||||||
{saved.length === 0
|
{saved.length === 0
|
||||||
@@ -229,7 +234,7 @@ function SavedMenu<K extends string>({
|
|||||||
<SavedMenuEntry
|
<SavedMenuEntry
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
preset={preset}
|
preset={preset}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={!isMobile && isFinePointer}
|
||||||
deletion={deletion}
|
deletion={deletion}
|
||||||
defaultMutationPending={defaultMutationPending}
|
defaultMutationPending={defaultMutationPending}
|
||||||
defaultMutationPendingForPreset={defaultMutationPendingId === preset.id}
|
defaultMutationPendingForPreset={defaultMutationPendingId === preset.id}
|
||||||
@@ -250,12 +255,12 @@ function SavedMenu<K extends string>({
|
|||||||
<span>{t("integrations:saveCurrentQuery")}</span>
|
<span>{t("integrations:saveCurrentQuery")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
{isFinePointer && deletion.target ? (
|
{(isMobile || isFinePointer) && deletion.target ? (
|
||||||
<SavedTaskViewDeleteConfirmation
|
<SavedTaskViewDeleteConfirmation
|
||||||
target={deletion.target}
|
target={deletion.target}
|
||||||
presentation="popover"
|
presentation="popover"
|
||||||
open
|
open
|
||||||
anchorRef={deletion.anchorRef}
|
anchorRef={isMobile ? triggerRef : deletion.anchorRef}
|
||||||
focusBoundaryRef={menuContentRef}
|
focusBoundaryRef={menuContentRef}
|
||||||
confirmDisabled={defaultMutationPending}
|
confirmDisabled={defaultMutationPending}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||||
|
import { IconInbox } from "@tabler/icons-react";
|
||||||
|
import { IntegrationScopeBar } from "./presets-scope-bar-base";
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
|
useResponsiveBreakpoint: () => ({ isMobile: true, isFinePointer: false }),
|
||||||
|
}));
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("hands the real phone saved-view menu off without selecting or changing its default", async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
const onDefault = vi.fn();
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<IntegrationScopeBar
|
||||||
|
testId="scope"
|
||||||
|
savedMenuTestId="saved-menu"
|
||||||
|
kinds={[{ value: "issue", label: "Issues" }]}
|
||||||
|
selected={{ kind: "issue", source: "preset", id: "inbox" }}
|
||||||
|
onSelect={onSelect}
|
||||||
|
presetsByKind={() => [{ value: "inbox", label: "Inbox", icon: IconInbox, group: "inbox" }]}
|
||||||
|
savedPresets={[{ id: "saved", kind: "issue", label: "My work" }]}
|
||||||
|
onDeleteSaved={onDelete}
|
||||||
|
onToggleSavedDefault={onDefault}
|
||||||
|
defaultMutationPendingId={null}
|
||||||
|
canSaveCurrent={false}
|
||||||
|
onSaveCurrent={vi.fn()}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByTestId("saved-menu");
|
||||||
|
fireEvent.pointerDown(trigger);
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: "Delete My work saved query" }));
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Delete My work?" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(screen.queryByRole("menu")).toBeNull();
|
||||||
|
expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeNull();
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
expect(onDefault).not.toHaveBeenCalled();
|
||||||
|
expect(onDelete).not.toHaveBeenCalled();
|
||||||
|
fireEvent.pointerDown(trigger);
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: "Delete My work saved query" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Delete My work" }));
|
||||||
|
await waitFor(() => expect(onDelete).toHaveBeenCalledExactlyOnceWith("saved"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { JiraActionBar } from "./jira-action-bar";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
it("keeps Jira removal in a phone sheet without testing or removing on Cancel", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
const onTest = vi.fn();
|
||||||
|
render(
|
||||||
|
<JiraActionBar
|
||||||
|
workspaceId="workspace-a"
|
||||||
|
testing={false}
|
||||||
|
loading={false}
|
||||||
|
hasConfig
|
||||||
|
disableTest={false}
|
||||||
|
onTest={onTest}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByTestId("jira-delete-button");
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const sheet = screen.getByRole("dialog");
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(trigger.isConnected).toBe(true);
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(onDelete).not.toHaveBeenCalled();
|
||||||
|
expect(onTest).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(screen.getByTestId("jira-remove-confirm"));
|
||||||
|
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
@@ -6,9 +6,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
|
||||||
type JiraActionBarProps = {
|
type JiraActionBarProps = {
|
||||||
|
workspaceId: string;
|
||||||
testing: boolean;
|
testing: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
hasConfig: boolean;
|
hasConfig: boolean;
|
||||||
@@ -18,6 +20,7 @@ type JiraActionBarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function JiraActionBar({
|
export function JiraActionBar({
|
||||||
|
workspaceId,
|
||||||
testing,
|
testing,
|
||||||
loading,
|
loading,
|
||||||
hasConfig,
|
hasConfig,
|
||||||
@@ -26,10 +29,11 @@ export function JiraActionBar({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: JiraActionBarProps) {
|
}: JiraActionBarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const removeConfirmation = t("jira:removeJiraConfiguration");
|
const removeConfirmation = t("jira:removeJiraConfiguration");
|
||||||
|
const removeLabel = t("jira:removeConfiguration");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasConfig && confirmingDelete) setConfirmingDelete(false);
|
if (!hasConfig && confirmingDelete) setConfirmingDelete(false);
|
||||||
@@ -48,7 +52,7 @@ export function JiraActionBar({
|
|||||||
>
|
>
|
||||||
{testing ? t("jira:testingConnection") : t("jira:testConnection")}
|
{testing ? t("jira:testingConnection") : t("jira:testConnection")}
|
||||||
</Button>
|
</Button>
|
||||||
{hasConfig && (isFinePointer || !confirmingDelete) && (
|
{hasConfig && (isMobile || isFinePointer || !confirmingDelete) && (
|
||||||
<Button
|
<Button
|
||||||
ref={deleteAnchorRef}
|
ref={deleteAnchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -57,39 +61,52 @@ export function JiraActionBar({
|
|||||||
className="ml-auto cursor-pointer"
|
className="ml-auto cursor-pointer"
|
||||||
data-testid="jira-delete-button"
|
data-testid="jira-delete-button"
|
||||||
>
|
>
|
||||||
{t("jira:removeConfiguration")}
|
{removeLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasConfig && !isFinePointer && confirmingDelete ? (
|
<MobileActionConfirmation
|
||||||
<InlineConfirmActions
|
open={hasConfig && confirmingDelete}
|
||||||
density="touch"
|
targetKey={workspaceId}
|
||||||
testId="jira-remove-inline-confirmation"
|
title={removeConfirmation}
|
||||||
ariaLabel={removeConfirmation}
|
cancelLabel={t("common:cancel")}
|
||||||
description={removeConfirmation}
|
confirmLabel={removeLabel}
|
||||||
cancelLabel={t("common:cancel")}
|
confirmAriaLabel={removeConfirmation}
|
||||||
confirmLabel={t("jira:removeConfiguration")}
|
confirmTestId="jira-remove-confirm"
|
||||||
confirmAriaLabel={removeConfirmation}
|
onOpenChange={setConfirmingDelete}
|
||||||
confirmTestId="jira-remove-confirm"
|
focusReturnRef={deleteAnchorRef}
|
||||||
onCancel={() => setConfirmingDelete(false)}
|
onConfirm={onDelete}
|
||||||
onClose={() => setConfirmingDelete(false)}
|
fallback={
|
||||||
onConfirm={onDelete}
|
!isFinePointer ? (
|
||||||
/>
|
<InlineConfirmActions
|
||||||
) : null}
|
density="touch"
|
||||||
{hasConfig && isFinePointer ? (
|
testId="jira-remove-inline-confirmation"
|
||||||
<ActionConfirmPopover
|
ariaLabel={removeConfirmation}
|
||||||
open={confirmingDelete}
|
description={removeConfirmation}
|
||||||
anchorRef={deleteAnchorRef}
|
cancelLabel={t("common:cancel")}
|
||||||
title={removeConfirmation}
|
confirmLabel={removeLabel}
|
||||||
cancelLabel={t("common:cancel")}
|
confirmAriaLabel={removeConfirmation}
|
||||||
confirmLabel={t("jira:removeConfiguration")}
|
confirmTestId="jira-remove-confirm"
|
||||||
confirmAriaLabel={removeConfirmation}
|
onCancel={() => setConfirmingDelete(false)}
|
||||||
confirmTestId="jira-remove-confirm"
|
onClose={() => setConfirmingDelete(false)}
|
||||||
testId="jira-remove-confirm-popover"
|
onConfirm={onDelete}
|
||||||
onOpenChange={setConfirmingDelete}
|
/>
|
||||||
onCancel={() => setConfirmingDelete(false)}
|
) : (
|
||||||
onConfirm={onDelete}
|
<ActionConfirmPopover
|
||||||
/>
|
open={confirmingDelete}
|
||||||
) : null}
|
anchorRef={deleteAnchorRef}
|
||||||
|
title={removeConfirmation}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
confirmLabel={removeLabel}
|
||||||
|
confirmAriaLabel={removeConfirmation}
|
||||||
|
confirmTestId="jira-remove-confirm"
|
||||||
|
testId="jira-remove-confirm-popover"
|
||||||
|
onOpenChange={setConfirmingDelete}
|
||||||
|
onCancel={() => setConfirmingDelete(false)}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ function WatchActions({
|
|||||||
<TooltipContent>{t("common:reset")}</TooltipContent>
|
<TooltipContent>{t("common:reset")}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<WatcherDeleteAction
|
<WatcherDeleteAction
|
||||||
|
targetKey={`${watch.workspaceId}:${watch.id}`}
|
||||||
|
subject={watch.jql}
|
||||||
title={t("jira:deleteThisJiraWatcher")}
|
title={t("jira:deleteThisJiraWatcher")}
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
confirmLabel={t("jira:delete")}
|
confirmLabel={t("jira:delete")}
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string })
|
|||||||
<TestResultAlert result={s.testResult} />
|
<TestResultAlert result={s.testResult} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<JiraActionBar
|
<JiraActionBar
|
||||||
|
workspaceId={workspaceId}
|
||||||
testing={s.testing}
|
testing={s.testing}
|
||||||
loading={s.loading}
|
loading={s.loading}
|
||||||
hasConfig={!!s.config}
|
hasConfig={!!s.config}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { DEFAULT_FILTERS } from "./filter-model";
|
|||||||
import type { SavedView } from "./use-saved-views";
|
import type { SavedView } from "./use-saved-views";
|
||||||
import { ListToolbar } from "./list-toolbar";
|
import { ListToolbar } from "./list-toolbar";
|
||||||
|
|
||||||
const responsive = vi.hoisted(() => ({ isFinePointer: false }));
|
const DELETE_VIEW_TITLE = "Delete view";
|
||||||
|
|
||||||
|
const responsive = vi.hoisted(() => ({ isFinePointer: false, isMobile: false }));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
useResponsiveBreakpoint: () => responsive,
|
useResponsiveBreakpoint: () => responsive,
|
||||||
@@ -49,14 +51,35 @@ function renderToolbar(onDeleteView = vi.fn()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Jira ListToolbar saved views", () => {
|
describe("Jira ListToolbar saved views", () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
responsive.isMobile = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands a phone saved view to a named sheet outside the closed picker", async () => {
|
||||||
|
responsive.isMobile = true;
|
||||||
|
const { onDeleteView } = renderToolbar();
|
||||||
|
const trigger = screen.getByRole("button", { name: "Sprint bugs" });
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE));
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Delete Sprint bugs?" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull();
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(onDeleteView).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Delete Sprint bugs" }));
|
||||||
|
await waitFor(() => expect(onDeleteView).toHaveBeenCalledExactlyOnceWith(CUSTOM.id));
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps built-ins protected and confirms custom deletion inline on coarse pointers", async () => {
|
it("keeps built-ins protected and confirms custom deletion inline on coarse pointers", async () => {
|
||||||
const { onDeleteView } = renderToolbar();
|
const { onDeleteView } = renderToolbar();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Sprint bugs" }));
|
fireEvent.click(screen.getByRole("button", { name: "Sprint bugs" }));
|
||||||
|
|
||||||
expect(screen.getAllByTitle("Delete view")).toHaveLength(1);
|
expect(screen.getAllByTitle(DELETE_VIEW_TITLE)).toHaveLength(1);
|
||||||
fireEvent.click(screen.getByTitle("Delete view"));
|
fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE));
|
||||||
|
|
||||||
expect(onDeleteView).not.toHaveBeenCalled();
|
expect(onDeleteView).not.toHaveBeenCalled();
|
||||||
const confirmation = screen.getByRole("group", { name: "Delete Sprint bugs?" });
|
const confirmation = screen.getByRole("group", { name: "Delete Sprint bugs?" });
|
||||||
@@ -66,7 +89,7 @@ describe("Jira ListToolbar saved views", () => {
|
|||||||
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
|
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
|
||||||
expect(onDeleteView).not.toHaveBeenCalled();
|
expect(onDeleteView).not.toHaveBeenCalled();
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle("Delete view"));
|
fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Delete Sprint bugs" }));
|
fireEvent.click(screen.getByRole("button", { name: "Delete Sprint bugs" }));
|
||||||
|
|
||||||
await waitFor(() => expect(onDeleteView).toHaveBeenCalledWith(CUSTOM.id));
|
await waitFor(() => expect(onDeleteView).toHaveBeenCalledWith(CUSTOM.id));
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
|||||||
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
|
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
|
||||||
import {
|
import {
|
||||||
useSavedTaskViewDeleteConfirmation,
|
useSavedTaskViewDeleteMenu,
|
||||||
type SavedTaskViewDeleteTarget,
|
type SavedTaskViewDeleteTarget,
|
||||||
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||||
|
|
||||||
@@ -179,15 +179,15 @@ function ViewsDropdown({
|
|||||||
activeName: string | undefined;
|
activeName: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const builtin = views.filter((v) => v.builtin);
|
const builtin = views.filter((v) => v.builtin);
|
||||||
const custom = views.filter((v) => !v.builtin);
|
const custom = views.filter((v) => !v.builtin);
|
||||||
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(custom);
|
const deletion = useSavedTaskViewDeleteMenu<HTMLButtonElement>(custom, isMobile);
|
||||||
|
|
||||||
const deleteProps: ViewDeletionProps = {
|
const deleteProps: ViewDeletionProps = {
|
||||||
isFinePointer,
|
isFinePointer: !isMobile && isFinePointer,
|
||||||
deleteTarget: deletion.target,
|
deleteTarget: deletion.target,
|
||||||
deleteAnchorRef: deletion.anchorRef,
|
deleteAnchorRef: deletion.anchorRef,
|
||||||
onDeleteOpenChange: (nextOpen) => {
|
onDeleteOpenChange: (nextOpen) => {
|
||||||
@@ -199,15 +199,9 @@ function ViewsDropdown({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover open={deletion.open} onOpenChange={deletion.onOpenChange}>
|
||||||
open={open}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
setOpen(nextOpen);
|
|
||||||
if (!nextOpen) deletion.close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="cursor-pointer text-xs gap-1.5">
|
<Button ref={triggerRef} variant="outline" className="cursor-pointer text-xs gap-1.5">
|
||||||
<IconBookmark className="h-3.5 w-3.5" />
|
<IconBookmark className="h-3.5 w-3.5" />
|
||||||
{activeName ?? t("jira:noView")}
|
{activeName ?? t("jira:noView")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -216,6 +210,7 @@ function ViewsDropdown({
|
|||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
align="start"
|
align="start"
|
||||||
className="w-60 max-w-[calc(100vw-1rem)] overflow-x-hidden p-0"
|
className="w-60 max-w-[calc(100vw-1rem)] overflow-x-hidden p-0"
|
||||||
|
onCloseAutoFocus={deletion.onCloseAutoFocus}
|
||||||
onFocusOutside={(event) => {
|
onFocusOutside={(event) => {
|
||||||
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
if (isActionConfirmationTarget(event.target)) event.preventDefault();
|
||||||
}}
|
}}
|
||||||
@@ -229,7 +224,7 @@ function ViewsDropdown({
|
|||||||
activeViewId={activeViewId}
|
activeViewId={activeViewId}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
onSelect(id);
|
onSelect(id);
|
||||||
setOpen(false);
|
deletion.onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
{...deleteProps}
|
{...deleteProps}
|
||||||
/>
|
/>
|
||||||
@@ -242,19 +237,19 @@ function ViewsDropdown({
|
|||||||
activeViewId={activeViewId}
|
activeViewId={activeViewId}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
onSelect(id);
|
onSelect(id);
|
||||||
setOpen(false);
|
deletion.onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
{...deleteProps}
|
{...deleteProps}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
{isFinePointer && deletion.target ? (
|
{(isMobile || isFinePointer) && deletion.target ? (
|
||||||
<SavedTaskViewDeleteConfirmation
|
<SavedTaskViewDeleteConfirmation
|
||||||
target={deletion.target}
|
target={deletion.target}
|
||||||
presentation="popover"
|
presentation="popover"
|
||||||
open
|
open
|
||||||
anchorRef={deletion.anchorRef}
|
anchorRef={isMobile ? triggerRef : deletion.anchorRef}
|
||||||
focusBoundaryRef={contentRef}
|
focusBoundaryRef={contentRef}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (!nextOpen) deletion.close();
|
if (!nextOpen) deletion.close();
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ function KanbanCardFrame({
|
|||||||
</KanbanCardContextMenu>
|
</KanbanCardContextMenu>
|
||||||
</div>
|
</div>
|
||||||
<TaskDetachConfirmationSurface
|
<TaskDetachConfirmationSurface
|
||||||
|
taskId={task.id}
|
||||||
open={menu.showDetachConfirm}
|
open={menu.showDetachConfirm}
|
||||||
anchorRef={menu.detachAnchorRef}
|
anchorRef={menu.detachAnchorRef}
|
||||||
focusReturnRef={menu.detachFocusReturnRef}
|
focusReturnRef={menu.detachFocusReturnRef}
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ function WatchActions({
|
|||||||
<TooltipContent>{t("common:reset")}</TooltipContent>
|
<TooltipContent>{t("common:reset")}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<WatcherDeleteAction
|
<WatcherDeleteAction
|
||||||
|
targetKey={`${watch.workspaceId}:${watch.id}`}
|
||||||
|
subject={summarizeFilter(watch.filter)}
|
||||||
title={t("linear:deleteThisLinearWatcher")}
|
title={t("linear:deleteThisLinearWatcher")}
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
confirmLabel={t("linear:delete")}
|
confirmLabel={t("linear:delete")}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { LinearConfig } from "@/lib/types/linear";
|
import type { LinearConfig } from "@/lib/types/linear";
|
||||||
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
|
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
|
||||||
|
|
||||||
|
const REMOVE_CONFIRM_TEST_ID = "linear-remove-confirm";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
deleteConfig: vi.fn(),
|
deleteConfig: vi.fn(),
|
||||||
getConfig: vi.fn(),
|
getConfig: vi.fn(),
|
||||||
@@ -13,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let finePointer = true;
|
let finePointer = true;
|
||||||
|
let isMobile = false;
|
||||||
const DELETE_BUTTON_TEST_ID = "linear-delete-button";
|
const DELETE_BUTTON_TEST_ID = "linear-delete-button";
|
||||||
const CONFIRM_POPOVER_TEST_ID = "linear-remove-confirm-popover";
|
const CONFIRM_POPOVER_TEST_ID = "linear-remove-confirm-popover";
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ vi.mock("@/hooks/domains/integrations/use-integration-availability", () => ({
|
|||||||
INTEGRATION_STATUS_REFRESH_MS: 100_000,
|
INTEGRATION_STATUS_REFRESH_MS: 100_000,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer }),
|
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer, isMobile }),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/components/linear/linear-enabled-control", () => ({
|
vi.mock("@/components/linear/linear-enabled-control", () => ({
|
||||||
LinearEnabledControl: () => null,
|
LinearEnabledControl: () => null,
|
||||||
@@ -60,6 +63,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
finePointer = true;
|
finePointer = true;
|
||||||
|
isMobile = false;
|
||||||
mocks.getConfig.mockResolvedValue(config);
|
mocks.getConfig.mockResolvedValue(config);
|
||||||
mocks.listTeams.mockResolvedValue({ teams: [] });
|
mocks.listTeams.mockResolvedValue({ teams: [] });
|
||||||
mocks.deleteConfig.mockResolvedValue(undefined);
|
mocks.deleteConfig.mockResolvedValue(undefined);
|
||||||
@@ -72,6 +76,24 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("LinearConnectionSection removal", () => {
|
describe("LinearConnectionSection removal", () => {
|
||||||
|
it("keeps the phone form controls behind a sheet and cancels locally", async () => {
|
||||||
|
isMobile = true;
|
||||||
|
finePointer = false;
|
||||||
|
renderSection();
|
||||||
|
const trigger = await screen.findByTestId(DELETE_BUTTON_TEST_ID);
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const sheet = screen.getByRole("dialog");
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(trigger.isConnected).toBe(true);
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(mocks.deleteConfig).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.deleteConfig).toHaveBeenCalledExactlyOnceWith({ workspaceId: "workspace-a" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
it("uses local fine-pointer confirmation and invokes deletion once", async () => {
|
it("uses local fine-pointer confirmation and invokes deletion once", async () => {
|
||||||
const nativeConfirm = vi.fn(() => false);
|
const nativeConfirm = vi.fn(() => false);
|
||||||
vi.stubGlobal("confirm", nativeConfirm);
|
vi.stubGlobal("confirm", nativeConfirm);
|
||||||
@@ -88,7 +110,7 @@ describe("LinearConnectionSection removal", () => {
|
|||||||
|
|
||||||
fireEvent.click(removeButton);
|
fireEvent.click(removeButton);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(screen.getByTestId(CONFIRM_POPOVER_TEST_ID)).getByTestId("linear-remove-confirm"),
|
within(screen.getByTestId(CONFIRM_POPOVER_TEST_ID)).getByTestId(REMOVE_CONFIRM_TEST_ID),
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(mocks.deleteConfig).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mocks.deleteConfig).toHaveBeenCalledTimes(1));
|
||||||
expect(mocks.deleteConfig).toHaveBeenCalledWith({ workspaceId: "workspace-a" });
|
expect(mocks.deleteConfig).toHaveBeenCalledWith({ workspaceId: "workspace-a" });
|
||||||
@@ -102,14 +124,14 @@ describe("LinearConnectionSection removal", () => {
|
|||||||
fireEvent.click(removeButton);
|
fireEvent.click(removeButton);
|
||||||
const inline = screen.getByTestId("linear-remove-inline-confirmation");
|
const inline = screen.getByTestId("linear-remove-inline-confirmation");
|
||||||
expect(screen.queryByTestId(CONFIRM_POPOVER_TEST_ID)).toBeNull();
|
expect(screen.queryByTestId(CONFIRM_POPOVER_TEST_ID)).toBeNull();
|
||||||
expect(within(inline).getByTestId("linear-remove-confirm").className).toContain("h-11");
|
expect(within(inline).getByTestId(REMOVE_CONFIRM_TEST_ID).className).toContain("h-11");
|
||||||
|
|
||||||
fireEvent.click(within(inline).getByRole("button", { name: "Cancel" }));
|
fireEvent.click(within(inline).getByRole("button", { name: "Cancel" }));
|
||||||
expect(mocks.deleteConfig).not.toHaveBeenCalled();
|
expect(mocks.deleteConfig).not.toHaveBeenCalled();
|
||||||
fireEvent.click(screen.getByTestId(DELETE_BUTTON_TEST_ID));
|
fireEvent.click(screen.getByTestId(DELETE_BUTTON_TEST_ID));
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(screen.getByTestId("linear-remove-inline-confirmation")).getByTestId(
|
within(screen.getByTestId("linear-remove-inline-confirmation")).getByTestId(
|
||||||
"linear-remove-confirm",
|
REMOVE_CONFIRM_TEST_ID,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(mocks.deleteConfig).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mocks.deleteConfig).toHaveBeenCalledTimes(1));
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { useToast } from "@/components/toast-provider";
|
import { useToast } from "@/components/toast-provider";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { SettingsSection } from "@/components/settings/settings-section";
|
import { SettingsSection } from "@/components/settings/settings-section";
|
||||||
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
|
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
|
||||||
import { SettingsCard } from "@/components/settings/settings-card";
|
import { SettingsCard } from "@/components/settings/settings-card";
|
||||||
@@ -191,6 +192,7 @@ function configToHealth(config: LinearConfig | null): IntegrationAuthHealth | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ActionBarProps = {
|
type ActionBarProps = {
|
||||||
|
workspaceId: string;
|
||||||
testing: boolean;
|
testing: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
hasConfig: boolean;
|
hasConfig: boolean;
|
||||||
@@ -199,12 +201,21 @@ type ActionBarProps = {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete }: ActionBarProps) {
|
function ActionBar({
|
||||||
|
workspaceId,
|
||||||
|
testing,
|
||||||
|
loading,
|
||||||
|
hasConfig,
|
||||||
|
disableTest,
|
||||||
|
onTest,
|
||||||
|
onDelete,
|
||||||
|
}: ActionBarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const removeConfirmation = t("linear:removeLinearConfigurationConfirm");
|
const removeConfirmation = t("linear:removeLinearConfigurationConfirm");
|
||||||
|
const removeLabel = t("linear:removeConfiguration");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasConfig && confirmingDelete) setConfirmingDelete(false);
|
if (!hasConfig && confirmingDelete) setConfirmingDelete(false);
|
||||||
@@ -223,7 +234,7 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
|
|||||||
>
|
>
|
||||||
{testing ? t("linear:testing") : t("linear:testConnection")}
|
{testing ? t("linear:testing") : t("linear:testConnection")}
|
||||||
</Button>
|
</Button>
|
||||||
{hasConfig && (isFinePointer || !confirmingDelete) && (
|
{hasConfig && (isMobile || isFinePointer || !confirmingDelete) && (
|
||||||
<Button
|
<Button
|
||||||
ref={deleteAnchorRef}
|
ref={deleteAnchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -232,39 +243,52 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
|
|||||||
className="ml-auto cursor-pointer"
|
className="ml-auto cursor-pointer"
|
||||||
data-testid="linear-delete-button"
|
data-testid="linear-delete-button"
|
||||||
>
|
>
|
||||||
{t("linear:removeConfiguration")}
|
{removeLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasConfig && !isFinePointer && confirmingDelete ? (
|
<MobileActionConfirmation
|
||||||
<InlineConfirmActions
|
open={hasConfig && confirmingDelete}
|
||||||
density="touch"
|
targetKey={workspaceId}
|
||||||
testId="linear-remove-inline-confirmation"
|
title={removeConfirmation}
|
||||||
ariaLabel={removeConfirmation}
|
cancelLabel={t("common:cancel")}
|
||||||
description={removeConfirmation}
|
confirmLabel={removeLabel}
|
||||||
cancelLabel={t("common:cancel")}
|
confirmAriaLabel={removeConfirmation}
|
||||||
confirmLabel={t("linear:removeConfiguration")}
|
confirmTestId="linear-remove-confirm"
|
||||||
confirmAriaLabel={removeConfirmation}
|
onOpenChange={setConfirmingDelete}
|
||||||
confirmTestId="linear-remove-confirm"
|
focusReturnRef={deleteAnchorRef}
|
||||||
onCancel={() => setConfirmingDelete(false)}
|
onConfirm={onDelete}
|
||||||
onClose={() => setConfirmingDelete(false)}
|
fallback={
|
||||||
onConfirm={onDelete}
|
!isFinePointer ? (
|
||||||
/>
|
<InlineConfirmActions
|
||||||
) : null}
|
density="touch"
|
||||||
{hasConfig && isFinePointer ? (
|
testId="linear-remove-inline-confirmation"
|
||||||
<ActionConfirmPopover
|
ariaLabel={removeConfirmation}
|
||||||
open={confirmingDelete}
|
description={removeConfirmation}
|
||||||
anchorRef={deleteAnchorRef}
|
cancelLabel={t("common:cancel")}
|
||||||
title={removeConfirmation}
|
confirmLabel={removeLabel}
|
||||||
cancelLabel={t("common:cancel")}
|
confirmAriaLabel={removeConfirmation}
|
||||||
confirmLabel={t("linear:removeConfiguration")}
|
confirmTestId="linear-remove-confirm"
|
||||||
confirmAriaLabel={removeConfirmation}
|
onCancel={() => setConfirmingDelete(false)}
|
||||||
confirmTestId="linear-remove-confirm"
|
onClose={() => setConfirmingDelete(false)}
|
||||||
testId="linear-remove-confirm-popover"
|
onConfirm={onDelete}
|
||||||
onOpenChange={setConfirmingDelete}
|
/>
|
||||||
onCancel={() => setConfirmingDelete(false)}
|
) : (
|
||||||
onConfirm={onDelete}
|
<ActionConfirmPopover
|
||||||
/>
|
open={confirmingDelete}
|
||||||
) : null}
|
anchorRef={deleteAnchorRef}
|
||||||
|
title={removeConfirmation}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
confirmLabel={removeLabel}
|
||||||
|
confirmAriaLabel={removeConfirmation}
|
||||||
|
confirmTestId="linear-remove-confirm"
|
||||||
|
testId="linear-remove-confirm-popover"
|
||||||
|
onOpenChange={setConfirmingDelete}
|
||||||
|
onCancel={() => setConfirmingDelete(false)}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -514,6 +538,7 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
|
|||||||
<TestResultAlert result={s.testResult} />
|
<TestResultAlert result={s.testResult} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<ActionBar
|
<ActionBar
|
||||||
|
workspaceId={workspaceId}
|
||||||
testing={s.testing}
|
testing={s.testing}
|
||||||
loading={s.loading}
|
loading={s.loading}
|
||||||
hasConfig={!!s.config}
|
hasConfig={!!s.config}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, within, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard";
|
import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard";
|
||||||
|
import { ResetContextButton } from "@/components/task/chat/reset-context-button";
|
||||||
|
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||||
|
|
||||||
const setQuickChatInitialPrompt = vi.fn();
|
const setQuickChatInitialPrompt = vi.fn();
|
||||||
const quickChatSessionItems: Record<string, { state: string; task_id: string }> = {};
|
const quickChatSessionItems: Record<string, { state: string; task_id: string }> = {};
|
||||||
@@ -18,7 +20,7 @@ const WORKSPACE_ID = "ws-1";
|
|||||||
const TERMINAL_TAB_ID = "terminal-1";
|
const TERMINAL_TAB_ID = "terminal-1";
|
||||||
const FIRE_ESCAPE_TESTID = "fire-escape";
|
const FIRE_ESCAPE_TESTID = "fire-escape";
|
||||||
const TOGGLE_ESCAPE_GUARD_TESTID = "toggle-escape-guard";
|
const TOGGLE_ESCAPE_GUARD_TESTID = "toggle-escape-guard";
|
||||||
const responsiveMock = vi.hoisted(() => ({ isFinePointer: true }));
|
const responsiveMock = vi.hoisted(() => ({ isFinePointer: true, isMobile: false }));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
useResponsiveBreakpoint: () => responsiveMock,
|
useResponsiveBreakpoint: () => responsiveMock,
|
||||||
@@ -34,6 +36,7 @@ vi.mock("@/components/state-provider", () => ({
|
|||||||
) =>
|
) =>
|
||||||
selector({
|
selector({
|
||||||
setQuickChatInitialPrompt,
|
setQuickChatInitialPrompt,
|
||||||
|
clearContextWindow: vi.fn(),
|
||||||
taskSessions: { items: quickChatSessionItems },
|
taskSessions: { items: quickChatSessionItems },
|
||||||
prepareProgress: { bySessionId: quickChatPrepareProgress },
|
prepareProgress: { bySessionId: quickChatPrepareProgress },
|
||||||
}),
|
}),
|
||||||
@@ -126,6 +129,9 @@ function EscapeGuardProbe() {
|
|||||||
useClarificationEscapeGuard(predicate);
|
useClarificationEscapeGuard(predicate);
|
||||||
return (
|
return (
|
||||||
<div data-testid="quick-chat-session-view">
|
<div data-testid="quick-chat-session-view">
|
||||||
|
<TooltipProvider>
|
||||||
|
<ResetContextButton sessionId="chat-1" presentation="mobile" />
|
||||||
|
</TooltipProvider>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={TOGGLE_ESCAPE_GUARD_TESTID}
|
data-testid={TOGGLE_ESCAPE_GUARD_TESTID}
|
||||||
@@ -148,6 +154,24 @@ vi.mock("./use-quick-chat-modal", () => ({
|
|||||||
useQuickChatModal: () => useQuickChatModalMock(),
|
useQuickChatModal: () => useQuickChatModalMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it("hosts phone context reset inside Quick Chat without another modal", async () => {
|
||||||
|
responsiveMock.isMobile = true;
|
||||||
|
useQuickChatModalMock.mockReturnValue({
|
||||||
|
...defaultQuickChatModalState,
|
||||||
|
activeKind: "conversation",
|
||||||
|
activeSessionNeedsAgent: false,
|
||||||
|
});
|
||||||
|
render(<QuickChatModal workspaceId={WORKSPACE_ID} />);
|
||||||
|
const trigger = screen.getByTestId("reset-context-button");
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const confirmation = await screen.findByTestId("mobile-action-confirmation");
|
||||||
|
expect(document.querySelector('[data-slot="drawer-content"]')).toBeNull();
|
||||||
|
expect(trigger.closest('[aria-hidden="true"][inert]')).toBeTruthy();
|
||||||
|
fireEvent.click(within(confirmation).getByRole("button", { name: "Back" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
responsiveMock.isMobile = false;
|
||||||
|
});
|
||||||
|
|
||||||
const defaultQuickChatModalState = {
|
const defaultQuickChatModalState = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
sessions: [{ sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat", name: CHAT_NAME }],
|
sessions: [{ sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat", name: CHAT_NAME }],
|
||||||
@@ -201,6 +225,7 @@ const defaultQuickChatModalState = {
|
|||||||
import { QuickChatModal } from "./quick-chat-modal";
|
import { QuickChatModal } from "./quick-chat-modal";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
responsiveMock.isMobile = false;
|
||||||
cleanup();
|
cleanup();
|
||||||
for (const key of Object.keys(quickChatSessionItems)) delete quickChatSessionItems[key];
|
for (const key of Object.keys(quickChatSessionItems)) delete quickChatSessionItems[key];
|
||||||
for (const key of Object.keys(quickChatPrepareProgress)) delete quickChatPrepareProgress[key];
|
for (const key of Object.keys(quickChatPrepareProgress)) delete quickChatPrepareProgress[key];
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
} from "@/hooks/use-clarification-escape-guard";
|
} from "@/hooks/use-clarification-escape-guard";
|
||||||
import { ConfigChatSetup } from "@/components/config-chat/config-chat-setup";
|
import { ConfigChatSetup } from "@/components/config-chat/config-chat-setup";
|
||||||
import { useConfigChat } from "@/components/config-chat/use-config-chat";
|
import { useConfigChat } from "@/components/config-chat/use-config-chat";
|
||||||
|
import {
|
||||||
|
MobileConfirmationHost,
|
||||||
|
MobileConfirmationHostBody,
|
||||||
|
} from "@/components/confirmation/mobile-confirmation-host";
|
||||||
|
|
||||||
const QuickTerminalTabView = dynamic(
|
const QuickTerminalTabView = dynamic(
|
||||||
() => import("./quick-terminal-tab-view").then((module) => module.QuickTerminalTabView),
|
() => import("./quick-terminal-tab-view").then((module) => module.QuickTerminalTabView),
|
||||||
@@ -174,41 +178,52 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<ClarificationEscapeGuardProvider value={escapeGuardRegistry}>
|
<ClarificationEscapeGuardProvider value={escapeGuardRegistry}>
|
||||||
<Dialog open={quickChat.isOpen} onOpenChange={quickChat.handleOpenChange}>
|
<MobileConfirmationHost open={quickChat.isOpen} key={workspaceId}>
|
||||||
<DialogContent
|
{({ contentProps }) => (
|
||||||
className="!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 p-0 pt-safe pb-safe shadow-2xl sm:!left-1/2 sm:!top-1/2 sm:!h-[85vh] sm:!max-h-[85vh] sm:!w-[var(--quick-chat-width)] sm:!max-w-[calc(100vw-2rem)] sm:!-translate-x-1/2 sm:!-translate-y-1/2"
|
<Dialog open={quickChat.isOpen} onOpenChange={quickChat.handleOpenChange}>
|
||||||
style={{ "--quick-chat-width": `${width}px` } as CSSProperties}
|
<DialogContent
|
||||||
showCloseButton={false}
|
{...contentProps}
|
||||||
overlayClassName="bg-black/20 sm:bg-black/40 sm:backdrop-blur-sm"
|
className="!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 p-0 pt-safe pb-safe shadow-2xl sm:!left-1/2 sm:!top-1/2 sm:!h-[85vh] sm:!max-h-[85vh] sm:!w-[var(--quick-chat-width)] sm:!max-w-[calc(100vw-2rem)] sm:!-translate-x-1/2 sm:!-translate-y-1/2"
|
||||||
onEscapeKeyDown={(event) => {
|
style={{ "--quick-chat-width": `${width}px` } as CSSProperties}
|
||||||
// A pending, expanded clarification (or an open suggestion popup,
|
showCloseButton={false}
|
||||||
// or the reverse-search overlay) handles this Escape itself and
|
overlayClassName="bg-black/20 sm:bg-black/40 sm:backdrop-blur-sm"
|
||||||
// the modal stays open -- but only when that widget's own guard
|
onEscapeKeyDown={(event) => {
|
||||||
// predicate reports it will actually act on this exact keydown
|
if (contentProps.onEscapeKeyDown) {
|
||||||
// (matching its enabled/scope/modifier state), so Escape never
|
contentProps.onEscapeKeyDown(event);
|
||||||
// goes silently swallowed with nothing left to handle it. Once no
|
return;
|
||||||
// guard claims it (e.g. a second, now-unguarded Escape), the modal
|
}
|
||||||
// closes, matching the main task chat panel's two-stage Escape
|
// A pending, expanded clarification (or an open suggestion popup,
|
||||||
// after #2729.
|
// or the reverse-search overlay) handles this Escape itself and
|
||||||
for (const test of escapeGuardsRef.current.values()) {
|
// the modal stays open -- but only when that widget's own guard
|
||||||
if (test(event)) {
|
// predicate reports it will actually act on this exact keydown
|
||||||
event.preventDefault();
|
// (matching its enabled/scope/modifier state), so Escape never
|
||||||
break;
|
// goes silently swallowed with nothing left to handle it. Once no
|
||||||
}
|
// guard claims it (e.g. a second, now-unguarded Escape), the modal
|
||||||
}
|
// closes, matching the main task chat panel's two-stage Escape
|
||||||
}}
|
// after #2729.
|
||||||
>
|
for (const test of escapeGuardsRef.current.values()) {
|
||||||
<DialogTitle className="sr-only">{t("common:commandQuickChat")}</DialogTitle>
|
if (test(event)) {
|
||||||
<QuickChatResizeHandle edge="left" {...leftResizeHandleProps} />
|
event.preventDefault();
|
||||||
<QuickChatResizeHandle edge="right" {...rightResizeHandleProps} />
|
break;
|
||||||
<QuickChatContent
|
}
|
||||||
workspaceId={workspaceId}
|
}
|
||||||
configChat={configChat}
|
}}
|
||||||
quickChat={quickChat}
|
>
|
||||||
setQuickChatInitialPrompt={setQuickChatInitialPrompt}
|
<MobileConfirmationHostBody>
|
||||||
/>
|
<DialogTitle className="sr-only">{t("common:commandQuickChat")}</DialogTitle>
|
||||||
</DialogContent>
|
<QuickChatResizeHandle edge="left" {...leftResizeHandleProps} />
|
||||||
</Dialog>
|
<QuickChatResizeHandle edge="right" {...rightResizeHandleProps} />
|
||||||
|
<QuickChatContent
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
configChat={configChat}
|
||||||
|
quickChat={quickChat}
|
||||||
|
setQuickChatInitialPrompt={setQuickChatInitialPrompt}
|
||||||
|
/>
|
||||||
|
</MobileConfirmationHostBody>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
|
|
||||||
<QuickChatDeleteDialog
|
<QuickChatDeleteDialog
|
||||||
sessionToDelete={quickChat.sessionToClose}
|
sessionToDelete={quickChat.sessionToClose}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TaskWalkthrough } from "@/lib/types/http";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
|
||||||
|
export function WalkthroughDiscardConfirmation({
|
||||||
|
taskId,
|
||||||
|
walkthrough,
|
||||||
|
open,
|
||||||
|
disabled,
|
||||||
|
anchorRef,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
taskId: string;
|
||||||
|
walkthrough: TaskWalkthrough;
|
||||||
|
open: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
anchorRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isFinePointer } = useResponsiveBreakpoint();
|
||||||
|
const actions = {
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
title: t("review:discardWalkthroughTitle"),
|
||||||
|
description: t("review:discardWalkthroughDescription"),
|
||||||
|
cancelLabel: t("common:cancel"),
|
||||||
|
confirmLabel: t("review:discardWalkthrough"),
|
||||||
|
confirmTestId: "walkthrough-discard-confirm",
|
||||||
|
testId: "walkthrough-discard-confirmation",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<MobileActionConfirmation
|
||||||
|
{...actions}
|
||||||
|
targetKey={`${taskId}:${walkthrough.id}:${walkthrough.updated_at}`}
|
||||||
|
subject={walkthrough.title}
|
||||||
|
focusReturnRef={anchorRef}
|
||||||
|
disabled={disabled}
|
||||||
|
fallback={isFinePointer ? <ActionConfirmPopover {...actions} anchorRef={anchorRef} /> : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -264,7 +264,7 @@ describe("WalkthroughOverlay fine-pointer discard confirmation", () => {
|
|||||||
describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
|
describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
|
||||||
beforeEach(resetOverlayMocks);
|
beforeEach(resetOverlayMocks);
|
||||||
|
|
||||||
it("morphs coarse-pointer discard into local touch actions", async () => {
|
it("shows a named phone sheet without replacing the walkthrough launcher", async () => {
|
||||||
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(false));
|
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(false));
|
||||||
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
@@ -274,11 +274,14 @@ describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
|
|||||||
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
||||||
|
|
||||||
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
|
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
|
||||||
|
expect(screen.getByRole("dialog").getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(confirmation.textContent).toContain("Walkthrough");
|
||||||
|
expect(screen.getByTestId(DISCARD_BUTTON_TEST_ID).isConnected).toBe(true);
|
||||||
expect(confirmation.getAttribute("role")).toBe("group");
|
expect(confirmation.getAttribute("role")).toBe("group");
|
||||||
expect(screen.queryByRole(ALERT_DIALOG_ROLE)).toBeNull();
|
expect(screen.queryByRole(ALERT_DIALOG_ROLE)).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
within(confirmation).getByRole("button", { name: DISCARD_BUTTON_NAME }).className,
|
within(confirmation).getByRole("button", { name: DISCARD_BUTTON_NAME }).className,
|
||||||
).toContain("h-11");
|
).toContain("min-h-12");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores focus to the coarse-pointer discard button after cancel", async () => {
|
it("restores focus to the coarse-pointer discard button after cancel", async () => {
|
||||||
@@ -301,7 +304,9 @@ describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
|
|||||||
);
|
);
|
||||||
act(() => runAnimationFrame?.(0));
|
act(() => runAnimationFrame?.(0));
|
||||||
|
|
||||||
expect(document.activeElement).toBe(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(screen.getByTestId(DISCARD_BUTTON_TEST_ID)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { clearOpenWalkthroughTaskId, setOpenWalkthroughTaskId } from "@/lib/walk
|
|||||||
import { cn } from "@kandev/ui/lib/utils";
|
import { cn } from "@kandev/ui/lib/utils";
|
||||||
import type { TaskWalkthrough } from "@/lib/types/http";
|
import type { TaskWalkthrough } from "@/lib/types/http";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { WalkthroughDiscardConfirmation } from "./walkthrough-discard-confirmation";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
|
||||||
type WalkthroughOverlayProps = {
|
type WalkthroughOverlayProps = {
|
||||||
@@ -29,6 +29,7 @@ type WalkthroughLauncherProps = {
|
|||||||
discardDisabled: boolean;
|
discardDisabled: boolean;
|
||||||
hasUnseen: boolean;
|
hasUnseen: boolean;
|
||||||
isFinePointer: boolean;
|
isFinePointer: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onDiscardClick: () => void;
|
onDiscardClick: () => void;
|
||||||
onDiscardCancel: () => void;
|
onDiscardCancel: () => void;
|
||||||
@@ -45,6 +46,7 @@ function WalkthroughLauncher({
|
|||||||
discardDisabled,
|
discardDisabled,
|
||||||
hasUnseen,
|
hasUnseen,
|
||||||
isFinePointer,
|
isFinePointer,
|
||||||
|
isMobile,
|
||||||
isOpen,
|
isOpen,
|
||||||
onDiscardClick,
|
onDiscardClick,
|
||||||
onDiscardCancel,
|
onDiscardCancel,
|
||||||
@@ -90,7 +92,7 @@ function WalkthroughLauncher({
|
|||||||
{activeStep + 1}/{stepCount}
|
{activeStep + 1}/{stepCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{isFinePointer || !confirmDiscardOpen ? (
|
{isMobile || isFinePointer || !confirmDiscardOpen ? (
|
||||||
<button
|
<button
|
||||||
ref={discardAnchorRef}
|
ref={discardAnchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -178,8 +180,7 @@ function useWalkthroughBackfill(params: {
|
|||||||
*/
|
*/
|
||||||
export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayProps) {
|
export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const discardLabel = t("review:discardWalkthrough");
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
|
||||||
const walkthrough = useAppStore((s) => (taskId ? s.walkthroughs.byTaskId[taskId] : null));
|
const walkthrough = useAppStore((s) => (taskId ? s.walkthroughs.byTaskId[taskId] : null));
|
||||||
const connectionStatus = useAppStore((s) => s.connection.status);
|
const connectionStatus = useAppStore((s) => s.connection.status);
|
||||||
const activeStep = useAppStore((s) =>
|
const activeStep = useAppStore((s) =>
|
||||||
@@ -251,7 +252,8 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
|
|||||||
discardAnchorRef={discardAnchorRef}
|
discardAnchorRef={discardAnchorRef}
|
||||||
discardDisabled={discarding}
|
discardDisabled={discarding}
|
||||||
hasUnseen={hasUnseen}
|
hasUnseen={hasUnseen}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={!isMobile && isFinePointer}
|
||||||
|
isMobile={isMobile}
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
onDiscardClick={() => setDiscardTargetTaskId(taskId)}
|
onDiscardClick={() => setDiscardTargetTaskId(taskId)}
|
||||||
onDiscardCancel={() => {
|
onDiscardCancel={() => {
|
||||||
@@ -263,20 +265,15 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
|
|||||||
onToggle={() => (open ? closeTour() : openTour())}
|
onToggle={() => (open ? closeTour() : openTour())}
|
||||||
stepCount={walkthrough.steps.length}
|
stepCount={walkthrough.steps.length}
|
||||||
/>
|
/>
|
||||||
{isFinePointer ? (
|
<WalkthroughDiscardConfirmation
|
||||||
<ActionConfirmPopover
|
taskId={taskId}
|
||||||
open={confirmDiscardOpen}
|
walkthrough={walkthrough}
|
||||||
anchorRef={discardAnchorRef}
|
open={confirmDiscardOpen}
|
||||||
title={t("review:discardWalkthroughTitle")}
|
anchorRef={discardAnchorRef}
|
||||||
description={t("review:discardWalkthroughDescription")}
|
disabled={discarding}
|
||||||
cancelLabel={t("common:cancel")}
|
onOpenChange={(nextOpen) => setDiscardTargetTaskId(nextOpen ? taskId : null)}
|
||||||
confirmLabel={discardLabel}
|
onConfirm={() => discardWalkthrough(discardTargetTaskId)}
|
||||||
confirmTestId="walkthrough-discard-confirm"
|
/>
|
||||||
testId="walkthrough-discard-confirmation"
|
|
||||||
onOpenChange={(nextOpen) => setDiscardTargetTaskId(nextOpen ? taskId : null)}
|
|
||||||
onConfirm={() => discardWalkthrough(discardTargetTaskId)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,33 @@ function instance(id: string, name: string): SentryConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
describe("SentryInstanceCard", () => {
|
describe("SentryInstanceCard", () => {
|
||||||
|
it("names the phone instance in a sheet and keeps its delete button mounted", () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SentryInstanceCard
|
||||||
|
instance={instance("production", "Production")}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
isFinePointer={false}
|
||||||
|
confirmingDelete
|
||||||
|
onDeleteCancel={onCancel}
|
||||||
|
onDeleteConfirm={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const sheet = screen.getByRole("dialog");
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(sheet.textContent).toContain("Production");
|
||||||
|
expect(screen.getByTestId("sentry-instance-delete-button").isConnected).toBe(true);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledExactlyOnceWith("production");
|
||||||
|
});
|
||||||
it("gives each instance action a distinct accessible name", () => {
|
it("gives each instance action a distinct accessible name", () => {
|
||||||
render(
|
render(
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from "@/components/integrations/auth-status-banner";
|
} from "@/components/integrations/auth-status-banner";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import type { SentryConfig } from "@/lib/types/sentry";
|
import type { SentryConfig } from "@/lib/types/sentry";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -48,7 +50,16 @@ export function SentryInstanceCard({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const confirmationTitle = t("sentry:removeInstanceConfirm", { name: instance.name });
|
const confirmationTitle = t("sentry:removeInstanceConfirm", { name: instance.name });
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
const cancelDelete = () => onDeleteCancel(instance.id);
|
const cancelDelete = () => onDeleteCancel(instance.id);
|
||||||
|
const deleteLabel = t("sentry:delete");
|
||||||
|
const actions = {
|
||||||
|
cancelLabel: t("common:cancel"),
|
||||||
|
confirmLabel: deleteLabel,
|
||||||
|
confirmAriaLabel: confirmationTitle,
|
||||||
|
confirmTestId: "sentry-remove-confirm",
|
||||||
|
onConfirm: onDeleteConfirm,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-md border p-4" data-testid="sentry-instance-card">
|
<div className="space-y-3 rounded-md border p-4" data-testid="sentry-instance-card">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
@@ -70,7 +81,7 @@ export function SentryInstanceCard({
|
|||||||
<IconPencil className="h-3.5 w-3.5" />
|
<IconPencil className="h-3.5 w-3.5" />
|
||||||
{t("sentry:edit")}
|
{t("sentry:edit")}
|
||||||
</Button>
|
</Button>
|
||||||
{(isFinePointer || !confirmingDelete) && (
|
{(isMobile || isFinePointer || !confirmingDelete) && (
|
||||||
<Button
|
<Button
|
||||||
ref={deleteAnchorRef}
|
ref={deleteAnchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -81,43 +92,46 @@ export function SentryInstanceCard({
|
|||||||
data-testid="sentry-instance-delete-button"
|
data-testid="sentry-instance-delete-button"
|
||||||
>
|
>
|
||||||
<IconTrash className="h-3.5 w-3.5" />
|
<IconTrash className="h-3.5 w-3.5" />
|
||||||
{t("sentry:delete")}
|
{deleteLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isFinePointer && confirmingDelete ? (
|
<MobileActionConfirmation
|
||||||
<InlineConfirmActions
|
open={confirmingDelete}
|
||||||
density="touch"
|
targetKey={`${instance.workspaceId}:${instance.id}`}
|
||||||
testId="sentry-remove-inline-confirmation"
|
title={confirmationTitle}
|
||||||
ariaLabel={confirmationTitle}
|
{...actions}
|
||||||
description={confirmationTitle}
|
onOpenChange={(open) => {
|
||||||
cancelLabel={t("common:cancel")}
|
if (!open) cancelDelete();
|
||||||
confirmLabel={t("sentry:delete")}
|
}}
|
||||||
confirmAriaLabel={confirmationTitle}
|
focusReturnRef={deleteAnchorRef}
|
||||||
confirmTestId="sentry-remove-confirm"
|
fallback={
|
||||||
onCancel={cancelDelete}
|
!isFinePointer ? (
|
||||||
onClose={cancelDelete}
|
<InlineConfirmActions
|
||||||
onConfirm={onDeleteConfirm}
|
density="touch"
|
||||||
/>
|
testId="sentry-remove-inline-confirmation"
|
||||||
) : null}
|
ariaLabel={confirmationTitle}
|
||||||
{isFinePointer ? (
|
description={confirmationTitle}
|
||||||
<ActionConfirmPopover
|
{...actions}
|
||||||
open={confirmingDelete}
|
onCancel={cancelDelete}
|
||||||
anchorRef={deleteAnchorRef}
|
onClose={cancelDelete}
|
||||||
title={confirmationTitle}
|
/>
|
||||||
cancelLabel={t("common:cancel")}
|
) : (
|
||||||
confirmLabel={t("sentry:delete")}
|
<ActionConfirmPopover
|
||||||
confirmAriaLabel={confirmationTitle}
|
open={confirmingDelete}
|
||||||
confirmTestId="sentry-remove-confirm"
|
anchorRef={deleteAnchorRef}
|
||||||
testId="sentry-remove-confirm-popover"
|
title={confirmationTitle}
|
||||||
onOpenChange={(open) => {
|
{...actions}
|
||||||
if (!open) cancelDelete();
|
testId="sentry-remove-confirm-popover"
|
||||||
}}
|
onOpenChange={(open) => {
|
||||||
onCancel={cancelDelete}
|
if (!open) cancelDelete();
|
||||||
onConfirm={onDeleteConfirm}
|
}}
|
||||||
/>
|
onCancel={cancelDelete}
|
||||||
) : null}
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<IntegrationAuthStatusBanner health={configToHealth(instance)} />
|
<IntegrationAuthStatusBanner health={configToHealth(instance)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ function WatchActions({
|
|||||||
<TooltipContent>{t("common:reset")}</TooltipContent>
|
<TooltipContent>{t("common:reset")}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<WatcherDeleteAction
|
<WatcherDeleteAction
|
||||||
|
targetKey={`${watch.workspaceId}:${watch.id}`}
|
||||||
|
subject={summarizeFilter(watch.filter)}
|
||||||
title={t("sentry:deleteThisSentryWatcher")}
|
title={t("sentry:deleteThisSentryWatcher")}
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
confirmLabel={t("sentry:delete")}
|
confirmLabel={t("sentry:delete")}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
} from "./agent-profile-delete-dialog";
|
} from "./agent-profile-delete-dialog";
|
||||||
import type { AgentProfileDeleteConflict } from "./agent-profile-delete-dialog";
|
import type { AgentProfileDeleteConflict } from "./agent-profile-delete-dialog";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
function renderLocalConfirmation(
|
function renderLocalConfirmation(
|
||||||
isFinePointer: boolean,
|
isFinePointer: boolean,
|
||||||
@@ -25,6 +28,8 @@ function renderLocalConfirmation(
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<AgentProfileDeleteConfirmation
|
<AgentProfileDeleteConfirmation
|
||||||
|
profileId="profile-1"
|
||||||
|
profileName="Code reviewer"
|
||||||
open={open}
|
open={open}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={isFinePointer}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
@@ -40,6 +45,19 @@ function renderLocalConfirmation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AgentProfileDeleteConfirmation", () => {
|
describe("AgentProfileDeleteConfirmation", () => {
|
||||||
|
it("names the phone target and restores the original trigger on Cancel", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
renderLocalConfirmation(false, onConfirm);
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Delete agent profile?" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(within(sheet).getByText("Code reviewer", { selector: "p" })).toBeTruthy();
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(screen.getByTestId("profile-delete-trigger")),
|
||||||
|
);
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
it("uses touch-sized inline actions on coarse pointers and cancels without mutating", async () => {
|
it("uses touch-sized inline actions on coarse pointers and cancels without mutating", async () => {
|
||||||
const onConfirm = vi.fn();
|
const onConfirm = vi.fn();
|
||||||
renderLocalConfirmation(false, onConfirm);
|
renderLocalConfirmation(false, onConfirm);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@kandev/ui/alert-dialog";
|
} from "@kandev/ui/alert-dialog";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { useAppStore } from "@/components/state-provider";
|
import { useAppStore } from "@/components/state-provider";
|
||||||
import type {
|
import type {
|
||||||
ActiveSessionInfo,
|
ActiveSessionInfo,
|
||||||
@@ -39,6 +40,8 @@ const DELETE_PROFILE_DESCRIPTION_KEY = "agents:deleteAgentProfileDescription";
|
|||||||
const CANCEL_LABEL_KEY = "common:cancel";
|
const CANCEL_LABEL_KEY = "common:cancel";
|
||||||
|
|
||||||
type AgentProfileDeleteConfirmationProps = {
|
type AgentProfileDeleteConfirmationProps = {
|
||||||
|
profileId: string;
|
||||||
|
profileName: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
isFinePointer: boolean;
|
isFinePointer: boolean;
|
||||||
anchorRef: RefObject<HTMLElement | null>;
|
anchorRef: RefObject<HTMLElement | null>;
|
||||||
@@ -52,6 +55,8 @@ type AgentProfileDeleteConfirmationProps = {
|
|||||||
* AgentProfileDeleteConflictDialog because its dependency lists need a modal.
|
* AgentProfileDeleteConflictDialog because its dependency lists need a modal.
|
||||||
*/
|
*/
|
||||||
export function AgentProfileDeleteConfirmation({
|
export function AgentProfileDeleteConfirmation({
|
||||||
|
profileId,
|
||||||
|
profileName,
|
||||||
open,
|
open,
|
||||||
isFinePointer,
|
isFinePointer,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
@@ -60,26 +65,21 @@ export function AgentProfileDeleteConfirmation({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}: AgentProfileDeleteConfirmationProps) {
|
}: AgentProfileDeleteConfirmationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (isFinePointer) {
|
const fallback = isFinePointer ? (
|
||||||
return (
|
<ActionConfirmPopover
|
||||||
<ActionConfirmPopover
|
open={open}
|
||||||
open={open}
|
anchorRef={anchorRef}
|
||||||
anchorRef={anchorRef}
|
title={t(DELETE_PROFILE_TITLE_KEY)}
|
||||||
title={t(DELETE_PROFILE_TITLE_KEY)}
|
description={t(DELETE_PROFILE_DESCRIPTION_KEY)}
|
||||||
description={t(DELETE_PROFILE_DESCRIPTION_KEY)}
|
cancelLabel={t(CANCEL_LABEL_KEY)}
|
||||||
cancelLabel={t(CANCEL_LABEL_KEY)}
|
confirmLabel={t("agents:delete")}
|
||||||
confirmLabel={t("agents:delete")}
|
confirmTestId="agent-profile-delete-confirm"
|
||||||
confirmTestId="agent-profile-delete-confirm"
|
testId="agent-profile-delete-confirm-popover"
|
||||||
testId="agent-profile-delete-confirm-popover"
|
onOpenChange={onOpenChange}
|
||||||
onOpenChange={onOpenChange}
|
onCancel={onCancel}
|
||||||
onCancel={onCancel}
|
onConfirm={onConfirm}
|
||||||
onConfirm={onConfirm}
|
/>
|
||||||
/>
|
) : (
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
return (
|
|
||||||
<InlineConfirmActions
|
<InlineConfirmActions
|
||||||
density="touch"
|
density="touch"
|
||||||
testId="agent-profile-delete-inline-confirmation"
|
testId="agent-profile-delete-inline-confirmation"
|
||||||
@@ -93,6 +93,23 @@ export function AgentProfileDeleteConfirmation({
|
|||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={open}
|
||||||
|
targetKey={profileId}
|
||||||
|
title={t(DELETE_PROFILE_TITLE_KEY)}
|
||||||
|
subject={profileName}
|
||||||
|
description={t(DELETE_PROFILE_DESCRIPTION_KEY)}
|
||||||
|
cancelLabel={t(CANCEL_LABEL_KEY)}
|
||||||
|
confirmLabel={t("agents:delete")}
|
||||||
|
confirmTestId="agent-profile-delete-confirm"
|
||||||
|
focusReturnRef={anchorRef}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
fallback={fallback}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentProfileDisableConflictDialog({
|
export function AgentProfileDisableConflictDialog({
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import type { AgentProfileMcpConfig } from "@/lib/types/http";
|
|||||||
import { useAgentProfileSettings } from "@/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings";
|
import { useAgentProfileSettings } from "@/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings";
|
||||||
import { agentProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
|
import { agentProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { DynamicAgentProfileEditor } from "@/components/settings/dynamic-agent-profile-editor";
|
import { DynamicAgentProfileEditor } from "@/components/settings/dynamic-agent-profile-editor";
|
||||||
import { isHandledApiError } from "@/lib/api/client";
|
import { isHandledApiError } from "@/lib/api/client";
|
||||||
|
|
||||||
@@ -137,15 +138,23 @@ function ProfileEditorHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DeleteProfileCardProps = {
|
type DeleteProfileCardProps = {
|
||||||
|
profile: AgentProfile;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onConfirm: () => void | Promise<void>;
|
onConfirm: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeleteProfileCardProps) {
|
function DeleteProfileCard({
|
||||||
|
profile,
|
||||||
|
onDelete,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteProfileCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
|
useConfirmationBoundary(open, profile.id, onOpenChange);
|
||||||
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const closeDeleteConfirmation = () => {
|
const closeDeleteConfirmation = () => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -161,7 +170,7 @@ function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeletePr
|
|||||||
<p className="text-sm font-medium">{t("agents:removeThisProfile")}</p>
|
<p className="text-sm font-medium">{t("agents:removeThisProfile")}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t("agents:actionCannotBeUndone")}</p>
|
<p className="text-xs text-muted-foreground">{t("agents:actionCannotBeUndone")}</p>
|
||||||
</div>
|
</div>
|
||||||
{!open || isFinePointer ? (
|
{!open || isFinePointer || isMobile ? (
|
||||||
<Button
|
<Button
|
||||||
ref={deleteAnchorRef}
|
ref={deleteAnchorRef}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -173,9 +182,11 @@ function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeletePr
|
|||||||
{t("agents:delete")}
|
{t("agents:delete")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{!isFinePointer && open ? (
|
{!isMobile && !isFinePointer && open ? (
|
||||||
<div className="basis-full min-w-0">
|
<div className="basis-full min-w-0">
|
||||||
<AgentProfileDeleteConfirmation
|
<AgentProfileDeleteConfirmation
|
||||||
|
profileId={profile.id}
|
||||||
|
profileName={profile.name}
|
||||||
open={open}
|
open={open}
|
||||||
isFinePointer={false}
|
isFinePointer={false}
|
||||||
anchorRef={deleteAnchorRef}
|
anchorRef={deleteAnchorRef}
|
||||||
@@ -186,8 +197,10 @@ function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeletePr
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{isFinePointer ? (
|
{isMobile || isFinePointer ? (
|
||||||
<AgentProfileDeleteConfirmation
|
<AgentProfileDeleteConfirmation
|
||||||
|
profileId={profile.id}
|
||||||
|
profileName={profile.name}
|
||||||
open={open}
|
open={open}
|
||||||
isFinePointer
|
isFinePointer
|
||||||
anchorRef={deleteAnchorRef}
|
anchorRef={deleteAnchorRef}
|
||||||
@@ -517,6 +530,7 @@ function ProfileEditor({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteProfileCard
|
<DeleteProfileCard
|
||||||
|
profile={savedProfile}
|
||||||
onDelete={deleteState.requestDelete}
|
onDelete={deleteState.requestDelete}
|
||||||
open={deleteState.showDeleteConfirm}
|
open={deleteState.showDeleteConfirm}
|
||||||
onOpenChange={deleteState.setShowDeleteConfirm}
|
onOpenChange={deleteState.setShowDeleteConfirm}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { deleteAgentProfileAction } from "@/app/actions/agents";
|
|||||||
import { useProfileDuplicate } from "@/hooks/domains/settings/use-profile-duplicate";
|
import { useProfileDuplicate } from "@/hooks/domains/settings/use-profile-duplicate";
|
||||||
import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin";
|
import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { useRouter } from "@/lib/routing/client-router";
|
import { useRouter } from "@/lib/routing/client-router";
|
||||||
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||||
import type { Agent, AgentProfile } from "@/lib/types/http";
|
import type { Agent, AgentProfile } from "@/lib/types/http";
|
||||||
@@ -78,6 +79,8 @@ function ProfileRowActions({
|
|||||||
onConfirmDelete: () => void;
|
onConfirmDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
const pendingDelete = useRef(false);
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -92,7 +95,15 @@ function ProfileRowActions({
|
|||||||
<IconDotsVertical className="h-4 w-4" />
|
<IconDotsVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!pendingDelete.current) return;
|
||||||
|
pendingDelete.current = false;
|
||||||
|
event.preventDefault();
|
||||||
|
onConfirmDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{profile.kind !== "dynamic" && (
|
{profile.kind !== "dynamic" && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
@@ -106,7 +117,10 @@ function ProfileRowActions({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
data-testid={`delete-profile-${profile.id}`}
|
data-testid={`delete-profile-${profile.id}`}
|
||||||
onSelect={onConfirmDelete}
|
onSelect={() => {
|
||||||
|
if (isMobile) pendingDelete.current = true;
|
||||||
|
else onConfirmDelete();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<IconTrash className="h-4 w-4 mr-2" />
|
<IconTrash className="h-4 w-4 mr-2" />
|
||||||
{t("agents:delete")}
|
{t("agents:delete")}
|
||||||
@@ -168,6 +182,8 @@ function ProfileRowInlineActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProfileRowDeleteConfirmationProps = {
|
type ProfileRowDeleteConfirmationProps = {
|
||||||
|
profileId: string;
|
||||||
|
profileName: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
isFinePointer: boolean;
|
isFinePointer: boolean;
|
||||||
anchorRef: RefObject<HTMLButtonElement | null>;
|
anchorRef: RefObject<HTMLButtonElement | null>;
|
||||||
@@ -179,6 +195,8 @@ type ProfileRowDeleteConfirmationProps = {
|
|||||||
type ProfileRowDeleteConfirmationBaseProps = Omit<ProfileRowDeleteConfirmationProps, "placement">;
|
type ProfileRowDeleteConfirmationBaseProps = Omit<ProfileRowDeleteConfirmationProps, "placement">;
|
||||||
|
|
||||||
function ProfileRowDeleteConfirmation({
|
function ProfileRowDeleteConfirmation({
|
||||||
|
profileId,
|
||||||
|
profileName,
|
||||||
open,
|
open,
|
||||||
isFinePointer,
|
isFinePointer,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
@@ -187,11 +205,14 @@ function ProfileRowDeleteConfirmation({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
placement,
|
placement,
|
||||||
}: ProfileRowDeleteConfirmationProps) {
|
}: ProfileRowDeleteConfirmationProps) {
|
||||||
if (placement === "inline" && (isFinePointer || !open)) return null;
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
if (placement === "popover" && !isFinePointer) return null;
|
if (placement === "inline" && (isMobile || isFinePointer || !open)) return null;
|
||||||
|
if (placement === "popover" && !isMobile && !isFinePointer) return null;
|
||||||
|
|
||||||
const confirmation = (
|
const confirmation = (
|
||||||
<AgentProfileDeleteConfirmation
|
<AgentProfileDeleteConfirmation
|
||||||
|
profileId={profileId}
|
||||||
|
profileName={profileName}
|
||||||
open={open}
|
open={open}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={isFinePointer}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
@@ -232,6 +253,7 @@ function ProfileRowCard({
|
|||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
confirmationProps,
|
confirmationProps,
|
||||||
}: ProfileRowCardProps) {
|
}: ProfileRowCardProps) {
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
// Same surface treatment as the workspace section tiles.
|
// Same surface treatment as the workspace section tiles.
|
||||||
@@ -261,7 +283,7 @@ function ProfileRowCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 flex shrink-0 items-center gap-1">
|
<div className="relative z-10 flex shrink-0 items-center gap-1">
|
||||||
{canManage &&
|
{canManage &&
|
||||||
!(confirmOpen && !isFinePointer) &&
|
(isMobile || !(confirmOpen && !isFinePointer)) &&
|
||||||
(isFullDesktop ? (
|
(isFullDesktop ? (
|
||||||
<ProfileRowInlineActions
|
<ProfileRowInlineActions
|
||||||
profile={profile}
|
profile={profile}
|
||||||
@@ -294,6 +316,7 @@ export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentPro
|
|||||||
const { isFinePointer, isFullDesktop } = useResponsiveBreakpoint();
|
const { isFinePointer, isFullDesktop } = useResponsiveBreakpoint();
|
||||||
const handleDuplicate = useProfileDuplicate();
|
const handleDuplicate = useProfileDuplicate();
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
useConfirmationBoundary(confirmOpen, profile.id, setConfirmOpen);
|
||||||
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const store = useAppStoreApi();
|
const store = useAppStoreApi();
|
||||||
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
|
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
|
||||||
@@ -343,6 +366,8 @@ export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentPro
|
|||||||
closeDeleteConfirmation();
|
closeDeleteConfirmation();
|
||||||
};
|
};
|
||||||
const confirmationProps = {
|
const confirmationProps = {
|
||||||
|
profileId: profile.id,
|
||||||
|
profileName: profile.name,
|
||||||
open: confirmOpen,
|
open: confirmOpen,
|
||||||
isFinePointer,
|
isFinePointer,
|
||||||
anchorRef: deleteAnchorRef,
|
anchorRef: deleteAnchorRef,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||||
|
import { getBuiltInLayoutProfile } from "@/lib/layout/layout-profiles";
|
||||||
|
import { LayoutProfileDeleteConfirmation } from "./layout-profile-delete-confirmation";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the default-layout warning and stable trigger in the phone sheet", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
function Harness() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<LayoutProfileDeleteConfirmation
|
||||||
|
profile={{
|
||||||
|
id: "layout-1",
|
||||||
|
name: "Review",
|
||||||
|
is_default: true,
|
||||||
|
layout: getBuiltInLayoutProfile("default").layout,
|
||||||
|
created_at: "2026-09-10T00:00:00Z",
|
||||||
|
}}
|
||||||
|
isFinePointer={false}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const trigger = screen.getByTestId("layout-profile-delete");
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const sheet = screen.getByRole("dialog");
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(within(sheet).getByText(/built-in default layout/i)).toBeTruthy();
|
||||||
|
expect(trigger.isConnected).toBe(true);
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(screen.getByTestId("layout-profile-delete-confirm"));
|
||||||
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { settingsActionClassName } from "@/components/settings/settings-control";
|
import { settingsActionClassName } from "@/components/settings/settings-control";
|
||||||
import type { SavedLayout } from "@/lib/types/http";
|
import type { SavedLayout } from "@/lib/types/http";
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ export function LayoutProfileDeleteConfirmation({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}: LayoutProfileDeleteConfirmationProps) {
|
}: LayoutProfileDeleteConfirmationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
const title = t("settings:deleteLayoutProfileNamed", { name: profile.name });
|
const title = t("settings:deleteLayoutProfileNamed", { name: profile.name });
|
||||||
const description = profile.is_default
|
const description = profile.is_default
|
||||||
? t("settings:theBuiltInDefaultLayoutWill")
|
? t("settings:theBuiltInDefaultLayoutWill")
|
||||||
@@ -38,41 +41,53 @@ export function LayoutProfileDeleteConfirmation({
|
|||||||
queueMicrotask(() => anchorRef.current?.focus());
|
queueMicrotask(() => anchorRef.current?.focus());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isFinePointer) {
|
const fallback = !isFinePointer ? (
|
||||||
if (!open)
|
<InlineConfirmActions
|
||||||
return <DeleteProfileButton anchorRef={anchorRef} onClick={() => onOpenChange(true)} />;
|
density="touch"
|
||||||
return (
|
testId="layout-profile-delete-inline-confirmation"
|
||||||
<InlineConfirmActions
|
ariaLabel={title}
|
||||||
density="touch"
|
description={description}
|
||||||
testId="layout-profile-delete-inline-confirmation"
|
cancelLabel={t("settings:cancel")}
|
||||||
ariaLabel={title}
|
confirmLabel={t("settings:delete")}
|
||||||
description={description}
|
confirmAriaLabel={title}
|
||||||
cancelLabel={t("settings:cancel")}
|
confirmTestId="layout-profile-delete-confirm"
|
||||||
confirmLabel={t("settings:delete")}
|
onCancel={cancelInlineDelete}
|
||||||
confirmAriaLabel={title}
|
onClose={() => onOpenChange(false)}
|
||||||
confirmTestId="layout-profile-delete-confirm"
|
onConfirm={onConfirm}
|
||||||
onCancel={cancelInlineDelete}
|
/>
|
||||||
onClose={() => onOpenChange(false)}
|
) : (
|
||||||
onConfirm={onConfirm}
|
<ActionConfirmPopover
|
||||||
/>
|
open={open}
|
||||||
);
|
anchorRef={anchorRef}
|
||||||
}
|
title={title}
|
||||||
|
description={description}
|
||||||
|
cancelLabel={t("settings:cancel")}
|
||||||
|
confirmLabel={t("settings:delete")}
|
||||||
|
confirmAriaLabel={title}
|
||||||
|
confirmTestId="layout-profile-delete-confirm"
|
||||||
|
testId="layout-profile-delete-confirm-popover"
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DeleteProfileButton anchorRef={anchorRef} onClick={() => onOpenChange(true)} />
|
{(isMobile || isFinePointer || !open) && (
|
||||||
<ActionConfirmPopover
|
<DeleteProfileButton anchorRef={anchorRef} onClick={() => onOpenChange(true)} />
|
||||||
|
)}
|
||||||
|
<MobileActionConfirmation
|
||||||
open={open}
|
open={open}
|
||||||
anchorRef={anchorRef}
|
targetKey={profile.id}
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
cancelLabel={t("settings:cancel")}
|
cancelLabel={t("settings:cancel")}
|
||||||
confirmLabel={t("settings:delete")}
|
confirmLabel={t("settings:delete")}
|
||||||
confirmAriaLabel={title}
|
confirmAriaLabel={title}
|
||||||
confirmTestId="layout-profile-delete-confirm"
|
confirmTestId="layout-profile-delete-confirm"
|
||||||
testId="layout-profile-delete-confirm-popover"
|
focusReturnRef={anchorRef}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
|
fallback={fallback}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ function PluginDangerZone({
|
|||||||
onUninstall,
|
onUninstall,
|
||||||
}: PluginDangerZoneProps) {
|
}: PluginDangerZoneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
const busy = actions.busyId === plugin.id || actions.uninstallBusy;
|
const busy = actions.busyId === plugin.id || actions.uninstallBusy;
|
||||||
const canEnable =
|
const canEnable =
|
||||||
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
|
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
|
||||||
@@ -271,7 +272,7 @@ function PluginDangerZone({
|
|||||||
{t("plugins:disable")}
|
{t("plugins:disable")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(isFinePointer || !confirmingUninstall) && (
|
{(isMobile || isFinePointer || !confirmingUninstall) && (
|
||||||
<Button
|
<Button
|
||||||
ref={uninstallAnchorRef}
|
ref={uninstallAnchorRef}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { PluginRepoLink } from "./plugin-repo-link";
|
|||||||
import { PluginStatusBadge } from "./plugin-status-badge";
|
import { PluginStatusBadge } from "./plugin-status-badge";
|
||||||
import { PluginErrorDiagnostic } from "./plugin-error-diagnostic";
|
import { PluginErrorDiagnostic } from "./plugin-error-diagnostic";
|
||||||
import { PluginUninstallConfirmation } from "./uninstall-plugin-dialog";
|
import { PluginUninstallConfirmation } from "./uninstall-plugin-dialog";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins";
|
import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins";
|
||||||
import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography";
|
import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography";
|
||||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||||
@@ -489,31 +490,18 @@ function PluginRowActions({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
}: PluginRowActionsProps) {
|
}: PluginRowActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
const updateEntry = update?.hasUpdate ? update.latest : undefined;
|
const updateEntry = update?.hasUpdate ? update.latest : undefined;
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex flex-wrap items-center gap-2 shrink-0">
|
<div className="relative z-10 flex flex-wrap items-center gap-2 shrink-0">
|
||||||
{updateEntry && onUpdate && (
|
{updateEntry && onUpdate && (
|
||||||
<Button
|
<PluginUpdateButton
|
||||||
variant="default"
|
pluginId={plugin.id}
|
||||||
size="sm"
|
entry={updateEntry}
|
||||||
data-testid={`plugin-update-${plugin.id}`}
|
updating={!!update?.busy}
|
||||||
className={controlSizingClassName(
|
|
||||||
"compact",
|
|
||||||
"cursor-pointer gap-1 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11",
|
|
||||||
)}
|
|
||||||
aria-busy={update?.busy ? "true" : undefined}
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onUpdate(updateEntry)}
|
onUpdate={onUpdate}
|
||||||
>
|
/>
|
||||||
{update?.busy ? (
|
|
||||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<IconArrowUpCircle className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{update?.busy
|
|
||||||
? t("plugins:updating")
|
|
||||||
: t("plugins:updateToVersion", { version: updateEntry.version })}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{canEnable && (
|
{canEnable && (
|
||||||
<Button
|
<Button
|
||||||
@@ -543,7 +531,7 @@ function PluginRowActions({
|
|||||||
{t("plugins:disable")}
|
{t("plugins:disable")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(isFinePointer || !confirmingUninstall) && (
|
{(isMobile || isFinePointer || !confirmingUninstall) && (
|
||||||
<Button
|
<Button
|
||||||
ref={uninstallAnchorRef}
|
ref={uninstallAnchorRef}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -573,3 +561,40 @@ function PluginRowActions({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PluginUpdateButton({
|
||||||
|
pluginId,
|
||||||
|
entry,
|
||||||
|
updating,
|
||||||
|
disabled,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
pluginId: string;
|
||||||
|
entry: MarketplaceEntry;
|
||||||
|
updating: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onUpdate: (entry: MarketplaceEntry) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
data-testid={`plugin-update-${pluginId}`}
|
||||||
|
className={controlSizingClassName(
|
||||||
|
"compact",
|
||||||
|
"cursor-pointer gap-1 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11",
|
||||||
|
)}
|
||||||
|
aria-busy={updating ? "true" : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onUpdate(entry)}
|
||||||
|
>
|
||||||
|
{updating ? (
|
||||||
|
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<IconArrowUpCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{updating ? t("plugins:updating") : t("plugins:updateToVersion", { version: entry.version })}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { PluginUninstallConfirmation } from "./uninstall-plugin-dialog";
|
import { PluginUninstallConfirmation } from "./uninstall-plugin-dialog";
|
||||||
import type { PluginRecord } from "@/lib/types/plugins";
|
import type { PluginRecord } from "@/lib/types/plugins";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
function plugin(): PluginRecord {
|
function plugin(): PluginRecord {
|
||||||
return {
|
return {
|
||||||
@@ -48,6 +51,14 @@ function Confirmation({ isFinePointer }: { isFinePointer: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("plugin uninstall confirmation", () => {
|
describe("plugin uninstall confirmation", () => {
|
||||||
|
it("shows the named phone uninstall warning in a sheet", () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
render(<Confirmation isFinePointer={false} />);
|
||||||
|
const sheet = screen.getByRole("dialog");
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(sheet.textContent).toContain("Acme Tools");
|
||||||
|
expect(sheet.textContent).toContain("revoke its API key");
|
||||||
|
});
|
||||||
it("uses an anchored non-modal confirmation on fine pointers", () => {
|
it("uses an anchored non-modal confirmation on fine pointers", () => {
|
||||||
render(<Confirmation isFinePointer />);
|
render(<Confirmation isFinePointer />);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import type { PluginRecord } from "@/lib/types/plugins";
|
import type { PluginRecord } from "@/lib/types/plugins";
|
||||||
|
|
||||||
type UninstallPluginConfirmationProps = {
|
type UninstallPluginConfirmationProps = {
|
||||||
@@ -40,33 +41,28 @@ export function PluginUninstallConfirmation({
|
|||||||
);
|
);
|
||||||
const confirmAriaLabel = t("plugins:confirmUninstall");
|
const confirmAriaLabel = t("plugins:confirmUninstall");
|
||||||
|
|
||||||
if (!isFinePointer) {
|
const fallback = !isFinePointer ? (
|
||||||
if (!open) return null;
|
<InlineConfirmActions
|
||||||
return (
|
density="touch"
|
||||||
<InlineConfirmActions
|
testId="plugin-uninstall-inline-confirmation"
|
||||||
density="touch"
|
ariaLabel={t("plugins:uninstallPlugin")}
|
||||||
testId="plugin-uninstall-inline-confirmation"
|
description={description}
|
||||||
ariaLabel={t("plugins:uninstallPlugin")}
|
cancelLabel={t("plugins:cancel")}
|
||||||
description={description}
|
confirmLabel={confirmAriaLabel}
|
||||||
cancelLabel={t("plugins:cancel")}
|
confirmAriaLabel={confirmAriaLabel}
|
||||||
confirmLabel={t("plugins:confirmUninstall")}
|
confirmTestId="plugin-uninstall-confirm"
|
||||||
confirmAriaLabel={confirmAriaLabel}
|
onCancel={onCancel}
|
||||||
confirmTestId="plugin-uninstall-confirm"
|
onClose={() => onOpenChange(false)}
|
||||||
onCancel={onCancel}
|
onConfirm={onConfirm}
|
||||||
onClose={() => onOpenChange(false)}
|
/>
|
||||||
onConfirm={onConfirm}
|
) : (
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionConfirmPopover
|
<ActionConfirmPopover
|
||||||
open={open}
|
open={open}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
title={t("plugins:uninstallPlugin")}
|
title={t("plugins:uninstallPlugin")}
|
||||||
description={description}
|
description={description}
|
||||||
cancelLabel={t("plugins:cancel")}
|
cancelLabel={t("plugins:cancel")}
|
||||||
confirmLabel={t("plugins:confirmUninstall")}
|
confirmLabel={confirmAriaLabel}
|
||||||
confirmAriaLabel={confirmAriaLabel}
|
confirmAriaLabel={confirmAriaLabel}
|
||||||
confirmTestId="plugin-uninstall-confirm"
|
confirmTestId="plugin-uninstall-confirm"
|
||||||
testId="plugin-uninstall-confirm-popover"
|
testId="plugin-uninstall-confirm-popover"
|
||||||
@@ -75,4 +71,20 @@ export function PluginUninstallConfirmation({
|
|||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={open}
|
||||||
|
targetKey={target.id}
|
||||||
|
title={t("plugins:uninstallPlugin")}
|
||||||
|
description={description}
|
||||||
|
cancelLabel={t("plugins:cancel")}
|
||||||
|
confirmLabel={confirmAriaLabel}
|
||||||
|
confirmAriaLabel={confirmAriaLabel}
|
||||||
|
confirmTestId="plugin-uninstall-confirm"
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
focusReturnRef={anchorRef}
|
||||||
|
fallback={fallback}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
|
||||||
type PromptDeleteConfirmationProps = {
|
type PromptDeleteConfirmationProps = {
|
||||||
|
promptId: string;
|
||||||
promptName: string;
|
promptName: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
isFinePointer: boolean;
|
isFinePointer: boolean;
|
||||||
@@ -28,6 +30,7 @@ function PromptDeleteDescription({ promptName }: { promptName: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PromptDeleteConfirmation({
|
export function PromptDeleteConfirmation({
|
||||||
|
promptId,
|
||||||
promptName,
|
promptName,
|
||||||
open,
|
open,
|
||||||
isFinePointer,
|
isFinePointer,
|
||||||
@@ -53,26 +56,22 @@ export function PromptDeleteConfirmation({
|
|||||||
anchorRef.current?.focus();
|
anchorRef.current?.focus();
|
||||||
}, [anchorRef, isFinePointer, open]);
|
}, [anchorRef, isFinePointer, open]);
|
||||||
|
|
||||||
if (!isFinePointer) {
|
const fallback = !isFinePointer ? (
|
||||||
return open ? (
|
<InlineConfirmActions
|
||||||
<InlineConfirmActions
|
density="touch"
|
||||||
density="touch"
|
testId="prompt-delete-inline-confirmation"
|
||||||
testId="prompt-delete-inline-confirmation"
|
ariaLabel={promptDeleteLabel}
|
||||||
ariaLabel={promptDeleteLabel}
|
description={description}
|
||||||
description={description}
|
cancelLabel={cancelLabel}
|
||||||
cancelLabel={cancelLabel}
|
confirmLabel={promptDeleteLabel}
|
||||||
confirmLabel={promptDeleteLabel}
|
confirmAriaLabel={promptDeleteLabel}
|
||||||
confirmAriaLabel={promptDeleteLabel}
|
confirmTestId="prompt-delete-confirm"
|
||||||
confirmTestId="prompt-delete-confirm"
|
confirmDisabled={isBusy}
|
||||||
confirmDisabled={isBusy}
|
onCancel={handleCancel}
|
||||||
onCancel={handleCancel}
|
onClose={onClose}
|
||||||
onClose={onClose}
|
onConfirm={onConfirm}
|
||||||
onConfirm={onConfirm}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionConfirmPopover
|
<ActionConfirmPopover
|
||||||
open={open}
|
open={open}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
@@ -91,4 +90,26 @@ export function PromptDeleteConfirmation({
|
|||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={open}
|
||||||
|
targetKey={promptId}
|
||||||
|
title={promptDeleteLabel}
|
||||||
|
subject={`@${promptName}`}
|
||||||
|
description={description}
|
||||||
|
cancelLabel={cancelLabel}
|
||||||
|
confirmLabel={promptDeleteLabel}
|
||||||
|
confirmAriaLabel={promptDeleteLabel}
|
||||||
|
confirmTestId="prompt-delete-confirm"
|
||||||
|
confirmDisabled={isBusy}
|
||||||
|
focusReturnRef={anchorRef}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) onClose();
|
||||||
|
}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onClose={onClose}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
fallback={fallback}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { IconEdit, IconTrash } from "@tabler/icons-react";
|
import { IconEdit, IconTrash } from "@tabler/icons-react";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
|
|
||||||
import { PromptDeleteConfirmation } from "@/components/settings/prompt-delete-confirmation";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import type { CustomPrompt } from "@/lib/types/http";
|
import type { CustomPrompt } from "@/lib/types/http";
|
||||||
|
|
||||||
type PromptRowActionsProps = {
|
type PromptRowActionsProps = {
|
||||||
@@ -13,9 +13,6 @@ type PromptRowActionsProps = {
|
|||||||
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
|
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
|
||||||
onStartEditing: (prompt: CustomPrompt) => void;
|
onStartEditing: (prompt: CustomPrompt) => void;
|
||||||
onOpenDelete: (prompt: CustomPrompt) => void;
|
onOpenDelete: (prompt: CustomPrompt) => void;
|
||||||
onDeleteClose: () => void;
|
|
||||||
onDeleteCancel: () => void;
|
|
||||||
onDeleteConfirm: () => void;
|
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
showCreate: boolean;
|
showCreate: boolean;
|
||||||
isFinePointer: boolean;
|
isFinePointer: boolean;
|
||||||
@@ -27,18 +24,16 @@ export function PromptRowActions({
|
|||||||
deleteAnchorRef,
|
deleteAnchorRef,
|
||||||
onStartEditing,
|
onStartEditing,
|
||||||
onOpenDelete,
|
onOpenDelete,
|
||||||
onDeleteClose,
|
|
||||||
onDeleteCancel,
|
|
||||||
onDeleteConfirm,
|
|
||||||
isBusy,
|
isBusy,
|
||||||
showCreate,
|
showCreate,
|
||||||
isFinePointer,
|
isFinePointer,
|
||||||
isDeleteTarget,
|
isDeleteTarget,
|
||||||
}: PromptRowActionsProps) {
|
}: PromptRowActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isFinePointer || !isDeleteTarget ? (
|
{isMobile || isFinePointer || !isDeleteTarget ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -65,18 +60,6 @@ export function PromptRowActions({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{isFinePointer ? (
|
|
||||||
<PromptDeleteConfirmation
|
|
||||||
promptName={prompt.name}
|
|
||||||
open={isDeleteTarget}
|
|
||||||
isFinePointer={isFinePointer}
|
|
||||||
anchorRef={deleteAnchorRef}
|
|
||||||
isBusy={isBusy}
|
|
||||||
onClose={onDeleteClose}
|
|
||||||
onCancel={onDeleteCancel}
|
|
||||||
onConfirm={onDeleteConfirm}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
toast: vi.fn(),
|
toast: vi.fn(),
|
||||||
prompts: [] as CustomPrompt[],
|
prompts: [] as CustomPrompt[],
|
||||||
finePointer: true,
|
finePointer: true,
|
||||||
|
isMobile: false,
|
||||||
promptEditor: vi.fn(),
|
promptEditor: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ vi.mock("@/components/state-provider", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer }),
|
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer, isMobile: mocks.isMobile }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/toast-provider", () => ({
|
vi.mock("@/components/toast-provider", () => ({
|
||||||
@@ -69,6 +70,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.prompts = [];
|
mocks.prompts = [];
|
||||||
mocks.finePointer = true;
|
mocks.finePointer = true;
|
||||||
|
mocks.isMobile = false;
|
||||||
mocks.setPrompts.mockImplementation((next: CustomPrompt[]) => {
|
mocks.setPrompts.mockImplementation((next: CustomPrompt[]) => {
|
||||||
mocks.prompts = next;
|
mocks.prompts = next;
|
||||||
});
|
});
|
||||||
@@ -101,6 +103,29 @@ function renderPromptSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("PromptsSettings deletion confirmation", () => {
|
describe("PromptsSettings deletion confirmation", () => {
|
||||||
|
it("keeps the phone editor draft and delete trigger behind a confirmation sheet", async () => {
|
||||||
|
mocks.isMobile = true;
|
||||||
|
mocks.finePointer = false;
|
||||||
|
renderPromptSettings();
|
||||||
|
const row = screen.getByTestId(promptRowTestId);
|
||||||
|
fireEvent.click(within(row).getByTestId("prompt-edit-button"));
|
||||||
|
const editor = within(row).getByTestId(promptContentInputTestId) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(editor, { target: { value: "Keep the mobile draft" } });
|
||||||
|
const trigger = within(row).getByTestId(promptDeleteButtonTestId);
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Delete prompt" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(trigger.isConnected).toBe(true);
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(editor.value).toBe("Keep the mobile draft");
|
||||||
|
expect(mocks.deletePrompt).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(screen.getByTestId(promptDeleteConfirmTestId));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.deletePrompt).toHaveBeenCalledExactlyOnceWith(promptId, { cache: "no-store" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
it("cancels an anchored delete without losing the prompt editor draft", async () => {
|
it("cancels an anchored delete without losing the prompt editor draft", async () => {
|
||||||
renderPromptSettings();
|
renderPromptSettings();
|
||||||
|
|
||||||
|
|||||||
@@ -158,27 +158,23 @@ function PromptListItem({
|
|||||||
deleteAnchorRef={deleteAnchorRef}
|
deleteAnchorRef={deleteAnchorRef}
|
||||||
onStartEditing={onStartEditing}
|
onStartEditing={onStartEditing}
|
||||||
onOpenDelete={onOpenDelete}
|
onOpenDelete={onOpenDelete}
|
||||||
onDeleteClose={onDeleteCancel}
|
|
||||||
onDeleteCancel={onDeleteCancel}
|
|
||||||
onDeleteConfirm={onDeleteConfirm}
|
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
showCreate={showCreate}
|
showCreate={showCreate}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={isFinePointer}
|
||||||
isDeleteTarget={isDeleteTarget}
|
isDeleteTarget={isDeleteTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isFinePointer ? (
|
<PromptDeleteConfirmation
|
||||||
<PromptDeleteConfirmation
|
promptId={prompt.id}
|
||||||
promptName={prompt.name}
|
promptName={prompt.name}
|
||||||
open={isDeleteTarget}
|
open={isDeleteTarget}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={isFinePointer}
|
||||||
anchorRef={deleteAnchorRef}
|
anchorRef={deleteAnchorRef}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
onClose={onDeleteCancel}
|
onClose={onDeleteCancel}
|
||||||
onCancel={onDeleteCancel}
|
onCancel={onDeleteCancel}
|
||||||
onConfirm={onDeleteConfirm}
|
onConfirm={onDeleteConfirm}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@kandev/ui/alert-dialog";
|
} from "@kandev/ui/alert-dialog";
|
||||||
|
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import type { SecretListItem, SecretReference } from "@/lib/types/http-secrets";
|
import type { SecretListItem, SecretReference } from "@/lib/types/http-secrets";
|
||||||
import { settingsActionClassName } from "./settings-control";
|
import { settingsActionClassName } from "./settings-control";
|
||||||
import { secretReferenceLabel } from "./secret-delete-error";
|
import { secretReferenceLabel } from "./secret-delete-error";
|
||||||
@@ -42,31 +43,41 @@ export function SecretDeleteConfirmation({
|
|||||||
}: SecretDeleteConfirmationProps) {
|
}: SecretDeleteConfirmationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const description = loading ? (
|
||||||
|
t("settings:checkingSecretReferences")
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="settings:thisWillPermanentlyRemoveSecret" values={{ name: secret.name }}>
|
||||||
|
This will permanently remove{" "}
|
||||||
|
<span className="font-medium text-foreground">{secret.name}</span>. This action cannot be
|
||||||
|
undone.
|
||||||
|
</Trans>
|
||||||
|
);
|
||||||
|
const actions = {
|
||||||
|
open,
|
||||||
|
description,
|
||||||
|
cancelLabel: t("settings:cancel"),
|
||||||
|
confirmLabel: t("settings:deleteSecret"),
|
||||||
|
confirmAriaLabel: t("settings:deleteSecretNamed", { name: secret.name }),
|
||||||
|
confirmTestId: "secret-delete-confirm",
|
||||||
|
confirmDisabled: loading,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<ActionConfirmPopover
|
<MobileActionConfirmation
|
||||||
open={open}
|
{...actions}
|
||||||
anchorRef={anchorRef}
|
targetKey={secret.id}
|
||||||
title={t("settings:deleteSecret")}
|
title={t("settings:deleteSecretNamed", { name: secret.name })}
|
||||||
description={
|
focusReturnRef={anchorRef}
|
||||||
loading ? (
|
fallback={
|
||||||
t("settings:checkingSecretReferences")
|
<ActionConfirmPopover
|
||||||
) : (
|
{...actions}
|
||||||
<Trans i18nKey="settings:thisWillPermanentlyRemoveSecret" values={{ name: secret.name }}>
|
anchorRef={anchorRef}
|
||||||
This will permanently remove{" "}
|
title={t("settings:deleteSecret")}
|
||||||
<span className="font-medium text-foreground">{secret.name}</span>. This action cannot
|
testId="secret-delete-confirm-popover"
|
||||||
be undone.
|
onCancel={onCancel}
|
||||||
</Trans>
|
/>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
cancelLabel={t("settings:cancel")}
|
|
||||||
confirmLabel={t("settings:deleteSecret")}
|
|
||||||
confirmAriaLabel={t("settings:deleteSecretNamed", { name: secret.name })}
|
|
||||||
confirmTestId="secret-delete-confirm"
|
|
||||||
confirmDisabled={loading}
|
|
||||||
testId="secret-delete-confirm-popover"
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { SecretListItem } from "@/lib/types/http-secrets";
|
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||||
|
|
||||||
let finePointer = true;
|
let finePointer = true;
|
||||||
|
let isMobile = false;
|
||||||
|
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer }),
|
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer, isMobile }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { SecretListItemRow } from "./secrets-list-item-row";
|
import { SecretListItemRow } from "./secrets-list-item-row";
|
||||||
@@ -47,6 +48,7 @@ function renderRow(overrides: Partial<Parameters<typeof SecretListItemRow>[0]> =
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
finePointer = true;
|
finePointer = true;
|
||||||
|
isMobile = false;
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +63,18 @@ function copyMoveDisabled(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SecretListItemRow", () => {
|
describe("SecretListItemRow", () => {
|
||||||
|
it("uses a named phone sheet while keeping the original row controls mounted", async () => {
|
||||||
|
isMobile = true;
|
||||||
|
finePointer = false;
|
||||||
|
const { onDeleteCancel } = renderRow({ isDeleteConfirming: true });
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Delete secret API Key" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(screen.getByRole("button", { name: COPY_MOVE_BUTTON, hidden: true }).isConnected).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onDeleteCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
it("keeps delete trigger target-aware and touch-sized", () => {
|
it("keeps delete trigger target-aware and touch-sized", () => {
|
||||||
const { onDelete } = renderRow();
|
const { onDelete } = renderRow();
|
||||||
const deleteButton = screen.getByRole("button", { name: DELETE_BUTTON });
|
const deleteButton = screen.getByRole("button", { name: DELETE_BUTTON });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
|||||||
import { revealSecret } from "@/lib/api/domains/secrets-api";
|
import { revealSecret } from "@/lib/api/domains/secrets-api";
|
||||||
import type { SecretListItem } from "@/lib/types/http-secrets";
|
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||||
import { SecretDeleteConfirmation } from "./secrets-delete-dialog";
|
import { SecretDeleteConfirmation } from "./secrets-delete-dialog";
|
||||||
|
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
|
||||||
type SecretListItemRowProps = {
|
type SecretListItemRowProps = {
|
||||||
secret: SecretListItem;
|
secret: SecretListItem;
|
||||||
@@ -77,7 +78,10 @@ export function SecretListItemRow({
|
|||||||
isDeleteBlocked = false,
|
isDeleteBlocked = false,
|
||||||
}: SecretListItemRowProps) {
|
}: SecretListItemRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
|
useConfirmationBoundary(isDeleteConfirming, `${workspaceId}:${secret.id}`, () =>
|
||||||
|
onDeleteCancel(),
|
||||||
|
);
|
||||||
const [revealed, setRevealed] = useState(false);
|
const [revealed, setRevealed] = useState(false);
|
||||||
const [revealedValue, setRevealedValue] = useState<string | null>(null);
|
const [revealedValue, setRevealedValue] = useState<string | null>(null);
|
||||||
const [revealing, setRevealing] = useState(false);
|
const [revealing, setRevealing] = useState(false);
|
||||||
@@ -134,7 +138,7 @@ export function SecretListItemRow({
|
|||||||
isDeleteConfirming={isDeleteConfirming}
|
isDeleteConfirming={isDeleteConfirming}
|
||||||
isDeleteLoading={isDeleteLoading}
|
isDeleteLoading={isDeleteLoading}
|
||||||
isDeleteBlocked={isDeleteBlocked}
|
isDeleteBlocked={isDeleteBlocked}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={isMobile || isFinePointer}
|
||||||
revealed={revealed}
|
revealed={revealed}
|
||||||
revealing={revealing}
|
revealing={revealing}
|
||||||
deleteAnchorRef={deleteAnchorRef}
|
deleteAnchorRef={deleteAnchorRef}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe("UsersTable confirmation presentation", () => {
|
|||||||
expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull();
|
expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("morphs a phone row action into visible touch-sized status confirmation", async () => {
|
it("opens a phone status change in a named sheet", async () => {
|
||||||
mocks.responsive.isFinePointer = false;
|
mocks.responsive.isFinePointer = false;
|
||||||
mocks.responsive.isMobile = true;
|
mocks.responsive.isMobile = true;
|
||||||
await renderUsersTable();
|
await renderUsersTable();
|
||||||
@@ -117,7 +117,8 @@ describe("UsersTable confirmation presentation", () => {
|
|||||||
expect(within(row).getByTestId("users-table-email").textContent).toContain(TARGET.email);
|
expect(within(row).getByTestId("users-table-email").textContent).toContain(TARGET.email);
|
||||||
fireEvent.click(within(row).getByTestId(STATUS_TOGGLE_TEST_ID));
|
fireEvent.click(within(row).getByTestId(STATUS_TOGGLE_TEST_ID));
|
||||||
|
|
||||||
const confirmation = await screen.findByTestId("users-table-inline-confirmation");
|
const confirmation = await screen.findByRole("dialog");
|
||||||
|
expect(confirmation.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
expect(confirmation.textContent).toContain(
|
expect(confirmation.textContent).toContain(
|
||||||
`Disable ${TARGET_EMAIL}? They will be signed out everywhere.`,
|
`Disable ${TARGET_EMAIL}? They will be signed out everywhere.`,
|
||||||
);
|
);
|
||||||
@@ -149,7 +150,7 @@ describe("UsersTable confirmation presentation", () => {
|
|||||||
expect(screen.queryByTestId("users-table")).toBeNull();
|
expect(screen.queryByTestId("users-table")).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(within(targetRow()).getByTestId(ROLE_TOGGLE_TEST_ID));
|
fireEvent.click(within(targetRow()).getByTestId(ROLE_TOGGLE_TEST_ID));
|
||||||
expect(await screen.findByTestId(POPOVER_TEST_ID)).toBeTruthy();
|
expect((await screen.findByRole("dialog")).getAttribute("data-slot")).toBe("drawer-content");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { IconMailForward, IconUserPlus, IconUsers } from "@tabler/icons-react";
|
import { IconMailForward, IconUserPlus, IconUsers } from "@tabler/icons-react";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import {
|
||||||
|
MobileActionConfirmation,
|
||||||
|
useConfirmationBoundary,
|
||||||
|
} from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { useToast } from "@/components/toast-provider";
|
import { useToast } from "@/components/toast-provider";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { ApiError } from "@/lib/api/client";
|
import { ApiError } from "@/lib/api/client";
|
||||||
@@ -171,8 +175,10 @@ function UserActionRegion({
|
|||||||
const statusAnchorRef = useRef<HTMLButtonElement>(null);
|
const statusAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const action = pending?.user.id === user.id ? pending : null;
|
const action = pending?.user.id === user.id ? pending : null;
|
||||||
const anchorRef = action?.next.role !== undefined ? roleAnchorRef : statusAnchorRef;
|
const anchorRef = action?.next.role !== undefined ? roleAnchorRef : statusAnchorRef;
|
||||||
|
const targetKey = `${user.id}:${JSON.stringify(action?.next)}`;
|
||||||
|
const { isMobile } = useConfirmationBoundary(!!action, targetKey, () => onCancel());
|
||||||
|
|
||||||
if (!isFinePointer && action) {
|
if (!isMobile && !isFinePointer && action) {
|
||||||
return (
|
return (
|
||||||
<InlineConfirmActions
|
<InlineConfirmActions
|
||||||
density="touch"
|
density="touch"
|
||||||
@@ -195,14 +201,14 @@ function UserActionRegion({
|
|||||||
<UserActionButtons
|
<UserActionButtons
|
||||||
user={user}
|
user={user}
|
||||||
isLastActiveAdmin={isLastActiveAdmin}
|
isLastActiveAdmin={isLastActiveAdmin}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={!isMobile && isFinePointer}
|
||||||
roleAnchorRef={roleAnchorRef}
|
roleAnchorRef={roleAnchorRef}
|
||||||
statusAnchorRef={statusAnchorRef}
|
statusAnchorRef={statusAnchorRef}
|
||||||
onToggleRole={onToggleRole}
|
onToggleRole={onToggleRole}
|
||||||
onToggleStatus={onToggleStatus}
|
onToggleStatus={onToggleStatus}
|
||||||
isMutating={isMutating}
|
isMutating={isMutating}
|
||||||
/>
|
/>
|
||||||
{isFinePointer && action ? (
|
{(isMobile || isFinePointer) && action ? (
|
||||||
<UserConfirmPopover
|
<UserConfirmPopover
|
||||||
action={action}
|
action={action}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
@@ -285,19 +291,32 @@ function UserConfirmPopover({
|
|||||||
onConfirm: (action: PendingAction) => void;
|
onConfirm: (action: PendingAction) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const actions = {
|
||||||
|
title: action.label,
|
||||||
|
description: t("system:usersTakesEffectImmediately", { email: action.user.email }),
|
||||||
|
cancelLabel: t("common:cancel"),
|
||||||
|
confirmLabel: t("system:usersConfirm"),
|
||||||
|
confirmAriaLabel: action.label,
|
||||||
|
confirmTestId: "users-table-confirm",
|
||||||
|
onOpenChange: (open: boolean) => {
|
||||||
|
if (!open) onCancel();
|
||||||
|
},
|
||||||
|
onConfirm: () => onConfirm(action),
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<ActionConfirmPopover
|
<MobileActionConfirmation
|
||||||
|
{...actions}
|
||||||
open
|
open
|
||||||
anchorRef={anchorRef}
|
targetKey={`${action.user.id}:${JSON.stringify(action.next)}`}
|
||||||
title={action.label}
|
focusReturnRef={anchorRef}
|
||||||
description={t("system:usersTakesEffectImmediately", { email: action.user.email })}
|
fallback={
|
||||||
cancelLabel={t("common:cancel")}
|
<ActionConfirmPopover
|
||||||
confirmLabel={t("system:usersConfirm")}
|
{...actions}
|
||||||
confirmAriaLabel={action.label}
|
open
|
||||||
confirmTestId="users-table-confirm"
|
anchorRef={anchorRef}
|
||||||
testId="users-table-confirm-popover"
|
testId="users-table-confirm-popover"
|
||||||
onOpenChange={(open) => !open && onCancel()}
|
/>
|
||||||
onConfirm={() => onConfirm(action)}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, act } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
WorkflowSyncController,
|
WorkflowSyncController,
|
||||||
@@ -7,7 +7,10 @@ import type {
|
|||||||
import type { WorkflowSyncConfig } from "@/lib/types/workflow-sync";
|
import type { WorkflowSyncConfig } from "@/lib/types/workflow-sync";
|
||||||
import { WorkflowSyncDialog } from "./workflow-sync-dialog";
|
import { WorkflowSyncDialog } from "./workflow-sync-dialog";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
const WORKFLOWS_DIR = ".kandev/workflows";
|
const WORKFLOWS_DIR = ".kandev/workflows";
|
||||||
const REMOVE_TEST_ID = "workflow-sync-remove";
|
const REMOVE_TEST_ID = "workflow-sync-remove";
|
||||||
@@ -77,7 +80,82 @@ function controller(overrides: Partial<WorkflowSyncController> = {}): WorkflowSy
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WorkflowSyncDialog removal confirmation", () => {
|
describe("WorkflowSyncDialog phone removal confirmation", () => {
|
||||||
|
it("does not dismiss a replacement dialog when an unmounted removal completes", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
let finish!: (result: boolean) => void;
|
||||||
|
const sync = controller({
|
||||||
|
handleDelete: vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<WorkflowSyncDialog key="original" open onOpenChange={onOpenChange} sync={sync} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId(REMOVE_TEST_ID));
|
||||||
|
fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID));
|
||||||
|
await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1));
|
||||||
|
rerender(
|
||||||
|
<WorkflowSyncDialog key="replacement" open onOpenChange={onOpenChange} sync={controller()} />,
|
||||||
|
);
|
||||||
|
await act(async () => finish(true));
|
||||||
|
expect(onOpenChange).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId(REMOVE_TEST_ID)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses one phone dialog, preserves its draft, and retries a failed removal", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
let finish!: (result: boolean) => void;
|
||||||
|
const sync = controller({
|
||||||
|
handleDelete: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(true),
|
||||||
|
});
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
render(<WorkflowSyncDialog open onOpenChange={onOpenChange} sync={sync} />);
|
||||||
|
const dialog = screen.getByTestId("workflow-sync-dialog");
|
||||||
|
const originalId = dialog.id;
|
||||||
|
const input = screen.getByTestId("workflow-sync-branch-input");
|
||||||
|
const trigger = screen.getByTestId(REMOVE_TEST_ID);
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole("dialog")).toHaveLength(1);
|
||||||
|
expect(dialog.id).toBe(originalId);
|
||||||
|
expect(input.closest("[inert]")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(screen.getByTestId("workflow-sync-branch-input")).toBe(input);
|
||||||
|
expect(sync.handleDelete).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const confirm = screen.getByTestId(REMOVE_CONFIRM_TEST_ID);
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1));
|
||||||
|
expect((confirm as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
await act(async () => finish(false));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect((screen.getByTestId(REMOVE_CONFIRM_TEST_ID) as HTMLButtonElement).disabled).toBe(
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(onOpenChange).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID));
|
||||||
|
await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(2));
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkflowSyncDialog wider removal confirmation", () => {
|
||||||
it("keeps removal inline in the existing dialog without a second overlay", () => {
|
it("keeps removal inline in the existing dialog without a second overlay", () => {
|
||||||
const sync = controller();
|
const sync = controller();
|
||||||
render(<WorkflowSyncDialog open onOpenChange={vi.fn()} sync={sync} />);
|
render(<WorkflowSyncDialog open onOpenChange={vi.fn()} sync={sync} />);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,6 +29,12 @@ import type {
|
|||||||
import type { WorkflowSyncProvider } from "@/lib/types/workflow-sync";
|
import type { WorkflowSyncProvider } from "@/lib/types/workflow-sync";
|
||||||
import { RemoteRepoProviderTabs } from "@/components/task-create-dialog-remote-repo-provider-tabs";
|
import { RemoteRepoProviderTabs } from "@/components/task-create-dialog-remote-repo-provider-tabs";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
import {
|
||||||
|
MobileConfirmationHost,
|
||||||
|
MobileConfirmationHostBody,
|
||||||
|
} from "@/components/confirmation/mobile-confirmation-host";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import type { RemoteRepositoryProvider } from "@/hooks/domains/integrations/use-remote-repositories";
|
import type { RemoteRepositoryProvider } from "@/hooks/domains/integrations/use-remote-repositories";
|
||||||
|
|
||||||
const WORKFLOW_EXPORT_FORMAT = "kandev_workflow";
|
const WORKFLOW_EXPORT_FORMAT = "kandev_workflow";
|
||||||
@@ -230,6 +243,8 @@ function isSaveDisabled(sync: WorkflowSyncController, intervalInvalid: boolean):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WorkflowSyncRemovalActionsProps = {
|
type WorkflowSyncRemovalActionsProps = {
|
||||||
|
targetKey: string;
|
||||||
|
subject: string;
|
||||||
hasConfig: boolean;
|
hasConfig: boolean;
|
||||||
confirming: boolean;
|
confirming: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
@@ -239,6 +254,8 @@ type WorkflowSyncRemovalActionsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function WorkflowSyncRemovalActions({
|
function WorkflowSyncRemovalActions({
|
||||||
|
targetKey,
|
||||||
|
subject,
|
||||||
hasConfig,
|
hasConfig,
|
||||||
confirming,
|
confirming,
|
||||||
saving,
|
saving,
|
||||||
@@ -247,37 +264,61 @@ function WorkflowSyncRemovalActions({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}: WorkflowSyncRemovalActionsProps) {
|
}: WorkflowSyncRemovalActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||||
if (!hasConfig) return null;
|
if (!hasConfig) return null;
|
||||||
|
|
||||||
const removeLabel = t("workflows:remove");
|
const removeLabel = t("workflows:remove");
|
||||||
if (confirming) {
|
const fallback = (
|
||||||
return (
|
<InlineConfirmActions
|
||||||
<InlineConfirmActions
|
density="touch"
|
||||||
density="touch"
|
testId={REMOVE_CONFIRMATION_TEST_ID}
|
||||||
testId={REMOVE_CONFIRMATION_TEST_ID}
|
ariaLabel={removeLabel}
|
||||||
ariaLabel={removeLabel}
|
description={t("workflows:removeSyncConfirm")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
confirmLabel={removeLabel}
|
||||||
|
confirmAriaLabel={removeLabel}
|
||||||
|
confirmTestId={REMOVE_CONFIRM_TEST_ID}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(isMobile || !confirming) && (
|
||||||
|
<Button
|
||||||
|
ref={anchorRef}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onRequest}
|
||||||
|
disabled={saving}
|
||||||
|
className="sm:mr-auto cursor-pointer"
|
||||||
|
data-testid={REMOVE_TEST_ID}
|
||||||
|
>
|
||||||
|
{removeLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={confirming}
|
||||||
|
targetKey={targetKey}
|
||||||
|
title={removeLabel}
|
||||||
|
subject={subject}
|
||||||
description={t("workflows:removeSyncConfirm")}
|
description={t("workflows:removeSyncConfirm")}
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
confirmLabel={removeLabel}
|
confirmLabel={removeLabel}
|
||||||
confirmAriaLabel={removeLabel}
|
confirmAriaLabel={removeLabel}
|
||||||
confirmTestId={REMOVE_CONFIRM_TEST_ID}
|
confirmTestId={REMOVE_CONFIRM_TEST_ID}
|
||||||
onCancel={onCancel}
|
testId={REMOVE_CONFIRMATION_TEST_ID}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onCancel();
|
||||||
|
}}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
|
completionPolicy="await-with-retry"
|
||||||
|
focusReturnRef={anchorRef}
|
||||||
|
fallback={fallback}
|
||||||
/>
|
/>
|
||||||
);
|
</>
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={onRequest}
|
|
||||||
disabled={saving}
|
|
||||||
className="sm:mr-auto cursor-pointer"
|
|
||||||
data-testid={REMOVE_TEST_ID}
|
|
||||||
>
|
|
||||||
{removeLabel}
|
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +357,36 @@ type WorkflowSyncDialogProps = {
|
|||||||
sync: WorkflowSyncController;
|
sync: WorkflowSyncController;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function WorkflowSyncFields({ sync }: { sync: WorkflowSyncController }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const switchingProvider = sync.config && sync.config.provider !== sync.form.provider;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ProviderField provider={sync.form.provider} onChange={sync.setProvider} />
|
||||||
|
{switchingProvider && (
|
||||||
|
<p className="text-xs text-destructive" data-testid="workflow-sync-provider-switch-warning">
|
||||||
|
{t("workflows:switchProviderWarning")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<RepoUrlField
|
||||||
|
provider={sync.form.provider}
|
||||||
|
url={sync.url}
|
||||||
|
invalid={sync.urlInvalid}
|
||||||
|
resolved={resolvedTarget(t, sync.form)}
|
||||||
|
onChange={sync.setUrlInput}
|
||||||
|
/>
|
||||||
|
<BranchDirectoryFields form={sync.form} update={sync.update} />
|
||||||
|
<PollFields form={sync.form} update={sync.update} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("workflows:syncDirectoryHelp", {
|
||||||
|
format: WORKFLOW_EXPORT_FORMAT,
|
||||||
|
extensions: WORKFLOW_EXPORT_EXTENSIONS,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// WorkflowSyncDialog holds the workflow sync configuration form. It closes
|
// WorkflowSyncDialog holds the workflow sync configuration form. It closes
|
||||||
// itself after a successful save or removal; failures keep it open with the
|
// itself after a successful save or removal; failures keep it open with the
|
||||||
// error surfaced via toast.
|
// error surfaced via toast.
|
||||||
@@ -323,12 +394,18 @@ export function WorkflowSyncDialog({ open, onOpenChange, sync }: WorkflowSyncDia
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [removeConfirming, setRemoveConfirming] = useState(false);
|
const [removeConfirming, setRemoveConfirming] = useState(false);
|
||||||
const hasConfig = !!sync.config;
|
const hasConfig = !!sync.config;
|
||||||
const switchingProvider = hasConfig && sync.config?.provider !== sync.form.provider;
|
|
||||||
const intervalInvalid =
|
const intervalInvalid =
|
||||||
sync.form.poll_enabled &&
|
sync.form.poll_enabled &&
|
||||||
(!Number.isInteger(sync.form.interval_seconds) || sync.form.interval_seconds < 60);
|
(!Number.isInteger(sync.form.interval_seconds) || sync.form.interval_seconds < 60);
|
||||||
const disableSave = isSaveDisabled(sync, intervalInvalid);
|
const disableSave = isSaveDisabled(sync, intervalInvalid);
|
||||||
const resolved = resolvedTarget(t, sync.form);
|
const targetKey = workflowSyncConfirmationTarget(sync);
|
||||||
|
const generation = useRef(0);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
generation.current += 1;
|
||||||
|
return () => {
|
||||||
|
generation.current += 1;
|
||||||
|
};
|
||||||
|
}, [open, targetKey]);
|
||||||
|
|
||||||
useClearWorkflowSyncRemovalConfirmation(open, sync, setRemoveConfirming);
|
useClearWorkflowSyncRemovalConfirmation(open, sync, setRemoveConfirming);
|
||||||
|
|
||||||
@@ -336,13 +413,14 @@ export function WorkflowSyncDialog({ open, onOpenChange, sync }: WorkflowSyncDia
|
|||||||
if (await sync.handleSave()) onOpenChange(false);
|
if (await sync.handleSave()) onOpenChange(false);
|
||||||
};
|
};
|
||||||
const handleRemove = async () => {
|
const handleRemove = async () => {
|
||||||
if (await sync.handleDelete()) {
|
const requestGeneration = generation.current;
|
||||||
|
const removed = await sync.handleDelete();
|
||||||
|
if (generation.current !== requestGeneration) return;
|
||||||
|
if (removed) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// InlineConfirmActions restores its own shell when this callback rejects.
|
// The confirmation owns retry state; the controller owns failure feedback.
|
||||||
// Keep parent confirmation state mounted so the existing failure toast and
|
|
||||||
// retry path remain inside this dialog.
|
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
};
|
};
|
||||||
const handleDialogOpenChange = (nextOpen: boolean) => {
|
const handleDialogOpenChange = (nextOpen: boolean) => {
|
||||||
@@ -351,71 +429,72 @@ export function WorkflowSyncDialog({ open, onOpenChange, sync }: WorkflowSyncDia
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
<MobileConfirmationHost open={open}>
|
||||||
<DialogContent
|
{({ active, contentProps }) => (
|
||||||
className="sm:max-w-lg"
|
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
||||||
data-testid="workflow-sync-dialog"
|
<DialogContent
|
||||||
onEscapeKeyDown={(event) => {
|
className="sm:max-w-lg max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden"
|
||||||
if (!removeConfirming) return;
|
data-testid="workflow-sync-dialog"
|
||||||
event.preventDefault();
|
enterConfirms={!removeConfirming}
|
||||||
setRemoveConfirming(false);
|
showCloseButton={!active}
|
||||||
}}
|
onEscapeKeyDown={(event) => {
|
||||||
>
|
if (!removeConfirming) return;
|
||||||
<DialogHeader>
|
event.preventDefault();
|
||||||
<DialogTitle>{t("workflows:syncTitle")}</DialogTitle>
|
setRemoveConfirming(false);
|
||||||
<DialogDescription>{t("workflows:syncDescription")}</DialogDescription>
|
}}
|
||||||
</DialogHeader>
|
{...contentProps}
|
||||||
<div className="space-y-4">
|
|
||||||
<ProviderField provider={sync.form.provider} onChange={sync.setProvider} />
|
|
||||||
{switchingProvider && (
|
|
||||||
<p
|
|
||||||
className="text-xs text-destructive"
|
|
||||||
data-testid="workflow-sync-provider-switch-warning"
|
|
||||||
>
|
|
||||||
{t("workflows:switchProviderWarning")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<RepoUrlField
|
|
||||||
provider={sync.form.provider}
|
|
||||||
url={sync.url}
|
|
||||||
invalid={sync.urlInvalid}
|
|
||||||
resolved={resolved}
|
|
||||||
onChange={sync.setUrlInput}
|
|
||||||
/>
|
|
||||||
<BranchDirectoryFields form={sync.form} update={sync.update} />
|
|
||||||
<PollFields form={sync.form} update={sync.update} />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("workflows:syncDirectoryHelp", {
|
|
||||||
format: WORKFLOW_EXPORT_FORMAT,
|
|
||||||
extensions: WORKFLOW_EXPORT_EXTENSIONS,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<WorkflowSyncRemovalActions
|
|
||||||
hasConfig={hasConfig}
|
|
||||||
confirming={removeConfirming}
|
|
||||||
saving={sync.saving}
|
|
||||||
onRequest={() => setRemoveConfirming(true)}
|
|
||||||
onCancel={() => setRemoveConfirming(false)}
|
|
||||||
onConfirm={handleRemove}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={disableSave || removeConfirming}
|
|
||||||
className="cursor-pointer"
|
|
||||||
data-testid="workflow-sync-save"
|
|
||||||
data-dialog-default-action
|
|
||||||
>
|
>
|
||||||
{saveLabel(t, sync.saving, hasConfig)}
|
<MobileConfirmationHostBody>
|
||||||
</Button>
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
|
||||||
</DialogFooter>
|
<DialogHeader>
|
||||||
</DialogContent>
|
<DialogTitle>{t("workflows:syncTitle")}</DialogTitle>
|
||||||
</Dialog>
|
<DialogDescription>{t("workflows:syncDescription")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<WorkflowSyncFields sync={sync} />
|
||||||
|
<DialogFooter>
|
||||||
|
<WorkflowSyncRemovalActions
|
||||||
|
targetKey={targetKey}
|
||||||
|
subject={resolvedTarget(t, sync.config ?? sync.form)}
|
||||||
|
hasConfig={hasConfig}
|
||||||
|
confirming={removeConfirming}
|
||||||
|
saving={sync.saving}
|
||||||
|
onRequest={() => setRemoveConfirming(true)}
|
||||||
|
onCancel={() => setRemoveConfirming(false)}
|
||||||
|
onConfirm={handleRemove}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={disableSave || removeConfirming}
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid="workflow-sync-save"
|
||||||
|
data-dialog-default-action
|
||||||
|
>
|
||||||
|
{saveLabel(t, sync.saving, hasConfig)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</MobileConfirmationHostBody>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowSyncConfirmationTarget(sync: WorkflowSyncController) {
|
||||||
|
const fields = (config: WorkflowSyncFormState | null) =>
|
||||||
|
config && [
|
||||||
|
config.provider,
|
||||||
|
config.repo_owner,
|
||||||
|
config.repo_name,
|
||||||
|
config.project_path,
|
||||||
|
config.branch,
|
||||||
|
config.path,
|
||||||
|
];
|
||||||
|
return JSON.stringify([sync.config?.workspace_id, fields(sync.config), fields(sync.form)]);
|
||||||
|
}
|
||||||
|
|
||||||
function saveLabel(t: TFunction, saving: boolean, hasConfig: boolean): string {
|
function saveLabel(t: TFunction, saving: boolean, hasConfig: boolean): string {
|
||||||
if (saving) return t("workflows:saving");
|
if (saving) return t("workflows:saving");
|
||||||
return hasConfig ? t("workflows:update") : t("workflows:save");
|
return hasConfig ? t("workflows:update") : t("workflows:save");
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ export function WorkflowSyncSection({
|
|||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<WorkflowSyncDialog open={dialogOpen} onOpenChange={onDialogOpenChange} sync={sync} />
|
<WorkflowSyncDialog
|
||||||
|
key={workspaceId}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={onDialogOpenChange}
|
||||||
|
sync={sync}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { IconAt } from "@tabler/icons-react";
|
import { IconAt } from "@tabler/icons-react";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
import { ModelSelector } from "@/components/task/model-selector";
|
import { ModelSelector } from "@/components/task/model-selector";
|
||||||
@@ -195,7 +196,9 @@ function MobileLeftActions(props: MobileLeftActionsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MobileChatInputToolbar(props: MobileToolbarProps) {
|
export function MobileChatInputToolbar(props: MobileToolbarProps) {
|
||||||
const [resetConfirmationOpen, setResetConfirmationOpen] = useState(false);
|
const [resetRequested, setResetConfirmationOpen] = useState(false);
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
const resetConfirmationOpen = resetRequested && !isMobile;
|
||||||
const presentation = props.presentation ?? "mobile";
|
const presentation = props.presentation ?? "mobile";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { render, screen, fireEvent, act, cleanup } from "@testing-library/react"
|
|||||||
import { StateProvider } from "@/components/state-provider";
|
import { StateProvider } from "@/components/state-provider";
|
||||||
import type { TaskSession } from "@/lib/types/http";
|
import type { TaskSession } from "@/lib/types/http";
|
||||||
|
|
||||||
|
const MODEL_SELECTOR_TEST_ID = "mock-model-selector";
|
||||||
|
|
||||||
const responsiveMock = vi.hoisted(() => ({
|
const responsiveMock = vi.hoisted(() => ({
|
||||||
breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop",
|
breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop",
|
||||||
}));
|
}));
|
||||||
@@ -97,8 +99,19 @@ vi.mock("./implement-plan-button", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./reset-context-button", () => ({
|
vi.mock("./reset-context-button", () => ({
|
||||||
ResetContextButton: ({ presentation = "desktop" }: { presentation?: "desktop" | "mobile" }) => (
|
ResetContextButton: ({
|
||||||
<button type="button" data-testid="reset-context-button" data-reset-presentation={presentation}>
|
presentation = "desktop",
|
||||||
|
onConfirmationOpenChange,
|
||||||
|
}: {
|
||||||
|
presentation?: "desktop" | "mobile";
|
||||||
|
onConfirmationOpenChange?: (open: boolean) => void;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="reset-context-button"
|
||||||
|
data-reset-presentation={presentation}
|
||||||
|
onClick={() => onConfirmationOpenChange?.(true)}
|
||||||
|
>
|
||||||
Reset context
|
Reset context
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
@@ -208,6 +221,16 @@ function makeRunningSession(cancellationPending: boolean): TaskSession {
|
|||||||
} as TaskSession;
|
} as TaskSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("keeps phone composer controls mounted while a reset sheet is open", () => {
|
||||||
|
responsiveMock.breakpoint = "mobile";
|
||||||
|
renderFullToolbar();
|
||||||
|
const model = screen.getByTestId(MODEL_SELECTOR_TEST_ID);
|
||||||
|
const submit = screen.getByTestId(SUBMIT_MESSAGE_BUTTON_TEST_ID);
|
||||||
|
fireEvent.click(screen.getByTestId("reset-context-button"));
|
||||||
|
expect(model.isConnected).toBe(true);
|
||||||
|
expect(submit.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe("ChatInputToolbar backend cancellation state", () => {
|
describe("ChatInputToolbar backend cancellation state", () => {
|
||||||
it("renders backend-owned pending state after store hydration", () => {
|
it("renders backend-owned pending state after store hydration", () => {
|
||||||
render(
|
render(
|
||||||
@@ -477,9 +500,9 @@ describe("ChatInputToolbar responsive wrapper", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByTestId("toolbar-item-enhance")).toBeTruthy();
|
expect(screen.getByTestId("toolbar-item-enhance")).toBeTruthy();
|
||||||
expect(screen.getByTestId("mock-mode-selector").className).toContain("max-w-[46vw]");
|
expect(screen.getByTestId("mock-mode-selector").className).toContain("max-w-[46vw]");
|
||||||
expect(screen.getByTestId("mock-model-selector").className).toContain("max-w-[56vw]");
|
expect(screen.getByTestId(MODEL_SELECTOR_TEST_ID).className).toContain("max-w-[56vw]");
|
||||||
expect(screen.getByTestId("mock-model-selector").className).toContain("min-w-0");
|
expect(screen.getByTestId(MODEL_SELECTOR_TEST_ID).className).toContain("min-w-0");
|
||||||
expect(screen.getByTestId("mock-model-selector").className).toContain("overflow-hidden");
|
expect(screen.getByTestId(MODEL_SELECTOR_TEST_ID).className).toContain("overflow-hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the compact sessions control on tablet layouts", () => {
|
it("keeps the compact sessions control on tablet layouts", () => {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function renderResetButton(
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,6 +118,25 @@ describe("ResetContextButton context-window invalidation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ResetContextButton mobile presentation", () => {
|
describe("ResetContextButton mobile presentation", () => {
|
||||||
|
it("does not leave a phone tooltip floating over a hosted confirmation", () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
renderResetButton("mobile");
|
||||||
|
act(() => screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID).focus());
|
||||||
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
});
|
||||||
|
it("uses a phone sheet while keeping the reset trigger mounted", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
renderResetButton("mobile");
|
||||||
|
const trigger = screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID);
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Reset agent context?" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(trigger.isConnected).toBe(true);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(mocks.request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.request.mockResolvedValue({ success: true });
|
mocks.request.mockResolvedValue({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|||||||
import { useAppStore } from "@/components/state-provider";
|
import { useAppStore } from "@/components/state-provider";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -32,8 +34,10 @@ function ResetContextTrigger({
|
|||||||
tooltip,
|
tooltip,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: ResetContextTriggerProps) {
|
}: ResetContextTriggerProps) {
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip open={!isMobile && tooltipOpen} onOpenChange={setTooltipOpen}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
ref={actionRef}
|
ref={actionRef}
|
||||||
@@ -64,11 +68,11 @@ export function ResetContextButton({
|
|||||||
onConfirmationOpenChange,
|
onConfirmationOpenChange,
|
||||||
}: ResetContextButtonProps) {
|
}: ResetContextButtonProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [isResetting, setIsResetting] = useState(false);
|
const { isResetting, handleReset } = useContextResetRequest(sessionId);
|
||||||
const actionRef = useRef<HTMLButtonElement>(null);
|
const actionRef = useRef<HTMLButtonElement>(null);
|
||||||
const restoreFocusAfterCancelRef = useRef(false);
|
const restoreFocusAfterCancelRef = useRef(false);
|
||||||
const clearContextWindow = useAppStore((state) => state.clearContextWindow);
|
|
||||||
const resetContextLabel = t("task:resetContext");
|
const resetContextLabel = t("task:resetContext");
|
||||||
const resetContextAriaLabel = t("task:resetAgentContext");
|
const resetContextAriaLabel = t("task:resetAgentContext");
|
||||||
const resetContextTooltip = t("task:resetAgentContextClearsConversationHistory");
|
const resetContextTooltip = t("task:resetAgentContextClearsConversationHistory");
|
||||||
@@ -94,20 +98,6 @@ export function ResetContextButton({
|
|||||||
actionRef.current?.focus();
|
actionRef.current?.focus();
|
||||||
}, [confirmOpen]);
|
}, [confirmOpen]);
|
||||||
|
|
||||||
const handleReset = useCallback(async () => {
|
|
||||||
setIsResetting(true);
|
|
||||||
try {
|
|
||||||
const client = getWebSocketClient();
|
|
||||||
if (!client) return;
|
|
||||||
await client.request("session.reset_context", { session_id: sessionId }, 30000);
|
|
||||||
clearContextWindow(sessionId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to reset agent context:", error);
|
|
||||||
} finally {
|
|
||||||
setIsResetting(false);
|
|
||||||
}
|
|
||||||
}, [clearContextWindow, sessionId]);
|
|
||||||
|
|
||||||
const handleMobileCancel = () => {
|
const handleMobileCancel = () => {
|
||||||
restoreFocusAfterCancelRef.current = true;
|
restoreFocusAfterCancelRef.current = true;
|
||||||
updateConfirmationOpen(false);
|
updateConfirmationOpen(false);
|
||||||
@@ -123,42 +113,66 @@ export function ResetContextButton({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (presentation === "mobile") {
|
const confirmationProps = {
|
||||||
return confirmOpen ? (
|
description: t("task:thisWillClearTheAgentS"),
|
||||||
|
cancelLabel: t("common:cancel"),
|
||||||
|
confirmLabel: resetContextLabel,
|
||||||
|
confirmAriaLabel: resetContextLabel,
|
||||||
|
confirmTestId: "reset-context-confirm",
|
||||||
|
onConfirm: handleReset,
|
||||||
|
};
|
||||||
|
const fallback =
|
||||||
|
presentation === "mobile" ? (
|
||||||
<InlineConfirmActions
|
<InlineConfirmActions
|
||||||
|
{...confirmationProps}
|
||||||
density="touch"
|
density="touch"
|
||||||
testId="reset-context-inline-confirm"
|
testId="reset-context-inline-confirm"
|
||||||
ariaLabel={resetContextAriaLabel}
|
ariaLabel={resetContextAriaLabel}
|
||||||
description={t("task:thisWillClearTheAgentS")}
|
|
||||||
cancelLabel={t("common:cancel")}
|
|
||||||
confirmLabel={resetContextLabel}
|
|
||||||
confirmAriaLabel={resetContextLabel}
|
|
||||||
confirmTestId="reset-context-confirm"
|
|
||||||
onCancel={handleMobileCancel}
|
onCancel={handleMobileCancel}
|
||||||
onClose={() => updateConfirmationOpen(false)}
|
onClose={() => updateConfirmationOpen(false)}
|
||||||
onConfirm={handleReset}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
resetContextTrigger
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{resetContextTrigger}
|
|
||||||
<ActionConfirmPopover
|
<ActionConfirmPopover
|
||||||
|
{...confirmationProps}
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
anchorRef={actionRef}
|
anchorRef={actionRef}
|
||||||
title={resetContextAriaLabel}
|
title={resetContextAriaLabel}
|
||||||
description={t("task:thisWillClearTheAgentS")}
|
|
||||||
cancelLabel={t("common:cancel")}
|
|
||||||
confirmLabel={resetContextLabel}
|
|
||||||
confirmAriaLabel={resetContextLabel}
|
|
||||||
confirmTestId="reset-context-confirm"
|
|
||||||
testId="reset-context-confirm-popover"
|
testId="reset-context-confirm-popover"
|
||||||
onOpenChange={updateConfirmationOpen}
|
onOpenChange={updateConfirmationOpen}
|
||||||
onConfirm={handleReset}
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isMobile || presentation !== "mobile" || !confirmOpen ? resetContextTrigger : null}
|
||||||
|
<MobileActionConfirmation
|
||||||
|
{...confirmationProps}
|
||||||
|
open={confirmOpen}
|
||||||
|
targetKey={sessionId}
|
||||||
|
title={resetContextAriaLabel}
|
||||||
|
focusReturnRef={actionRef}
|
||||||
|
onOpenChange={updateConfirmationOpen}
|
||||||
|
fallback={fallback}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useContextResetRequest(sessionId: string) {
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
const clearContextWindow = useAppStore((state) => state.clearContextWindow);
|
||||||
|
const handleReset = useCallback(async () => {
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
const client = getWebSocketClient();
|
||||||
|
if (!client) return;
|
||||||
|
await client.request("session.reset_context", { session_id: sessionId }, 30000);
|
||||||
|
clearContextWindow(sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset agent context:", error);
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
}, [clearContextWindow, sessionId]);
|
||||||
|
return { isResetting, handleReset };
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FileIcon } from "@/components/ui/file-icon";
|
import { FileIcon } from "@/components/ui/file-icon";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import type { FileTreeNode } from "@/lib/types/backend";
|
import type { FileTreeNode } from "@/lib/types/backend";
|
||||||
import type { FileInfo } from "@/lib/state/store";
|
import type { FileInfo } from "@/lib/state/store";
|
||||||
import type { FileBrowserRow } from "./file-browser-hooks";
|
import type { FileBrowserRow } from "./file-browser-hooks";
|
||||||
@@ -134,11 +135,13 @@ export function FileTreeNodeTouchActions({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deleteAction = useFileDeleteAction();
|
const deleteAction = useFileDeleteAction();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
const deletePendingRef = React.useRef(false);
|
||||||
if (!showTouchActions || (!onAddToChatContext && !deleteAction)) return null;
|
if (!showTouchActions || (!onAddToChatContext && !deleteAction)) return null;
|
||||||
|
|
||||||
const stopRowInteraction = (event: React.SyntheticEvent) => event.stopPropagation();
|
const stopRowInteraction = (event: React.SyntheticEvent) => event.stopPropagation();
|
||||||
|
|
||||||
if (deleteAction?.confirming && !deleteAction.isBulk) {
|
if (deleteAction?.confirming && !deleteAction.isBulk && !isMobile) {
|
||||||
return (
|
return (
|
||||||
<InlineConfirmActions
|
<InlineConfirmActions
|
||||||
density="touch"
|
density="touch"
|
||||||
@@ -159,6 +162,7 @@ export function FileTreeNodeTouchActions({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
ref={deleteAction?.triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="file-tree-node-actions"
|
data-testid="file-tree-node-actions"
|
||||||
data-path={node.path}
|
data-path={node.path}
|
||||||
@@ -176,6 +180,12 @@ export function FileTreeNodeTouchActions({
|
|||||||
align="end"
|
align="end"
|
||||||
data-testid="file-tree-touch-menu"
|
data-testid="file-tree-touch-menu"
|
||||||
onClick={stopRowInteraction}
|
onClick={stopRowInteraction}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!deletePendingRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
deletePendingRef.current = false;
|
||||||
|
deleteAction?.onDelete();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{onAddToChatContext && (
|
{onAddToChatContext && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -191,7 +201,10 @@ export function FileTreeNodeTouchActions({
|
|||||||
data-testid="file-tree-touch-delete"
|
data-testid="file-tree-touch-delete"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="min-h-11 cursor-pointer"
|
className="min-h-11 cursor-pointer"
|
||||||
onSelect={deleteAction.onDelete}
|
onSelect={() => {
|
||||||
|
if (isMobile && !deleteAction.isBulk) deletePendingRef.current = true;
|
||||||
|
else deleteAction.onDelete();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<IconTrash className="h-3.5 w-3.5" />
|
<IconTrash className="h-3.5 w-3.5" />
|
||||||
{deleteAction.isBulk
|
{deleteAction.isBulk
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type * as FileBrowserParts from "./file-browser-parts";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const NODE_ACTIONS_TEST_ID = "file-tree-node-actions";
|
||||||
|
|
||||||
const { responsive, node } = vi.hoisted(() => ({
|
const { responsive, node } = vi.hoisted(() => ({
|
||||||
responsive: { isMobile: false, isFinePointer: true },
|
responsive: { isMobile: false, isFinePointer: true },
|
||||||
node: { name: "README.md", path: "README.md", is_dir: false, size: 0 },
|
node: { name: "README.md", path: "README.md", is_dir: false, size: 0 },
|
||||||
@@ -111,11 +113,19 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FileBrowser responsive touch action wiring", () => {
|
describe("FileBrowser responsive touch action wiring", () => {
|
||||||
|
it("releases row-owned confirmations when the file session changes", () => {
|
||||||
|
responsive.isMobile = true;
|
||||||
|
const { rerender } = render(<FileBrowser sessionId="session-1" onOpenFile={vi.fn()} />);
|
||||||
|
const trigger = screen.getByTestId(NODE_ACTIONS_TEST_ID);
|
||||||
|
rerender(<FileBrowser sessionId="session-2" onOpenFile={vi.fn()} />);
|
||||||
|
expect(trigger.isConnected).toBe(false);
|
||||||
|
expect(screen.getByTestId(NODE_ACTIONS_TEST_ID)).not.toBe(trigger);
|
||||||
|
});
|
||||||
it("renders the 44px action for a coarse-pointer desktop workbench", () => {
|
it("renders the 44px action for a coarse-pointer desktop workbench", () => {
|
||||||
responsive.isFinePointer = false;
|
responsive.isFinePointer = false;
|
||||||
renderFileBrowser();
|
renderFileBrowser();
|
||||||
|
|
||||||
const trigger = screen.getByTestId("file-tree-node-actions");
|
const trigger = screen.getByTestId(NODE_ACTIONS_TEST_ID);
|
||||||
expect(trigger.className).toContain("min-h-11");
|
expect(trigger.className).toContain("min-h-11");
|
||||||
expect(trigger.className).toContain("min-w-11");
|
expect(trigger.className).toContain("min-w-11");
|
||||||
});
|
});
|
||||||
@@ -123,13 +133,13 @@ describe("FileBrowser responsive touch action wiring", () => {
|
|||||||
it("hides the touch action only for fine-pointer desktop", () => {
|
it("hides the touch action only for fine-pointer desktop", () => {
|
||||||
renderFileBrowser();
|
renderFileBrowser();
|
||||||
|
|
||||||
expect(screen.queryByTestId("file-tree-node-actions")).toBeNull();
|
expect(screen.queryByTestId(NODE_ACTIONS_TEST_ID)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the touch action for mobile", () => {
|
it("renders the touch action for mobile", () => {
|
||||||
responsive.isMobile = true;
|
responsive.isMobile = true;
|
||||||
renderFileBrowser();
|
renderFileBrowser();
|
||||||
|
|
||||||
expect(screen.getByTestId("file-tree-node-actions")).toBeTruthy();
|
expect(screen.getByTestId(NODE_ACTIONS_TEST_ID)).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ export function FileBrowser({
|
|||||||
addSourcesDisabledReason={addSourcesDisabledReason}
|
addSourcesDisabledReason={addSourcesDisabledReason}
|
||||||
/>
|
/>
|
||||||
<FileBrowserTreeContent
|
<FileBrowserTreeContent
|
||||||
|
key={`${sessionId}:${environmentId ?? ""}`}
|
||||||
scrollAreaRef={scrollAreaRef}
|
scrollAreaRef={scrollAreaRef}
|
||||||
data={data}
|
data={data}
|
||||||
handlers={handlers}
|
handlers={handlers}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { render, screen, cleanup, fireEvent, waitFor, within } from "@testing-li
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { FileTreeNode } from "@/lib/types/backend";
|
import type { FileTreeNode } from "@/lib/types/backend";
|
||||||
|
|
||||||
const responsive = vi.hoisted(() => ({ isFinePointer: true }));
|
const DELETE_INLINE_CONFIRM_ID = "file-delete-inline-confirmation";
|
||||||
|
const DELETE_CONFIRM_ID = "file-delete-confirm";
|
||||||
|
|
||||||
|
const responsive = vi.hoisted(() => ({ isFinePointer: true, isMobile: false }));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
useResponsiveBreakpoint: () => responsive,
|
useResponsiveBreakpoint: () => responsive,
|
||||||
@@ -40,9 +43,46 @@ const BULK_TREE: FileTreeNode = {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
responsive.isFinePointer = true;
|
responsive.isFinePointer = true;
|
||||||
|
responsive.isMobile = false;
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands phone file deletion from the touch menu to a named sheet without replacing its trigger", async () => {
|
||||||
|
responsive.isMobile = true;
|
||||||
|
responsive.isFinePointer = false;
|
||||||
|
const onDeleteFile = vi.fn().mockResolvedValue(true);
|
||||||
|
render(
|
||||||
|
<FileContextMenu
|
||||||
|
node={FILE_NODE}
|
||||||
|
tree={FILE_NODE}
|
||||||
|
setTree={vi.fn()}
|
||||||
|
onDeleteFile={onDeleteFile}
|
||||||
|
onStartRename={vi.fn()}
|
||||||
|
>
|
||||||
|
<div data-testid="phone-file-row">
|
||||||
|
<span>{FILE_NODE.name}</span>
|
||||||
|
<FileTreeNodeTouchActions node={FILE_NODE} showTouchActions />
|
||||||
|
</div>
|
||||||
|
</FileContextMenu>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByTestId("file-tree-node-actions");
|
||||||
|
fireEvent.pointerDown(trigger);
|
||||||
|
fireEvent.click(screen.getByTestId("file-tree-touch-delete"));
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Delete README.md" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(within(sheet).queryByText("README.md", { selector: "p" })).toBeNull();
|
||||||
|
expect(trigger.isConnected).toBe(true);
|
||||||
|
expect(screen.queryByRole("menu")).toBeNull();
|
||||||
|
expect(screen.queryByTestId(DELETE_INLINE_CONFIRM_ID)).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(onDeleteFile).not.toHaveBeenCalled();
|
||||||
|
fireEvent.pointerDown(trigger);
|
||||||
|
fireEvent.click(screen.getByTestId("file-tree-touch-delete"));
|
||||||
|
fireEvent.click(await screen.findByTestId(DELETE_CONFIRM_ID));
|
||||||
|
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledExactlyOnceWith(FILE_NODE.path));
|
||||||
|
});
|
||||||
|
|
||||||
function openMenu(triggerTestId: string) {
|
function openMenu(triggerTestId: string) {
|
||||||
const trigger = screen.getByTestId(triggerTestId);
|
const trigger = screen.getByTestId(triggerTestId);
|
||||||
fireEvent.contextMenu(trigger);
|
fireEvent.contextMenu(trigger);
|
||||||
@@ -369,8 +409,8 @@ describe("FileContextMenu bulk deletion", () => {
|
|||||||
|
|
||||||
openMenu("single-delete-row");
|
openMenu("single-delete-row");
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||||
await waitFor(() => expect(screen.getByTestId("file-delete-confirm")).toBeTruthy());
|
await waitFor(() => expect(screen.getByTestId(DELETE_CONFIRM_ID)).toBeTruthy());
|
||||||
fireEvent.click(screen.getByTestId("file-delete-confirm"));
|
fireEvent.click(screen.getByTestId(DELETE_CONFIRM_ID));
|
||||||
|
|
||||||
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(node.path));
|
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(node.path));
|
||||||
});
|
});
|
||||||
@@ -421,17 +461,13 @@ describe("FileContextMenu touch actions", () => {
|
|||||||
fireEvent.pointerDown(screen.getByTestId("file-tree-node-actions"));
|
fireEvent.pointerDown(screen.getByTestId("file-tree-node-actions"));
|
||||||
fireEvent.click(screen.getByTestId("file-tree-touch-delete"));
|
fireEvent.click(screen.getByTestId("file-tree-touch-delete"));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByTestId("file-delete-inline-confirmation")).toBeTruthy());
|
await waitFor(() => expect(screen.getByTestId(DELETE_INLINE_CONFIRM_ID)).toBeTruthy());
|
||||||
expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull();
|
expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull();
|
||||||
expect(onDeleteFile).not.toHaveBeenCalled();
|
expect(onDeleteFile).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(screen.getByTestId(DELETE_INLINE_CONFIRM_ID).querySelectorAll("button")).toHaveLength(2);
|
||||||
screen.getByTestId("file-delete-inline-confirmation").querySelectorAll("button"),
|
|
||||||
).toHaveLength(2);
|
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(screen.getByTestId("file-delete-inline-confirmation")).getByTestId(
|
within(screen.getByTestId(DELETE_INLINE_CONFIRM_ID)).getByTestId(DELETE_CONFIRM_ID),
|
||||||
"file-delete-confirm",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(FILE_NODE.path));
|
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(FILE_NODE.path));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useToast } from "@/components/toast-provider";
|
import { useToast } from "@/components/toast-provider";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { FileTreeNode } from "@/lib/types/backend";
|
import type { FileTreeNode } from "@/lib/types/backend";
|
||||||
@@ -229,7 +230,10 @@ function FileContextMenuSurface({
|
|||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: FileContextMenuSurfaceProps) {
|
}: FileContextMenuSurfaceProps) {
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
const renamePendingRef = useRef(false);
|
const renamePendingRef = useRef(false);
|
||||||
|
const deletePendingRef = useRef(false);
|
||||||
|
const touchTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const handleStartRename = useCallback(() => {
|
const handleStartRename = useCallback(() => {
|
||||||
renamePendingRef.current = true;
|
renamePendingRef.current = true;
|
||||||
@@ -237,16 +241,24 @@ function FileContextMenuSurface({
|
|||||||
|
|
||||||
const handleCloseAutoFocus = useCallback(
|
const handleCloseAutoFocus = useCallback(
|
||||||
(event: Event) => {
|
(event: Event) => {
|
||||||
|
if (deletePendingRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
deletePendingRef.current = false;
|
||||||
|
setDeleteConfirmationOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!renamePendingRef.current) return;
|
if (!renamePendingRef.current) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
renamePendingRef.current = false;
|
renamePendingRef.current = false;
|
||||||
onStartRename();
|
onStartRename();
|
||||||
},
|
},
|
||||||
[onStartRename],
|
[onStartRename, setDeleteConfirmationOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileDeleteActionContext.Provider value={deleteAction}>
|
<FileDeleteActionContext.Provider
|
||||||
|
value={deleteAction ? { ...deleteAction, triggerRef: touchTriggerRef } : null}
|
||||||
|
>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
<ContextMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
|
<ContextMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
|
||||||
@@ -265,7 +277,10 @@ function FileContextMenuSurface({
|
|||||||
onDownloadFile={onDownloadFile}
|
onDownloadFile={onDownloadFile}
|
||||||
onUploadFilesHere={onUploadFilesHere}
|
onUploadFilesHere={onUploadFilesHere}
|
||||||
onStartRename={handleStartRename}
|
onStartRename={handleStartRename}
|
||||||
onDelete={onDelete}
|
onDelete={(event) => {
|
||||||
|
if (isMobile && !isBulk) deletePendingRef.current = true;
|
||||||
|
else onDelete(event);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@@ -274,20 +289,16 @@ function FileContextMenuSurface({
|
|||||||
<DeleteConfirmDialog selectedCount={selectedCount} onConfirm={onConfirmDelete} />
|
<DeleteConfirmDialog selectedCount={selectedCount} onConfirm={onConfirmDelete} />
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
{!isBulk && isFinePointer && deleteAction && (
|
{!isBulk && deleteAction && (
|
||||||
<ActionConfirmPopover
|
<FileDeleteConfirmation
|
||||||
|
action={deleteAction}
|
||||||
open={deleteConfirmationOpen}
|
open={deleteConfirmationOpen}
|
||||||
|
path={node.path}
|
||||||
|
isFinePointer={isFinePointer}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
focusBoundaryRef={focusBoundaryRef}
|
focusBoundaryRef={focusBoundaryRef}
|
||||||
title={deleteAction.title}
|
focusReturnRef={touchTriggerRef.current ? touchTriggerRef : anchorRef}
|
||||||
description={deleteAction.description}
|
|
||||||
cancelLabel={deleteAction.cancelLabel}
|
|
||||||
confirmLabel={deleteAction.label}
|
|
||||||
confirmAriaLabel={deleteAction.title}
|
|
||||||
confirmTestId="file-delete-confirm"
|
|
||||||
testId="file-delete-confirm-popover"
|
|
||||||
onOpenChange={setDeleteConfirmationOpen}
|
onOpenChange={setDeleteConfirmationOpen}
|
||||||
onCancel={() => setDeleteConfirmationOpen(false)}
|
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -295,6 +306,55 @@ function FileContextMenuSurface({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FileDeleteConfirmation({
|
||||||
|
action,
|
||||||
|
path,
|
||||||
|
isFinePointer,
|
||||||
|
anchorRef,
|
||||||
|
focusReturnRef,
|
||||||
|
focusBoundaryRef,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
action: FileDeleteAction;
|
||||||
|
path: string;
|
||||||
|
isFinePointer: boolean;
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
|
focusReturnRef: React.RefObject<HTMLElement | null>;
|
||||||
|
focusBoundaryRef: React.RefObject<HTMLElement | null>;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
const actions = {
|
||||||
|
...props,
|
||||||
|
title: action.title,
|
||||||
|
description: action.description,
|
||||||
|
cancelLabel: action.cancelLabel,
|
||||||
|
confirmLabel: action.label,
|
||||||
|
confirmAriaLabel: action.title,
|
||||||
|
confirmTestId: "file-delete-confirm",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<MobileActionConfirmation
|
||||||
|
{...actions}
|
||||||
|
targetKey={path}
|
||||||
|
subject={path.includes("/") ? path : undefined}
|
||||||
|
focusReturnRef={focusReturnRef}
|
||||||
|
fallback={
|
||||||
|
isFinePointer ? (
|
||||||
|
<ActionConfirmPopover
|
||||||
|
{...actions}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
focusBoundaryRef={focusBoundaryRef}
|
||||||
|
testId="file-delete-confirm-popover"
|
||||||
|
onCancel={() => props.onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Context menu for file nodes with Download, Rename, and Delete options */
|
/** Context menu for file nodes with Download, Rename, and Delete options */
|
||||||
export function FileContextMenu({
|
export function FileContextMenu({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type FileDeleteAction = {
|
|||||||
label: string;
|
label: string;
|
||||||
cancelLabel: string;
|
cancelLabel: string;
|
||||||
description: React.ReactNode;
|
description: React.ReactNode;
|
||||||
|
triggerRef?: React.RefObject<HTMLButtonElement | null>;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getBuiltInLayoutProfile } from "@/lib/layout/layout-profiles";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
finePointer: true,
|
finePointer: true,
|
||||||
|
isMobile: false,
|
||||||
savedLayouts: [] as Array<Record<string, unknown>>,
|
savedLayouts: [] as Array<Record<string, unknown>>,
|
||||||
updateUserSettings: vi.fn(),
|
updateUserSettings: vi.fn(),
|
||||||
setUserSettings: vi.fn(),
|
setUserSettings: vi.fn(),
|
||||||
@@ -31,7 +32,7 @@ vi.mock("@/lib/i18n", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer }),
|
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer, isMobile: mocks.isMobile }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/toast/sonner", () => ({
|
vi.mock("@/lib/toast/sonner", () => ({
|
||||||
@@ -143,6 +144,7 @@ function deferred<T>() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.finePointer = true;
|
mocks.finePointer = true;
|
||||||
|
mocks.isMobile = false;
|
||||||
mocks.savedLayouts = [savedLayout()];
|
mocks.savedLayouts = [savedLayout()];
|
||||||
mocks.updateUserSettings.mockReset();
|
mocks.updateUserSettings.mockReset();
|
||||||
mocks.setUserSettings.mockReset();
|
mocks.setUserSettings.mockReset();
|
||||||
@@ -166,6 +168,26 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("LayoutPresetSelector saved-layout deletion", () => {
|
describe("LayoutPresetSelector saved-layout deletion", () => {
|
||||||
|
it("hands the phone menu off to a sheet without applying the layout", async () => {
|
||||||
|
mocks.isMobile = true;
|
||||||
|
mocks.finePointer = false;
|
||||||
|
render(<LayoutPresetSelector />);
|
||||||
|
const trigger = screen.getByTestId("layout-preset-trigger");
|
||||||
|
await openSavedLayouts();
|
||||||
|
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Delete Saved layout?" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeNull();
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: "common:cancel" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
|
expect(mocks.applyCustomLayout).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.updateUserSettings).not.toHaveBeenCalled();
|
||||||
|
await openSavedLayouts();
|
||||||
|
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
|
||||||
|
fireEvent.click(await screen.findByTestId(SAVED_DELETE_CONFIRM_TEST_ID));
|
||||||
|
await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mocks.savedLayouts).toEqual([]);
|
||||||
|
});
|
||||||
it("anchors desktop confirmation to the menu action and keeps cancel local", async () => {
|
it("anchors desktop confirmation to the menu action and keeps cancel local", async () => {
|
||||||
render(<LayoutPresetSelector />);
|
render(<LayoutPresetSelector />);
|
||||||
await openSavedLayouts();
|
await openSavedLayouts();
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|||||||
import type { SavedLayout } from "@/lib/types/http";
|
import type { SavedLayout } from "@/lib/types/http";
|
||||||
import { useTaskSessions } from "@/hooks/use-task-sessions";
|
import { useTaskSessions } from "@/hooks/use-task-sessions";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
import { resolveLayoutApplySessionIds } from "./layout-preset-selector-session-ids";
|
import { resolveLayoutApplySessionIds } from "./layout-preset-selector-session-ids";
|
||||||
import { SavedLayoutDeleteConfirmation } from "./saved-layout-delete-confirmation";
|
import { SavedLayoutDeleteConfirmation } from "./saved-layout-delete-confirmation";
|
||||||
@@ -236,7 +237,7 @@ function SavedLayoutItems({
|
|||||||
<SavedLayoutLabel layout={layout} />
|
<SavedLayoutLabel layout={layout} />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="min-h-11 min-w-11 shrink-0 cursor-pointer justify-center px-2 text-destructive/60 focus:text-destructive sm:min-h-7 sm:min-w-7"
|
className="min-h-11 min-w-11 shrink-0 cursor-pointer justify-center px-2 text-destructive/60 focus:text-destructive md:min-h-7 md:min-w-7"
|
||||||
aria-label={t("task:delete2", { name: layout.name })}
|
aria-label={t("task:delete2", { name: layout.name })}
|
||||||
data-testid="layout-saved-delete"
|
data-testid="layout-saved-delete"
|
||||||
data-layout-id={layout.id}
|
data-layout-id={layout.id}
|
||||||
@@ -341,6 +342,8 @@ function useApplyBuiltInLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PresetDropdownProps = {
|
type PresetDropdownProps = {
|
||||||
|
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
onCloseAutoFocus: (event: Event) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
deleteMenuRef: RefObject<HTMLDivElement | null>;
|
deleteMenuRef: RefObject<HTMLDivElement | null>;
|
||||||
@@ -360,6 +363,8 @@ type PresetDropdownProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function PresetDropdown({
|
function PresetDropdown({
|
||||||
|
triggerRef,
|
||||||
|
onCloseAutoFocus,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
deleteMenuRef,
|
deleteMenuRef,
|
||||||
@@ -384,6 +389,7 @@ function PresetDropdown({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="cursor-pointer px-2"
|
className="cursor-pointer px-2"
|
||||||
@@ -397,6 +403,7 @@ function PresetDropdown({
|
|||||||
<TooltipContent side="bottom">{t("task:layoutPresets")}</TooltipContent>
|
<TooltipContent side="bottom">{t("task:layoutPresets")}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
onCloseAutoFocus={onCloseAutoFocus}
|
||||||
ref={deleteMenuRef}
|
ref={deleteMenuRef}
|
||||||
align="end"
|
align="end"
|
||||||
className="w-60"
|
className="w-60"
|
||||||
@@ -490,12 +497,15 @@ function usePresetDropdownVisibility(onClose: () => void) {
|
|||||||
|
|
||||||
export function LayoutPresetSelector() {
|
export function LayoutPresetSelector() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
const [deleteCandidate, setDeleteCandidate] = useState<SavedLayout | null>(null);
|
const [deleteCandidate, setDeleteCandidate] = useState<SavedLayout | null>(null);
|
||||||
const cancelDelete = useCallback(() => setDeleteCandidate(null), []);
|
const cancelDelete = useCallback(() => setDeleteCandidate(null), []);
|
||||||
|
useConfirmationBoundary(deleteCandidate !== null, deleteCandidate?.id ?? "", cancelDelete);
|
||||||
const deleteAnchorRef = useRef<HTMLElement>(null);
|
const deleteAnchorRef = useRef<HTMLElement>(null);
|
||||||
const deleteMenuRef = useRef<HTMLDivElement>(null);
|
const deleteMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const pendingTarget = useRef<SavedLayout | null>(null);
|
||||||
const resetLayout = useDockviewStore((s) => s.resetLayout);
|
const resetLayout = useDockviewStore((s) => s.resetLayout);
|
||||||
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
|
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
|
||||||
const handleApplyCustom = useApplySavedLayout();
|
const handleApplyCustom = useApplySavedLayout();
|
||||||
@@ -503,10 +513,18 @@ export function LayoutPresetSelector() {
|
|||||||
const { saveLayout, deleteLayout } = useSavedLayoutMutations();
|
const { saveLayout, deleteLayout } = useSavedLayoutMutations();
|
||||||
const { dropdownOpen, tooltipOpen, handleDropdownOpenChange, handleTooltipOpenChange } =
|
const { dropdownOpen, tooltipOpen, handleDropdownOpenChange, handleTooltipOpenChange } =
|
||||||
usePresetDropdownVisibility(cancelDelete);
|
usePresetDropdownVisibility(cancelDelete);
|
||||||
const beginDelete = useCallback((layout: SavedLayout, anchor: HTMLElement) => {
|
const beginDelete = useCallback(
|
||||||
deleteAnchorRef.current = anchor;
|
(layout: SavedLayout, anchor: HTMLElement) => {
|
||||||
setDeleteCandidate(layout);
|
if (isMobile) {
|
||||||
}, []);
|
pendingTarget.current = layout;
|
||||||
|
handleDropdownOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteAnchorRef.current = anchor;
|
||||||
|
setDeleteCandidate(layout);
|
||||||
|
},
|
||||||
|
[isMobile, handleDropdownOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
const closeDelete = useCallback(() => {
|
const closeDelete = useCallback(() => {
|
||||||
setDeleteCandidate(null);
|
setDeleteCandidate(null);
|
||||||
@@ -528,14 +546,22 @@ export function LayoutPresetSelector() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PresetDropdown
|
<PresetDropdown
|
||||||
|
triggerRef={triggerRef}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
const target = pendingTarget.current;
|
||||||
|
pendingTarget.current = null;
|
||||||
|
if (!target) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (savedLayouts.some((layout) => layout.id === target.id)) setDeleteCandidate(target);
|
||||||
|
}}
|
||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={handleDropdownOpenChange}
|
onOpenChange={handleDropdownOpenChange}
|
||||||
deleteMenuRef={deleteMenuRef}
|
deleteMenuRef={deleteMenuRef}
|
||||||
tooltipOpen={tooltipOpen}
|
tooltipOpen={!isMobile && tooltipOpen}
|
||||||
onTooltipOpenChange={handleTooltipOpenChange}
|
onTooltipOpenChange={handleTooltipOpenChange}
|
||||||
resetLayout={resetLayout}
|
resetLayout={resetLayout}
|
||||||
savedLayouts={savedLayouts}
|
savedLayouts={savedLayouts}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={isMobile || isFinePointer}
|
||||||
onApplyCustom={handleApplyCustom}
|
onApplyCustom={handleApplyCustom}
|
||||||
onApplyBuiltIn={handleApplyBuiltIn}
|
onApplyBuiltIn={handleApplyBuiltIn}
|
||||||
confirmingDeleteId={deleteCandidate?.id ?? null}
|
confirmingDeleteId={deleteCandidate?.id ?? null}
|
||||||
@@ -552,8 +578,8 @@ export function LayoutPresetSelector() {
|
|||||||
/>
|
/>
|
||||||
<SavedLayoutDeleteConfirmation
|
<SavedLayoutDeleteConfirmation
|
||||||
layout={deleteCandidate}
|
layout={deleteCandidate}
|
||||||
open={isFinePointer && deleteCandidate !== null}
|
open={(isMobile || isFinePointer) && deleteCandidate !== null}
|
||||||
anchorRef={deleteAnchorRef}
|
anchorRef={isMobile ? triggerRef : deleteAnchorRef}
|
||||||
focusBoundaryRef={deleteMenuRef}
|
focusBoundaryRef={deleteMenuRef}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) cancelDelete();
|
if (!open) cancelDelete();
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
MobileConfirmationHost,
|
||||||
|
MobileConfirmationHostBody,
|
||||||
|
} from "@/components/confirmation/mobile-confirmation-host";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -18,6 +22,8 @@ type MobilePickerSheetProps = {
|
|||||||
contentTestId?: string;
|
contentTestId?: string;
|
||||||
/** Optional trailing element rendered to the right of the title (e.g. a "+" CTA). */
|
/** Optional trailing element rendered to the right of the title (e.g. a "+" CTA). */
|
||||||
headerAction?: ReactNode;
|
headerAction?: ReactNode;
|
||||||
|
/** Replace the picker content with a confirmation step in this same drawer. */
|
||||||
|
confirmationHost?: boolean;
|
||||||
onCloseAutoFocus?: (event: Event) => void;
|
onCloseAutoFocus?: (event: Event) => void;
|
||||||
/** Fixed content above the single scrolling picker region. */
|
/** Fixed content above the single scrolling picker region. */
|
||||||
fixedContent?: ReactNode;
|
fixedContent?: ReactNode;
|
||||||
@@ -36,28 +42,45 @@ export function MobilePickerSheet({
|
|||||||
description,
|
description,
|
||||||
contentTestId,
|
contentTestId,
|
||||||
headerAction,
|
headerAction,
|
||||||
|
confirmationHost = false,
|
||||||
onCloseAutoFocus,
|
onCloseAutoFocus,
|
||||||
fixedContent,
|
fixedContent,
|
||||||
children,
|
children,
|
||||||
}: MobilePickerSheetProps) {
|
}: MobilePickerSheetProps) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<DrawerHeader className="text-left pb-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<DrawerTitle className="text-sm">{title}</DrawerTitle>
|
||||||
|
{headerAction}
|
||||||
|
</div>
|
||||||
|
{description && <DrawerDescription>{description}</DrawerDescription>}
|
||||||
|
</DrawerHeader>
|
||||||
|
{fixedContent}
|
||||||
|
<div
|
||||||
|
className="flex-1 min-h-0 max-h-[70dvh] overflow-y-auto px-2 pb-[calc(1rem+env(safe-area-inset-bottom))]"
|
||||||
|
data-testid={contentTestId}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmationHost)
|
||||||
|
return (
|
||||||
|
<MobileConfirmationHost open={open} surface="drawer">
|
||||||
|
{({ contentProps }) => (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DrawerContent onCloseAutoFocus={onCloseAutoFocus} {...contentProps}>
|
||||||
|
<MobileConfirmationHostBody>{content}</MobileConfirmationHostBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
<DrawerContent onCloseAutoFocus={onCloseAutoFocus}>
|
<DrawerContent onCloseAutoFocus={onCloseAutoFocus}>{content}</DrawerContent>
|
||||||
<DrawerHeader className="text-left pb-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<DrawerTitle className="text-sm">{title}</DrawerTitle>
|
|
||||||
{headerAction}
|
|
||||||
</div>
|
|
||||||
{description && <DrawerDescription>{description}</DrawerDescription>}
|
|
||||||
</DrawerHeader>
|
|
||||||
{fixedContent}
|
|
||||||
<div
|
|
||||||
className="flex-1 min-h-0 max-h-[70dvh] overflow-y-auto px-2 pb-[calc(1rem+env(safe-area-inset-bottom))]"
|
|
||||||
data-testid={contentTestId}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
import { SessionDeleteDescription } from "../session-delete-description";
|
||||||
|
|
||||||
|
export function MobileSessionDeleteConfirmation({
|
||||||
|
open,
|
||||||
|
targetKey,
|
||||||
|
isPrimary,
|
||||||
|
isOnlySession,
|
||||||
|
targetName,
|
||||||
|
focusReturnRef,
|
||||||
|
onCancel,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
targetKey: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
isOnlySession: boolean;
|
||||||
|
targetName: string;
|
||||||
|
focusReturnRef: RefObject<HTMLElement | null>;
|
||||||
|
onCancel: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const description = (
|
||||||
|
<SessionDeleteDescription isPrimary={isPrimary} isOnlySession={isOnlySession} />
|
||||||
|
);
|
||||||
|
const actions = {
|
||||||
|
testId: "mobile-session-delete-confirmation",
|
||||||
|
description,
|
||||||
|
cancelLabel: t("common:cancel"),
|
||||||
|
confirmLabel: t("task:delete"),
|
||||||
|
confirmAriaLabel: t("task:delete2", { name: targetName }),
|
||||||
|
confirmTestId: "mobile-session-delete-confirm",
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<MobileActionConfirmation
|
||||||
|
{...actions}
|
||||||
|
open={open}
|
||||||
|
targetKey={targetKey}
|
||||||
|
title={t("task:deleteSession")}
|
||||||
|
subject={targetName}
|
||||||
|
focusReturnRef={focusReturnRef}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) onCancel();
|
||||||
|
}}
|
||||||
|
fallback={
|
||||||
|
<InlineConfirmActions
|
||||||
|
{...actions}
|
||||||
|
density="touch"
|
||||||
|
ariaLabel={t("task:deleteSession")}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -406,6 +406,33 @@ describe("MobileSessionsPicker pending lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("MobileSessionsPicker session delete confirmation", () => {
|
describe("MobileSessionsPicker session delete confirmation", () => {
|
||||||
|
it("uses the same modal for the named delete step and restores the picker on Back", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
try {
|
||||||
|
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
|
||||||
|
fireEvent.click(screen.getByTestId(PILL_TESTID));
|
||||||
|
const picker = screen.getByRole("dialog", { name: "Sessions" });
|
||||||
|
const row = screen.getByTestId(`mobile-session-row-${SESSION_A}`);
|
||||||
|
fireEvent.pointerDown(within(row).getByRole("button", { name: SESSION_ACTIONS_LABEL }));
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||||
|
expect(await screen.findByRole("dialog", { name: "Delete session?" })).toBe(picker);
|
||||||
|
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
|
||||||
|
expect(screen.getByRole("group").textContent).toContain("Alpha");
|
||||||
|
expect(screen.queryByRole("button", { name: SESSION_ACTIONS_LABEL })).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "Sessions" })).toBe(picker);
|
||||||
|
expect(row.isConnected).toBe(true);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
within(row).getByRole("button", { name: SESSION_ACTIONS_LABEL }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(mocks.removeSession).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("morphs the target row into local touch-sized confirmation actions", () => {
|
it("morphs the target row into local touch-sized confirmation actions", () => {
|
||||||
mocks.sessions = [session(SESSION_A, "profile-a", START_TIME, { state: "COMPLETED" })];
|
mocks.sessions = [session(SESSION_A, "profile-a", START_TIME, { state: "COMPLETED" })];
|
||||||
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
|
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import { IconDotsVertical, IconPlus, IconStar } from "@tabler/icons-react";
|
import { IconDotsVertical, IconPlus, IconStar } from "@tabler/icons-react";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@kandev/ui/dropdown-menu";
|
} from "@kandev/ui/dropdown-menu";
|
||||||
import { AgentLogo } from "@/components/agent-logo";
|
import { AgentLogo } from "@/components/agent-logo";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { useAppStore } from "@/components/state-provider";
|
import { useAppStore } from "@/components/state-provider";
|
||||||
import { useTaskSessions } from "@/hooks/use-task-sessions";
|
import { useTaskSessions } from "@/hooks/use-task-sessions";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +32,7 @@ import type { ForegroundActivity, TaskSession, TaskSessionState } from "@/lib/ty
|
|||||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { SessionDeleteDescription } from "../session-delete-description";
|
import { MobileSessionDeleteConfirmation } from "./mobile-session-delete-confirmation";
|
||||||
|
|
||||||
type SessionRow = {
|
type SessionRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -144,6 +144,7 @@ function StateBadge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionActionsMenu({
|
function SessionActionsMenu({
|
||||||
|
triggerRef,
|
||||||
taskId,
|
taskId,
|
||||||
state,
|
state,
|
||||||
isPrimary,
|
isPrimary,
|
||||||
@@ -153,6 +154,7 @@ function SessionActionsMenu({
|
|||||||
onAskDelete,
|
onAskDelete,
|
||||||
onHandoffProfile,
|
onHandoffProfile,
|
||||||
}: {
|
}: {
|
||||||
|
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
state: TaskSessionState | null;
|
state: TaskSessionState | null;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
@@ -163,6 +165,8 @@ function SessionActionsMenu({
|
|||||||
onHandoffProfile: (profileId: string) => void;
|
onHandoffProfile: (profileId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
const pendingDelete = useRef(false);
|
||||||
const hasLifecycleAction =
|
const hasLifecycleAction =
|
||||||
!!state &&
|
!!state &&
|
||||||
(isSessionStoppable(state) || isSessionResumable(state) || isSessionDeletable(state));
|
(isSessionStoppable(state) || isSessionResumable(state) || isSessionDeletable(state));
|
||||||
@@ -171,16 +175,26 @@ function SessionActionsMenu({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="cursor-pointer h-7 w-7"
|
className="cursor-pointer h-11 w-11 md:h-7 md:w-7"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
aria-label={t("task:sessionActions")}
|
aria-label={t("task:sessionActions")}
|
||||||
>
|
>
|
||||||
<IconDotsVertical className="h-4 w-4" />
|
<IconDotsVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!pendingDelete.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
pendingDelete.current = false;
|
||||||
|
onAskDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={onSetPrimary}
|
onSelect={onSetPrimary}
|
||||||
@@ -202,7 +216,10 @@ function SessionActionsMenu({
|
|||||||
{state && isSessionDeletable(state) && (
|
{state && isSessionDeletable(state) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
onSelect={onAskDelete}
|
onSelect={() => {
|
||||||
|
if (isMobile) pendingDelete.current = true;
|
||||||
|
else onAskDelete();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("task:delete")}
|
{t("task:delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -214,39 +231,6 @@ function SessionActionsMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionDeleteInlineConfirmation({
|
|
||||||
isPrimary,
|
|
||||||
isOnlySession,
|
|
||||||
targetName,
|
|
||||||
onCancel,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
isPrimary: boolean;
|
|
||||||
isOnlySession: boolean;
|
|
||||||
targetName: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<InlineConfirmActions
|
|
||||||
density="touch"
|
|
||||||
testId="mobile-session-delete-confirmation"
|
|
||||||
ariaLabel={t("task:deleteSession")}
|
|
||||||
description={<SessionDeleteDescription isPrimary={isPrimary} isOnlySession={isOnlySession} />}
|
|
||||||
cancelLabel={t("common:cancel")}
|
|
||||||
confirmLabel={t("task:delete")}
|
|
||||||
confirmAriaLabel={t("task:delete2", { name: targetName })}
|
|
||||||
confirmTestId="mobile-session-delete-confirm"
|
|
||||||
onCancel={onCancel}
|
|
||||||
onClose={onClose}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionIdentity({ row }: { row: SessionRow }) {
|
function SessionIdentity({ row }: { row: SessionRow }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
@@ -282,9 +266,14 @@ function SessionRowTrailingActions({
|
|||||||
onHandoffProfile: (profileId: string) => void;
|
onHandoffProfile: (profileId: string) => void;
|
||||||
actions: ReturnType<typeof useSessionActions>;
|
actions: ReturnType<typeof useSessionActions>;
|
||||||
}) {
|
}) {
|
||||||
if (isConfirming) {
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
return (
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
<SessionDeleteInlineConfirmation
|
return (
|
||||||
|
<>
|
||||||
|
<MobileSessionDeleteConfirmation
|
||||||
|
focusReturnRef={triggerRef}
|
||||||
|
open={isConfirming}
|
||||||
|
targetKey={`${taskId}:${row.id}`}
|
||||||
isPrimary={row.isPrimary}
|
isPrimary={row.isPrimary}
|
||||||
isOnlySession={totalSessions === 1}
|
isOnlySession={totalSessions === 1}
|
||||||
targetName={row.agentLabel}
|
targetName={row.agentLabel}
|
||||||
@@ -292,19 +281,20 @@ function SessionRowTrailingActions({
|
|||||||
onClose={onCancelDelete}
|
onClose={onCancelDelete}
|
||||||
onConfirm={() => void actions.remove()}
|
onConfirm={() => void actions.remove()}
|
||||||
/>
|
/>
|
||||||
);
|
{(!isConfirming || isMobile) && (
|
||||||
}
|
<SessionActionsMenu
|
||||||
return (
|
triggerRef={triggerRef}
|
||||||
<SessionActionsMenu
|
taskId={taskId}
|
||||||
taskId={taskId}
|
state={row.state}
|
||||||
state={row.state}
|
isPrimary={row.isPrimary}
|
||||||
isPrimary={row.isPrimary}
|
onSetPrimary={() => void actions.setPrimary()}
|
||||||
onSetPrimary={() => void actions.setPrimary()}
|
onStop={() => void actions.stop()}
|
||||||
onStop={() => void actions.stop()}
|
onResume={() => void actions.resume()}
|
||||||
onResume={() => void actions.resume()}
|
onAskDelete={onAskDelete}
|
||||||
onAskDelete={onAskDelete}
|
onHandoffProfile={onHandoffProfile}
|
||||||
onHandoffProfile={onHandoffProfile}
|
/>
|
||||||
/>
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,7 +586,13 @@ export const MobileSessionsPicker = memo(function MobileSessionsPicker({
|
|||||||
data-testid="mobile-sessions-pill"
|
data-testid="mobile-sessions-pill"
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
/>
|
/>
|
||||||
<MobilePickerSheet open={open} onOpenChange={setOpen} title={t("task:sessions")}>
|
<MobilePickerSheet
|
||||||
|
key={taskId}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title={t("task:sessions")}
|
||||||
|
confirmationHost
|
||||||
|
>
|
||||||
<MobileSessionsList
|
<MobileSessionsList
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
activeSessionId={effectiveSessionId}
|
activeSessionId={effectiveSessionId}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { IconTerminal2, IconX } from "@tabler/icons-react";
|
import { IconTerminal2, IconX } from "@tabler/icons-react";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
|
||||||
import type { Terminal } from "@/hooks/domains/session/use-terminals";
|
import type { Terminal } from "@/hooks/domains/session/use-terminals";
|
||||||
import { TerminalCloseInlineConfirmation } from "../terminal-close-inline-confirmation";
|
import { TerminalCloseInlineConfirmation } from "../terminal-close-inline-confirmation";
|
||||||
@@ -31,6 +33,8 @@ export function MobileTerminalRow({
|
|||||||
onConfirmClose,
|
onConfirmClose,
|
||||||
}: MobileTerminalRowProps) {
|
}: MobileTerminalRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={`mobile-terminal-row-${terminal.id}`}
|
data-testid={`mobile-terminal-row-${terminal.id}`}
|
||||||
@@ -45,32 +49,39 @@ export function MobileTerminalRow({
|
|||||||
>
|
>
|
||||||
<IconTerminal2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
<IconTerminal2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm truncate flex-1">{terminal.label}</span>
|
<span className="text-sm truncate flex-1">{terminal.label}</span>
|
||||||
{isRunning && !isConfirming && (
|
{isRunning && (!isConfirming || isMobile) && (
|
||||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 leading-none">
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 leading-none">
|
||||||
{t("task:running")}
|
{t("task:running")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{terminal.closable &&
|
{terminal.closable && (
|
||||||
(isConfirming ? (
|
<>
|
||||||
<TerminalCloseInlineConfirmation
|
<TerminalCloseInlineConfirmation
|
||||||
|
open={isConfirming}
|
||||||
|
terminalId={terminal.id}
|
||||||
|
terminalLabel={terminal.label}
|
||||||
|
focusReturnRef={closeRef}
|
||||||
density="touch"
|
density="touch"
|
||||||
testId="mobile-terminal-close-confirmation"
|
testId="mobile-terminal-close-confirmation"
|
||||||
onCancel={onCancelClose}
|
onCancel={onCancelClose}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onConfirm={onConfirmClose}
|
onConfirm={onConfirmClose}
|
||||||
/>
|
/>
|
||||||
) : (
|
{(!isConfirming || isMobile) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
ref={closeRef}
|
||||||
size="icon-sm"
|
variant="ghost"
|
||||||
aria-label={t("task:close2", { label: terminal.label })}
|
size="icon-sm"
|
||||||
className="h-11 min-h-11 w-11 min-w-11 transition-[color,background-color,transform] duration-100 active:scale-[0.96]"
|
aria-label={t("task:close2", { label: terminal.label })}
|
||||||
onClick={() => onAskClose(terminal)}
|
className="h-11 min-h-11 w-11 min-w-11 transition-[color,background-color,transform] duration-100 active:scale-[0.96]"
|
||||||
>
|
onClick={() => onAskClose(terminal)}
|
||||||
<IconX className="h-4 w-4" aria-hidden="true" />
|
>
|
||||||
</Button>
|
<IconX className="h-4 w-4" aria-hidden="true" />
|
||||||
))}
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,13 @@ export const MobileTerminalsPicker = memo(function MobileTerminalsPicker({
|
|||||||
data-testid="mobile-terminals-pill"
|
data-testid="mobile-terminals-pill"
|
||||||
ariaLabel={t("task:activeTerminalTapToSwitch", { label })}
|
ariaLabel={t("task:activeTerminalTapToSwitch", { label })}
|
||||||
/>
|
/>
|
||||||
<MobilePickerSheet open={open} onOpenChange={setOpen} title={t("task:terminals")}>
|
<MobilePickerSheet
|
||||||
|
key={sessionId}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title={t("task:terminals")}
|
||||||
|
confirmationHost
|
||||||
|
>
|
||||||
<MobileTerminalsList sessionId={sessionId} onClose={() => setOpen(false)} />
|
<MobileTerminalsList sessionId={sessionId} onClose={() => setOpen(false)} />
|
||||||
</MobilePickerSheet>
|
</MobilePickerSheet>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type { MobileTaskListProps } from "@/components/task/mobile/session-task-
|
|||||||
import { selectTaskFromSheet } from "@/components/task/mobile/session-task-switcher-sheet-selection";
|
import { selectTaskFromSheet } from "@/components/task/mobile/session-task-switcher-sheet-selection";
|
||||||
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
|
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
|
||||||
import type { TaskSession } from "@/lib/types/http";
|
import type { TaskSession } from "@/lib/types/http";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { DrawerTitle } from "@kandev/ui/drawer";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
import { TaskSwitcherDrawer } from "./task-switcher-drawer";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
toggleSidebarGroupCollapsed: vi.fn(),
|
toggleSidebarGroupCollapsed: vi.fn(),
|
||||||
@@ -77,6 +81,45 @@ function task(id: string): TaskSwitcherItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("keeps the Tasks drawer as the sole modal during confirmation and restores its list", () => {
|
||||||
|
const previousWidth = window.innerWidth;
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
function Tasks() {
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
return (
|
||||||
|
<TaskSwitcherDrawer open onOpenChange={vi.fn()}>
|
||||||
|
<DrawerTitle>Tasks</DrawerTitle>
|
||||||
|
<input aria-label="Task filter" defaultValue="Work in progress" />
|
||||||
|
<button onClick={() => setConfirm(true)}>Archive target</button>
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={confirm}
|
||||||
|
onOpenChange={setConfirm}
|
||||||
|
targetKey="task"
|
||||||
|
title="Archive task?"
|
||||||
|
subject="Target"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmLabel="Archive"
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>
|
||||||
|
</TaskSwitcherDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const view = render(<Tasks />);
|
||||||
|
try {
|
||||||
|
const drawer = screen.getByRole("dialog", { name: "Tasks" });
|
||||||
|
const input = screen.getByRole("textbox", { name: "Task filter" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Archive target" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "Archive task?" })).toBe(drawer);
|
||||||
|
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
|
||||||
|
expect(screen.queryByRole("textbox", { name: "Task filter" })).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(screen.getByRole("textbox", { name: "Task filter" })).toBe(input);
|
||||||
|
} finally {
|
||||||
|
view.unmount();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe("MobileTaskList", () => {
|
describe("MobileTaskList", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.toggleSidebarGroupCollapsed.mockClear();
|
mocks.toggleSidebarGroupCollapsed.mockClear();
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useState, memo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconCheck, IconNetwork, IconPlus } from "@tabler/icons-react";
|
import { IconCheck, IconNetwork, IconPlus } from "@tabler/icons-react";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
|
||||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
|
import { DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
|
||||||
|
import { TaskSwitcherDrawer } from "./task-switcher-drawer";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
import { QuickChatSheetButton } from "./quick-chat-sheet-button";
|
import { QuickChatSheetButton } from "./quick-chat-sheet-button";
|
||||||
import { TaskSwitcher } from "../task-switcher";
|
import { TaskSwitcher } from "../task-switcher";
|
||||||
@@ -475,14 +476,14 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
|
|||||||
|
|
||||||
const surface =
|
const surface =
|
||||||
presentation === "drawer" ? (
|
presentation === "drawer" ? (
|
||||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
<TaskSwitcherDrawer
|
||||||
<DrawerContent
|
key={workspaceId}
|
||||||
onCloseAutoFocus={onCloseAutoFocus}
|
open={open}
|
||||||
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
onOpenChange={handleOpenChange}
|
||||||
>
|
onCloseAutoFocus={onCloseAutoFocus}
|
||||||
{surfaceContent}
|
>
|
||||||
</DrawerContent>
|
{surfaceContent}
|
||||||
</Drawer>
|
</TaskSwitcherDrawer>
|
||||||
) : (
|
) : (
|
||||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Drawer, DrawerContent } from "@kandev/ui/drawer";
|
||||||
|
import {
|
||||||
|
MobileConfirmationHost,
|
||||||
|
MobileConfirmationHostBody,
|
||||||
|
} from "@/components/confirmation/mobile-confirmation-host";
|
||||||
|
|
||||||
|
export function TaskSwitcherDrawer({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCloseAutoFocus,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCloseAutoFocus?: (event: Event) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MobileConfirmationHost open={open} surface="drawer">
|
||||||
|
{({ contentProps }) => (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DrawerContent
|
||||||
|
onCloseAutoFocus={onCloseAutoFocus}
|
||||||
|
{...contentProps}
|
||||||
|
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
||||||
|
>
|
||||||
|
<MobileConfirmationHostBody>{children}</MobileConfirmationHostBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -116,6 +116,8 @@ function ParkedTerminalRow({
|
|||||||
className="w-full px-1"
|
className="w-full px-1"
|
||||||
>
|
>
|
||||||
<TerminalCloseInlineConfirmation
|
<TerminalCloseInlineConfirmation
|
||||||
|
terminalId={terminal.id}
|
||||||
|
terminalLabel={terminal.label}
|
||||||
onCancel={onCancelDestroy}
|
onCancel={onCancelDestroy}
|
||||||
onClose={onCloseDestroy}
|
onClose={onCloseDestroy}
|
||||||
onConfirm={onConfirmDestroy}
|
onConfirm={onConfirmDestroy}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type { RefObject } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
|
import {
|
||||||
|
MobileActionConfirmation,
|
||||||
|
useConfirmationBoundary,
|
||||||
|
} from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import type { SavedLayout } from "@/lib/types/http";
|
import type { SavedLayout } from "@/lib/types/http";
|
||||||
|
|
||||||
type SavedLayoutDeleteConfirmationProps = {
|
type SavedLayoutDeleteConfirmationProps = {
|
||||||
@@ -24,6 +28,8 @@ export function SavedLayoutDeleteConfirmation({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}: SavedLayoutDeleteConfirmationProps) {
|
}: SavedLayoutDeleteConfirmationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { changed } = useConfirmationBoundary(open, layout?.id ?? "", onOpenChange);
|
||||||
|
if (changed) return null;
|
||||||
if (!layout) return null;
|
if (!layout) return null;
|
||||||
|
|
||||||
const title = t("task:deleteLayoutConfirm", { name: layout.name });
|
const title = t("task:deleteLayoutConfirm", { name: layout.name });
|
||||||
@@ -31,21 +37,31 @@ export function SavedLayoutDeleteConfirmation({
|
|||||||
? t("task:theBuiltInDefaultLayoutWill")
|
? t("task:theBuiltInDefaultLayoutWill")
|
||||||
: t("task:thisSavedLayoutWillBePermanently");
|
: t("task:thisSavedLayoutWillBePermanently");
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
cancelLabel: t("common:cancel"),
|
||||||
|
confirmLabel: t("task:delete"),
|
||||||
|
confirmAriaLabel: t("task:delete2", { name: layout.name }),
|
||||||
|
confirmTestId: "layout-saved-delete-confirm",
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm: () => onConfirm(layout.id),
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<ActionConfirmPopover
|
<MobileActionConfirmation
|
||||||
open={open}
|
{...actions}
|
||||||
anchorRef={anchorRef}
|
targetKey={layout.id}
|
||||||
focusBoundaryRef={focusBoundaryRef}
|
focusReturnRef={anchorRef}
|
||||||
title={title}
|
fallback={
|
||||||
description={description}
|
<ActionConfirmPopover
|
||||||
cancelLabel={t("common:cancel")}
|
{...actions}
|
||||||
confirmLabel={t("task:delete")}
|
anchorRef={anchorRef}
|
||||||
confirmAriaLabel={t("task:delete2", { name: layout.name })}
|
focusBoundaryRef={focusBoundaryRef}
|
||||||
confirmTestId="layout-saved-delete-confirm"
|
testId="layout-saved-delete-confirm-popover"
|
||||||
testId="layout-saved-delete-confirm-popover"
|
confirmationBoundary
|
||||||
confirmationBoundary
|
/>
|
||||||
onOpenChange={onOpenChange}
|
}
|
||||||
onConfirm={() => onConfirm(layout.id)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
|
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
|
||||||
import { SidebarFilterPopover } from "./sidebar-filter-popover";
|
import { SidebarFilterPopover } from "./sidebar-filter-popover";
|
||||||
@@ -6,6 +6,7 @@ import { SidebarFilterPopover } from "./sidebar-filter-popover";
|
|||||||
const responsive = vi.hoisted(() => ({
|
const responsive = vi.hoisted(() => ({
|
||||||
usesDesktopWorkbench: true,
|
usesDesktopWorkbench: true,
|
||||||
isFinePointer: true,
|
isFinePointer: true,
|
||||||
|
isMobile: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
@@ -66,6 +67,7 @@ afterEach(() => {
|
|||||||
state.sidebarViews.activeViewId = VIEW.id;
|
state.sidebarViews.activeViewId = VIEW.id;
|
||||||
responsive.usesDesktopWorkbench = true;
|
responsive.usesDesktopWorkbench = true;
|
||||||
responsive.isFinePointer = true;
|
responsive.isFinePointer = true;
|
||||||
|
responsive.isMobile = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SidebarFilterPopover task-row editor", () => {
|
describe("SidebarFilterPopover task-row editor", () => {
|
||||||
@@ -95,6 +97,7 @@ describe("SidebarFilterPopover task-row editor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps deletion touch-reachable in a phone drawer with a fine pointer", async () => {
|
it("keeps deletion touch-reachable in a phone drawer with a fine pointer", async () => {
|
||||||
|
responsive.isMobile = true;
|
||||||
responsive.usesDesktopWorkbench = false;
|
responsive.usesDesktopWorkbench = false;
|
||||||
responsive.isFinePointer = true;
|
responsive.isFinePointer = true;
|
||||||
state.sidebarViews.views = [VIEW, SECOND_VIEW];
|
state.sidebarViews.views = [VIEW, SECOND_VIEW];
|
||||||
@@ -107,11 +110,19 @@ describe("SidebarFilterPopover task-row editor", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const deleteButton = screen.getByTestId("view-delete-button");
|
const deleteButton = screen.getByTestId("view-delete-button");
|
||||||
|
const drawer = screen.getByTestId("sidebar-filter-drawer");
|
||||||
|
const dialogId = drawer.id;
|
||||||
expect(deleteButton.className).toContain("min-h-11");
|
expect(deleteButton.className).toContain("min-h-11");
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
expect(state.deleteSidebarView).not.toHaveBeenCalled();
|
expect(state.deleteSidebarView).not.toHaveBeenCalled();
|
||||||
expect(await screen.findByRole("group", { name: "Delete All tasks?" })).toBeTruthy();
|
expect(await screen.findByRole("group", { name: "Delete All tasks?" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("dialog", { name: "Delete All tasks?" }).id).toBe(dialogId);
|
||||||
|
expect(document.querySelectorAll('[data-slot="drawer-content"]')).toHaveLength(1);
|
||||||
|
expect(deleteButton.isConnected).toBe(true);
|
||||||
|
fireEvent.click(within(drawer).getByRole("button", { name: "Back" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(deleteButton));
|
||||||
|
expect(state.deleteSidebarView).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps view settings collapsed until the user opens them", () => {
|
it("keeps view settings collapsed until the user opens them", () => {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import {
|
|||||||
type SavedTaskViewDeleteTarget,
|
type SavedTaskViewDeleteTarget,
|
||||||
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
|
||||||
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
|
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
|
||||||
|
import {
|
||||||
|
MobileConfirmationHost,
|
||||||
|
MobileConfirmationHostBody,
|
||||||
|
} from "@/components/confirmation/mobile-confirmation-host";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
trigger: React.ReactNode;
|
trigger: React.ReactNode;
|
||||||
@@ -207,28 +211,36 @@ function MobileSidebarFilterSurface({
|
|||||||
title,
|
title,
|
||||||
}: SidebarFilterSurfaceProps & { title: string }) {
|
}: SidebarFilterSurfaceProps & { title: string }) {
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<MobileConfirmationHost open={open} surface="drawer">
|
||||||
open={open}
|
{({ contentProps }) => (
|
||||||
onOpenChange={(nextOpen) => {
|
<Drawer
|
||||||
if (!nextOpen) deletion.close();
|
open={open}
|
||||||
onOpenChange(nextOpen);
|
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>{title}</DrawerTitle>
|
|
||||||
</DrawerHeader>
|
|
||||||
<div
|
|
||||||
data-testid="sidebar-filter-popover"
|
|
||||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(1rem+env(safe-area-inset-bottom))]"
|
|
||||||
>
|
>
|
||||||
{editor}
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
</div>
|
<DrawerContent
|
||||||
</DrawerContent>
|
aria-describedby={undefined}
|
||||||
</Drawer>
|
{...contentProps}
|
||||||
|
data-testid="sidebar-filter-drawer"
|
||||||
|
className="h-[min(90dvh,48rem)] max-h-[calc(100dvh-1rem)] overflow-hidden rounded-t-xl"
|
||||||
|
>
|
||||||
|
<MobileConfirmationHostBody>
|
||||||
|
<DrawerHeader className="shrink-0 border-b px-4 pb-3 pt-5 text-left">
|
||||||
|
<DrawerTitle>{title}</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div
|
||||||
|
data-testid="sidebar-filter-popover"
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(1rem+env(safe-area-inset-bottom))]"
|
||||||
|
>
|
||||||
|
{editor}
|
||||||
|
</div>
|
||||||
|
</MobileConfirmationHostBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
</MobileConfirmationHost>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Button } from "@kandev/ui/button";
|
|||||||
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
|
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
|
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
|
||||||
type HeaderMode = "view" | "rename" | "saveAs";
|
type HeaderMode = "view" | "rename" | "saveAs";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ type HeaderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ViewHeaderRow(props: HeaderProps) {
|
export function ViewHeaderRow(props: HeaderProps) {
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [mode, setMode] = useState<HeaderMode>("view");
|
const [mode, setMode] = useState<HeaderMode>("view");
|
||||||
const [nameDraft, setNameDraft] = useState("");
|
const [nameDraft, setNameDraft] = useState("");
|
||||||
@@ -81,41 +83,44 @@ export function ViewHeaderRow(props: HeaderProps) {
|
|||||||
setEditingViewId(null);
|
setEditingViewId(null);
|
||||||
}, [activeViewId, editingViewId, mode]);
|
}, [activeViewId, editingViewId, mode]);
|
||||||
|
|
||||||
if (props.deleteConfirmation) {
|
if (props.deleteConfirmation && !isMobile) {
|
||||||
return <div className="min-w-0 p-1">{props.deleteConfirmation}</div>;
|
return <div className="min-w-0 p-1">{props.deleteConfirmation}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<>
|
||||||
<div className="flex flex-1 items-center gap-2 text-xs">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-muted-foreground">
|
<div className="flex flex-1 items-center gap-2 text-xs">
|
||||||
{mode === "saveAs" ? t("task:saveAs") : t("task:view")}
|
<span className="text-muted-foreground">
|
||||||
</span>
|
{mode === "saveAs" ? t("task:saveAs") : t("task:view")}
|
||||||
{isEditing ? (
|
</span>
|
||||||
<NameInput
|
{isEditing ? (
|
||||||
mode={mode}
|
<NameInput
|
||||||
value={nameDraft}
|
mode={mode}
|
||||||
onChange={setNameDraft}
|
value={nameDraft}
|
||||||
onSubmit={submit}
|
onChange={setNameDraft}
|
||||||
onCancel={exit}
|
onSubmit={submit}
|
||||||
/>
|
onCancel={exit}
|
||||||
) : (
|
/>
|
||||||
<NameDisplay activeView={props.activeView} hasDraft={props.hasDraft} />
|
) : (
|
||||||
)}
|
<NameDisplay activeView={props.activeView} hasDraft={props.hasDraft} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<EditingActions
|
||||||
|
mode={mode}
|
||||||
|
canSubmit={!!nameDraft.trim()}
|
||||||
|
onSubmit={submit}
|
||||||
|
onCancel={exit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ViewActions {...props} onRename={enterRename} onSaveAs={enterSaveAs} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
{isMobile ? props.deleteConfirmation : null}
|
||||||
{isEditing ? (
|
</>
|
||||||
<EditingActions
|
|
||||||
mode={mode}
|
|
||||||
canSubmit={!!nameDraft.trim()}
|
|
||||||
onSubmit={submit}
|
|
||||||
onCancel={exit}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ViewActions {...props} onRename={enterRename} onSaveAs={enterSaveAs} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export function ParentPicker({ task }: ParentPickerProps) {
|
|||||||
testId="parent-picker-trigger"
|
testId="parent-picker-trigger"
|
||||||
/>
|
/>
|
||||||
<TaskDetachConfirmationSurface
|
<TaskDetachConfirmationSurface
|
||||||
|
taskId={task.id}
|
||||||
open={detachConfirmation.open}
|
open={detachConfirmation.open}
|
||||||
anchorRef={detachAnchorRef}
|
anchorRef={detachAnchorRef}
|
||||||
taskTitle={task.title}
|
taskTitle={task.title}
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ export function TaskActionsMenuDialogs({
|
|||||||
const editBoardRow = useLastResolvedBoardRow(taskId, boardRow);
|
const editBoardRow = useLastResolvedBoardRow(taskId, boardRow);
|
||||||
if (!taskId) return null;
|
if (!taskId) return null;
|
||||||
const linkDialogTask = buildLinkDialogTask(taskId, taskTitle, boardRow);
|
const linkDialogTask = buildLinkDialogTask(taskId, taskTitle, boardRow);
|
||||||
|
const confirmationTarget = {
|
||||||
|
taskId,
|
||||||
|
taskTitle,
|
||||||
|
anchorRef: menu.triggerRef,
|
||||||
|
focusReturnRef: menu.triggerRef,
|
||||||
|
restoreFocusOnConfirm: true,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -155,12 +162,8 @@ export function TaskActionsMenuDialogs({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TaskArchiveConfirmation
|
<TaskArchiveConfirmation
|
||||||
|
{...confirmationTarget}
|
||||||
open={menu.showArchiveConfirm}
|
open={menu.showArchiveConfirm}
|
||||||
anchorRef={menu.triggerRef}
|
|
||||||
focusReturnRef={menu.triggerRef}
|
|
||||||
restoreFocusOnConfirm
|
|
||||||
taskTitle={taskTitle}
|
|
||||||
taskId={taskId}
|
|
||||||
executorType={boardRow?.primaryExecutorType ?? subjectExecutorType}
|
executorType={boardRow?.primaryExecutorType ?? subjectExecutorType}
|
||||||
isArchiving={isArchiving}
|
isArchiving={isArchiving}
|
||||||
onOpenChange={menu.setShowArchiveConfirm}
|
onOpenChange={menu.setShowArchiveConfirm}
|
||||||
@@ -177,11 +180,8 @@ export function TaskActionsMenuDialogs({
|
|||||||
focusReturnRef={menu.triggerRef}
|
focusReturnRef={menu.triggerRef}
|
||||||
/>
|
/>
|
||||||
<TaskDetachConfirmationSurface
|
<TaskDetachConfirmationSurface
|
||||||
|
{...confirmationTarget}
|
||||||
open={menu.showDetachConfirm}
|
open={menu.showDetachConfirm}
|
||||||
anchorRef={menu.triggerRef}
|
|
||||||
focusReturnRef={menu.triggerRef}
|
|
||||||
restoreFocusOnConfirm
|
|
||||||
taskTitle={taskTitle}
|
|
||||||
sharesParentWorkspace={boardRow?.workspaceMode === "inherit_parent"}
|
sharesParentWorkspace={boardRow?.workspaceMode === "inherit_parent"}
|
||||||
onOpenChange={menu.setShowDetachConfirm}
|
onOpenChange={menu.setShowDetachConfirm}
|
||||||
onConfirm={menu.handleDetachConfirm}
|
onConfirm={menu.handleDetachConfirm}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
stopDialogPropagation,
|
stopDialogPropagation,
|
||||||
} from "./task-confirm-dialog-shared";
|
} from "./task-confirm-dialog-shared";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
|
|
||||||
type TaskArchiveConfirmDialogProps = {
|
type TaskArchiveConfirmDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -49,7 +50,7 @@ type TaskArchiveConfirmDialogProps = {
|
|||||||
executorType?: string | null;
|
executorType?: string | null;
|
||||||
/** Executor types of the tasks being archived (bulk). */
|
/** Executor types of the tasks being archived (bulk). */
|
||||||
executorTypes?: Array<string | null | undefined>;
|
executorTypes?: Array<string | null | undefined>;
|
||||||
onConfirm: (opts: { cascade: boolean }) => void;
|
onConfirm: (opts: { cascade: boolean }) => void | Promise<void>;
|
||||||
confirmTestId?: string;
|
confirmTestId?: string;
|
||||||
/** Preflight result supplied by the local confirmation adapter. */
|
/** Preflight result supplied by the local confirmation adapter. */
|
||||||
subtaskClassification?: SubtaskCountResult;
|
subtaskClassification?: SubtaskCountResult;
|
||||||
@@ -110,6 +111,55 @@ function isArchiveActionDisabled(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function archiveCleanup({
|
||||||
|
isBulkOperation,
|
||||||
|
executorTypes,
|
||||||
|
executorType,
|
||||||
|
}: Pick<TaskArchiveConfirmDialogProps, "isBulkOperation" | "executorTypes" | "executorType">) {
|
||||||
|
return isBulkOperation
|
||||||
|
? getBulkCleanupSummary(executorTypes ?? [])
|
||||||
|
: getCleanupSummary(executorType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArchiveOptions({
|
||||||
|
inFlight,
|
||||||
|
bulkCount,
|
||||||
|
subtaskCount,
|
||||||
|
cascade,
|
||||||
|
setCascade,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
inFlight: boolean;
|
||||||
|
bulkCount?: number;
|
||||||
|
subtaskCount: number;
|
||||||
|
cascade: boolean;
|
||||||
|
setCascade: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{inFlight && <StillWorkingWarning count={bulkCount} />}
|
||||||
|
{subtaskCount > 0 && (
|
||||||
|
<label className="flex min-h-11 cursor-pointer items-start gap-2 text-sm md:min-h-0">
|
||||||
|
<Checkbox
|
||||||
|
checked={cascade}
|
||||||
|
onCheckedChange={(v) => setCascade(v === true)}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid="archive-cascade-checkbox"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t("task:alsoArchiveSubtasks", { count: subtaskCount })}
|
||||||
|
<span className="block text-sm text-muted-foreground">
|
||||||
|
{t("task:subtasksStayActiveUnlessYouTick")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The legacy cascade dialog intentionally keeps its state, preference bypass,
|
// The legacy cascade dialog intentionally keeps its state, preference bypass,
|
||||||
// and cleanup copy in one boundary.
|
// and cleanup copy in one boundary.
|
||||||
// eslint-disable-next-line max-lines-per-function
|
// eslint-disable-next-line max-lines-per-function
|
||||||
@@ -140,9 +190,7 @@ export function TaskArchiveConfirmDialog({
|
|||||||
const firstLine = isBulkOperation
|
const firstLine = isBulkOperation
|
||||||
? t("task:archiveTasksConfirm", { count: safeCount })
|
? t("task:archiveTasksConfirm", { count: safeCount })
|
||||||
: t("task:archiveTaskConfirm", { taskTitle });
|
: t("task:archiveTaskConfirm", { taskTitle });
|
||||||
const cleanup = isBulkOperation
|
const cleanup = archiveCleanup({ isBulkOperation, executorTypes, executorType });
|
||||||
? getBulkCleanupSummary(executorTypes ?? [])
|
|
||||||
: getCleanupSummary(executorType);
|
|
||||||
|
|
||||||
const [cascade, setCascade] = useState(false);
|
const [cascade, setCascade] = useState(false);
|
||||||
const confirmedRef = useRef(false);
|
const confirmedRef = useRef(false);
|
||||||
@@ -179,7 +227,23 @@ export function TaskArchiveConfirmDialog({
|
|||||||
|
|
||||||
if (!requiresConfirmation) return null;
|
if (!requiresConfirmation) return null;
|
||||||
|
|
||||||
return (
|
const description = (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p data-testid="task-confirmation-outcome">{firstLine}</p>
|
||||||
|
<TaskCleanupConsequences summary={cleanup} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const options = (
|
||||||
|
<ArchiveOptions
|
||||||
|
inFlight={taskIsInFlight}
|
||||||
|
bulkCount={isBulkOperation ? safeCount : undefined}
|
||||||
|
subtaskCount={subtaskCount}
|
||||||
|
cascade={cascade}
|
||||||
|
setCascade={setCascade}
|
||||||
|
disabled={isArchiving}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const desktop = (
|
||||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -196,30 +260,9 @@ export function TaskArchiveConfirmDialog({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div data-testid="task-confirmation-body" className={TASK_CONFIRM_BODY_CLASS}>
|
<div data-testid="task-confirmation-body" className={TASK_CONFIRM_BODY_CLASS}>
|
||||||
<AlertDialogDescription asChild className="text-left text-sm leading-6">
|
<AlertDialogDescription asChild className="text-left text-sm leading-6">
|
||||||
<div className="space-y-3">
|
{description}
|
||||||
<p data-testid="task-confirmation-outcome">{firstLine}</p>
|
|
||||||
<TaskCleanupConsequences summary={cleanup} />
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
{taskIsInFlight && (
|
{options}
|
||||||
<StillWorkingWarning count={isBulkOperation ? safeCount : undefined} />
|
|
||||||
)}
|
|
||||||
{subtaskCount > 0 && (
|
|
||||||
<label className="flex cursor-pointer items-start gap-2 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={cascade}
|
|
||||||
onCheckedChange={(v) => setCascade(v === true)}
|
|
||||||
disabled={isArchiving}
|
|
||||||
data-testid="archive-cascade-checkbox"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{t("task:alsoArchiveSubtasks", { count: subtaskCount })}
|
|
||||||
<span className="block text-sm text-muted-foreground">
|
|
||||||
{t("task:subtasksStayActiveUnlessYouTick")}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogFooter className={TASK_CONFIRM_FOOTER_CLASS}>
|
<AlertDialogFooter className={TASK_CONFIRM_FOOTER_CLASS}>
|
||||||
<AlertDialogCancel className={TASK_CONFIRM_ACTION_CLASS}>
|
<AlertDialogCancel className={TASK_CONFIRM_ACTION_CLASS}>
|
||||||
@@ -244,4 +287,24 @@ export function TaskArchiveConfirmDialog({
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
<MobileActionConfirmation
|
||||||
|
open={open}
|
||||||
|
targetKey={taskId ?? taskIds?.join(",") ?? "archive"}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
focusReturnRef={focusReturnRef}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
variant="default"
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
confirmLabel={t("task:archive")}
|
||||||
|
confirmTestId={confirmTestId}
|
||||||
|
confirmDisabled={archiveDisabled}
|
||||||
|
onConfirm={() => onConfirm({ cascade })}
|
||||||
|
fallback={desktop}
|
||||||
|
>
|
||||||
|
{classification.status === "loading" && <p role="status">{t("common:loading")}</p>}
|
||||||
|
{options}
|
||||||
|
</MobileActionConfirmation>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useRef, useState, type ReactNode } from "react";
|
|||||||
import { StateProvider } from "@/components/state-provider";
|
import { StateProvider } from "@/components/state-provider";
|
||||||
|
|
||||||
const getSubtaskCountMock = vi.hoisted(() => vi.fn());
|
const getSubtaskCountMock = vi.hoisted(() => vi.fn());
|
||||||
const pointerState = vi.hoisted(() => ({ isFinePointer: false }));
|
const pointerState = vi.hoisted(() => ({ isFinePointer: false, isMobile: false }));
|
||||||
const CONFIRM_TEST_ID = "archive-task-confirm";
|
const CONFIRM_TEST_ID = "archive-task-confirm";
|
||||||
const INLINE_CONFIRMATION_TEST_ID = "task-archive-inline-confirmation";
|
const INLINE_CONFIRMATION_TEST_ID = "task-archive-inline-confirmation";
|
||||||
const CLEANUP_EFFECTS_TEST_ID = "task-cleanup-effects";
|
const CLEANUP_EFFECTS_TEST_ID = "task-cleanup-effects";
|
||||||
@@ -22,10 +22,48 @@ import { TaskArchiveConfirmation } from "./task-archive-confirmation";
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
pointerState.isMobile = false;
|
||||||
pointerState.isFinePointer = false;
|
pointerState.isFinePointer = false;
|
||||||
getSubtaskCountMock.mockReset();
|
getSubtaskCountMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TaskArchiveConfirmation phone surfaces", () => {
|
||||||
|
it.each([false, true])("uses a stable phone sheet even with a fine pointer: %s", async (fine) => {
|
||||||
|
pointerState.isMobile = true;
|
||||||
|
pointerState.isFinePointer = fine;
|
||||||
|
const count = deferredSubtaskCount();
|
||||||
|
getSubtaskCountMock.mockReturnValue(count.promise);
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
renderConfirmation(onConfirm);
|
||||||
|
const sheet = screen.getByRole("dialog", { name: /Archive task/ });
|
||||||
|
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(true);
|
||||||
|
expect(screen.queryByTestId(INLINE_CONFIRMATION_TEST_ID)).toBeNull();
|
||||||
|
await act(async () => count.resolve({ count: 2 }));
|
||||||
|
expect(screen.getByRole("dialog", { name: /Archive task/ })).toBe(sheet);
|
||||||
|
const cascade = screen.getByRole("checkbox");
|
||||||
|
expect(cascade.getAttribute("data-state")).toBe("unchecked");
|
||||||
|
fireEvent.click(cascade);
|
||||||
|
fireEvent.click(screen.getByTestId(CONFIRM_TEST_ID));
|
||||||
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ cascade: true }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps forced phone archive in a bottom sheet and cancels delayed classification", async () => {
|
||||||
|
pointerState.isMobile = true;
|
||||||
|
const count = deferredSubtaskCount();
|
||||||
|
getSubtaskCountMock.mockReturnValue(count.promise);
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
renderConfirmation(onConfirm, onOpenChange, true);
|
||||||
|
expect(screen.getByRole("dialog").getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await act(async () => count.resolve({ count: 3 }));
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function ConfirmationHarness({
|
function ConfirmationHarness({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -38,17 +76,26 @@ function ConfirmationHarness({
|
|||||||
renderInline?: (content: ReactNode) => ReactNode;
|
renderInline?: (content: ReactNode) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button ref={anchorRef} type="button" data-testid="archive-anchor">
|
<button
|
||||||
|
ref={anchorRef}
|
||||||
|
type="button"
|
||||||
|
data-testid="archive-anchor"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
Archive source
|
Archive source
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-testid="outside-action">
|
<button type="button" data-testid="outside-action">
|
||||||
Outside action
|
Outside action
|
||||||
</button>
|
</button>
|
||||||
<TaskArchiveConfirmation
|
<TaskArchiveConfirmation
|
||||||
open
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
onOpenChange(next);
|
||||||
|
}}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
taskId="task-1"
|
taskId="task-1"
|
||||||
taskTitle="Task One"
|
taskTitle="Task One"
|
||||||
@@ -260,7 +307,11 @@ describe("TaskArchiveConfirmation classification", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
fireEvent.click(archive);
|
fireEvent.click(screen.getByTestId("archive-anchor"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(false),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId(CONFIRM_TEST_ID));
|
||||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ cascade: false }));
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ cascade: false }));
|
||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useAppStore } from "@/components/state-provider";
|
import { useAppStore } from "@/components/state-provider";
|
||||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
|
||||||
import { useSubtaskCountState, type SubtaskCountResult } from "@/hooks/use-subtask-count";
|
import { useSubtaskCountState, type SubtaskCountResult } from "@/hooks/use-subtask-count";
|
||||||
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
|
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
|
||||||
import { getCleanupSummary, type CleanupSummary } from "./task-cleanup-summary";
|
import { getCleanupSummary, type CleanupSummary } from "./task-cleanup-summary";
|
||||||
@@ -227,6 +227,7 @@ type ArchiveConfirmationContentProps = ArchiveDialogProps & {
|
|||||||
confirmTaskArchive: boolean;
|
confirmTaskArchive: boolean;
|
||||||
forceDialog: boolean;
|
forceDialog: boolean;
|
||||||
isFinePointer: boolean;
|
isFinePointer: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
taskIsInFlight: boolean;
|
taskIsInFlight: boolean;
|
||||||
anchorRef: RefObject<HTMLElement | null>;
|
anchorRef: RefObject<HTMLElement | null>;
|
||||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||||
@@ -239,6 +240,7 @@ function ArchiveConfirmationContent({
|
|||||||
confirmTaskArchive,
|
confirmTaskArchive,
|
||||||
forceDialog,
|
forceDialog,
|
||||||
isFinePointer,
|
isFinePointer,
|
||||||
|
isMobile,
|
||||||
taskIsInFlight,
|
taskIsInFlight,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
focusReturnRef,
|
focusReturnRef,
|
||||||
@@ -250,6 +252,7 @@ function ArchiveConfirmationContent({
|
|||||||
}: ArchiveConfirmationContentProps) {
|
}: ArchiveConfirmationContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const shouldUseDialog =
|
const shouldUseDialog =
|
||||||
|
isMobile ||
|
||||||
forceDialog ||
|
forceDialog ||
|
||||||
!confirmTaskArchive ||
|
!confirmTaskArchive ||
|
||||||
dialogProps.isBulkOperation ||
|
dialogProps.isBulkOperation ||
|
||||||
@@ -340,19 +343,24 @@ export function TaskArchiveConfirmation({
|
|||||||
renderInline,
|
renderInline,
|
||||||
forceDialog = false,
|
forceDialog = false,
|
||||||
}: TaskArchiveConfirmationProps) {
|
}: TaskArchiveConfirmationProps) {
|
||||||
const { isFinePointer } = useResponsiveBreakpoint();
|
const { isFinePointer, isMobile, changed } = useConfirmationBoundary(
|
||||||
|
open,
|
||||||
|
taskId ?? taskIds?.join(",") ?? "archive",
|
||||||
|
onOpenChange,
|
||||||
|
);
|
||||||
const confirmTaskArchive = useAppStore((state) => state.userSettings?.confirmTaskArchive ?? true);
|
const confirmTaskArchive = useAppStore((state) => state.userSettings?.confirmTaskArchive ?? true);
|
||||||
const classification = useSubtaskCountState(open && confirmTaskArchive, taskId, taskIds);
|
const classification = useSubtaskCountState(open && confirmTaskArchive, taskId, taskIds);
|
||||||
const storeInFlight = useTaskInFlight(taskId, taskIds, open && confirmTaskArchive);
|
const storeInFlight = useTaskInFlight(taskId, taskIds, open && confirmTaskArchive);
|
||||||
const taskIsInFlight = Boolean(isInFlight) || storeInFlight;
|
const taskIsInFlight = Boolean(isInFlight) || storeInFlight;
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open || changed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArchiveConfirmationContent
|
<ArchiveConfirmationContent
|
||||||
confirmTaskArchive={confirmTaskArchive}
|
confirmTaskArchive={confirmTaskArchive}
|
||||||
forceDialog={forceDialog}
|
forceDialog={forceDialog}
|
||||||
isFinePointer={isFinePointer}
|
isFinePointer={isFinePointer}
|
||||||
|
isMobile={isMobile}
|
||||||
taskIsInFlight={taskIsInFlight}
|
taskIsInFlight={taskIsInFlight}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
focusReturnRef={focusReturnRef}
|
focusReturnRef={focusReturnRef}
|
||||||
|
|||||||
@@ -6,9 +6,43 @@ import {
|
|||||||
TaskDetachConfirmPopover,
|
TaskDetachConfirmPopover,
|
||||||
TaskDetachInlineConfirmation,
|
TaskDetachInlineConfirmation,
|
||||||
TaskDetachTargetConfirmDialog,
|
TaskDetachTargetConfirmDialog,
|
||||||
|
TaskDetachConfirmationSurface,
|
||||||
} from "./task-detach-confirm-dialog";
|
} from "./task-detach-confirm-dialog";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes phone detach to a sheet with the named hierarchy-only consequence", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
function Phone() {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button ref={anchorRef}>Child task</button>
|
||||||
|
<TaskDetachConfirmationSurface
|
||||||
|
taskId="child"
|
||||||
|
open={open}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
taskTitle="Child task"
|
||||||
|
sharesParentWorkspace
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Phone />);
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Detach task from parent?" });
|
||||||
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
||||||
|
expect(sheet.textContent).toContain("Child task");
|
||||||
|
expect(sheet.textContent).toContain("shares its parent's workspace");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
function PopoverHarness({ onConfirm = vi.fn() }: { onConfirm?: () => void | Promise<void> }) {
|
function PopoverHarness({ onConfirm = vi.fn() }: { onConfirm?: () => void | Promise<void> }) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user