diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index f9ac92b65c..17a210d53f 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -56,12 +56,20 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); +const mockSidebarState = vi.hoisted(() => ({ + isMobile: false, +})); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", }), })); +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => mockSidebarState, +})); + vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi, })); @@ -156,6 +164,11 @@ vi.mock("@/components/ui/popover", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} + async function act(callback: () => void | Promise) { let result: void | Promise = undefined; flushSync(() => { @@ -433,6 +446,7 @@ describe("IssueProperties", () => { let container: HTMLDivElement; beforeEach(() => { + mockSidebarState.isMobile = false; container = document.createElement("div"); document.body.appendChild(container); mockAgentsApi.list.mockResolvedValue([]); @@ -1030,6 +1044,75 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("opens visit and remove actions when a blocked-by chip is tapped on mobile", async () => { + mockSidebarState.isMobile = true; + const onUpdate = vi.fn(); + const root = renderProperties(container, { + issue: createIssue({ + blockedBy: [ + { + id: "issue-2", + identifier: "PAP-2", + title: "Existing blocker", + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + { + id: "issue-4", + identifier: "PAP-4", + title: "Keep blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + }), + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + expect(container.querySelector('a[href="/issues/PAP-2"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Remove PAP-2 as blocker"]')).toBeNull(); + const blockerActions = container.querySelector('button[aria-label="Actions for blocker PAP-2"]'); + expect(blockerActions).not.toBeNull(); + + await act(async () => { + blockerActions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); + blockerActions!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const visitLink = Array.from(document.body.querySelectorAll('a[href="/issues/PAP-2"]')) + .find((link) => link.textContent?.includes("Visit task")); + expect(visitLink).not.toBeUndefined(); + const removeMenuItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((item) => item.textContent?.includes("Remove blocker")); + expect(removeMenuItem).not.toBeUndefined(); + + await act(async () => { + removeMenuItem!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this task."); + const confirmButton = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Remove blocker")); + expect(confirmButton).not.toBeUndefined(); + + await act(async () => { + confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] }); + + act(() => root.unmount()); + }); + it("collapses long blocked-by and sub-task lists until the more button is clicked", async () => { const blockedBy = Array.from({ length: 7 }, (_, index) => ({ id: `blocker-${index + 1}`, diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index be48124d89..17f13aac63 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -18,6 +18,7 @@ import { useIssueDocuments } from "@/hooks/useIssueDocuments"; import { selectAgentArtifactAttachments } from "@/lib/issue-artifacts"; import { projectsApi } from "../../api/projects"; import { useCompany } from "../../context/CompanyContext"; +import { useSidebar } from "../../context/SidebarContext"; import { queryKeys } from "../../lib/queryKeys"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, isAgentTaskTarget } from "../../lib/company-members"; import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../../lib/issue-assignee-overrides"; @@ -166,6 +167,7 @@ export function IssueProperties({ checkingMonitorNow = false, }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); + const { isMobile } = useSidebar(); const queryClient = useQueryClient(); const companyId = issue.companyId ?? selectedCompanyId; const { data: experimentalSettings } = useQuery({ @@ -2160,7 +2162,12 @@ export function IssueProperties({
{visibleBlockedByRelations.map((relation) => ( - + ))} {visibleBlockedByRelations.map((relation) => ( - + ))} [number]; onRemove: (issueId: string) => void; + isMobile?: boolean; }) { const [isConfirmOpen, setIsConfirmOpen] = useState(false); const issueLabel = issue.identifier ?? issue.title; @@ -37,10 +45,11 @@ export function RemovableIssueReferencePill({ ); const removeLabel = `Remove ${issueLabel} as blocker`; + const openRemoveConfirmation = () => setIsConfirmOpen(true); const handleRemove = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); - setIsConfirmOpen(true); + openRemoveConfirmation(); }; const confirmRemove = () => { onRemove(issue.id); @@ -50,34 +59,66 @@ export function RemovableIssueReferencePill({ return ( <> - - {issue.identifier ? ( - - {content} - + {isMobile ? ( + + + + + + {issue.identifier ? ( + + + + Visit task + + + ) : null} + + + Remove blocker + + + ) : ( - - {content} - + <> + + {issue.identifier ? ( + + {content} + + ) : ( + + {content} + + )} + )} diff --git a/ui/storybook/stories/issue-management.stories.tsx b/ui/storybook/stories/issue-management.stories.tsx index a3e2857235..189e88885b 100644 --- a/ui/storybook/stories/issue-management.stories.tsx +++ b/ui/storybook/stories/issue-management.stories.tsx @@ -345,6 +345,40 @@ function IssuePropertiesModelOverridePane() { ); } +function IssuePropertiesMobileBlockerActionsPane() { + const rootRef = useRef(null); + + useEffect(() => { + const openTimer = window.setTimeout(() => { + const trigger = rootRef.current?.querySelector( + 'button[aria-label^="Actions for blocker"]', + ); + if (!trigger) return; + trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); + trigger.click(); + }, 0); + return () => window.clearTimeout(openTimer); + }, []); + + return ( + +
+
+
Properties
+
+ undefined} + inline + /> +
+
+
+
+ ); +} + function ColumnConfigurationMatrix() { const [columns, setColumns] = useState(visibleColumns); const visibleColumnSet = useMemo(() => new Set(columns), [columns]); @@ -927,6 +961,12 @@ export const IssuePropertiesModelOverride: Story = { render: () => , }; +export const IssuePropertiesMobileBlockerActions: Story = { + name: "IssueProperties - mobile blocker actions open", + render: () => , + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; + function ModelProfileLedgerStandalone() { return (