From 2fd388da08a152003a14745395fc0bbdc5472e9e Mon Sep 17 00:00:00 2001 From: Multica Eve Date: Thu, 30 Apr 2026 14:32:51 +0800 Subject: [PATCH] fix: stabilize mobile issue detail layout (#1912) Co-authored-by: Devv --- .../issues/components/issue-detail.test.tsx | 21 ++++++ .../views/issues/components/issue-detail.tsx | 68 +++++++++++-------- 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/packages/views/issues/components/issue-detail.test.tsx b/packages/views/issues/components/issue-detail.test.tsx index a5652e9dd..691356326 100644 --- a/packages/views/issues/components/issue-detail.test.tsx +++ b/packages/views/issues/components/issue-detail.test.tsx @@ -3,6 +3,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, TimelineEntry } from "@multica/core/types"; + +const mockViewport = vi.hoisted(() => ({ isMobile: false })); + +vi.mock("@multica/ui/hooks/use-mobile", () => ({ + useIsMobile: () => mockViewport.isMobile, +})); + // useWorkspaceId() derives from useCurrentWorkspace (relative import inside // @multica/core/hooks.tsx). vi.mock("@multica/core/paths") only intercepts // the bare-specifier, not the internal relative import. Mock the hooks module @@ -364,6 +371,7 @@ function renderIssueDetail(issueId = "issue-1") { describe("IssueDetail (shared)", () => { beforeEach(() => { vi.clearAllMocks(); + mockViewport.isMobile = false; // Default: issue loads successfully mockApiObj.getIssue.mockResolvedValue(mockIssue); mockApiObj.listTimeline.mockResolvedValue(mockTimeline); @@ -425,6 +433,19 @@ describe("IssueDetail (shared)", () => { expect(screen.getByText("Due date")).toBeInTheDocument(); }); + it("uses a non-resizable layout with the sidebar sheet closed by default on mobile", async () => { + mockViewport.isMobile = true; + + renderIssueDetail(); + + await waitFor(() => { + expect(screen.getByDisplayValue("Implement authentication")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("panel-group")).not.toBeInTheDocument(); + expect(screen.queryByText("Properties")).not.toBeInTheDocument(); + }); + it("renders Details section with Created by and dates", async () => { renderIssueDetail(); diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index cfc013721..da8f97380 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -177,14 +177,15 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr }); const sidebarRef = usePanelRef(); const isMobile = useIsMobile(); - const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); + const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(defaultSidebarOpen); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); useEffect(() => { if (isMobile) { - setSidebarOpen(false); - sidebarRef.current?.collapse(); + setMobileSidebarOpen(false); } }, [isMobile]); + const sidebarOpen = isMobile ? mobileSidebarOpen : desktopSidebarOpen; const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); const [parentIssueOpen, setParentIssueOpen] = useState(true); @@ -307,6 +308,18 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr const actions = useIssueActions(issue); const handleUpdateField = actions.updateField; + const handleToggleSidebar = useCallback(() => { + if (isMobile) { + setMobileSidebarOpen((open) => !open); + return; + } + + const panel = sidebarRef.current; + if (!panel) return; + if (panel.isCollapsed()) panel.expand(); + else panel.collapse(); + }, [isMobile, sidebarRef]); + if (loading) { return (
@@ -488,10 +501,8 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
); - return ( - - -
+ const detailContent = ( +
{workspace && ( @@ -572,16 +583,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr variant={sidebarOpen ? "secondary" : "ghost"} size="icon-sm" className={sidebarOpen ? "" : "text-muted-foreground"} - onClick={() => { - if (isMobile) { - setSidebarOpen(!sidebarOpen); - } else { - const panel = sidebarRef.current; - if (!panel) return; - if (panel.isCollapsed()) panel.expand(); - else panel.collapse(); - } - }} + onClick={handleToggleSidebar} > @@ -1000,9 +1002,27 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
+ ); + + if (isMobile) { + return ( +
+ {detailContent} + + + {sidebarContent} + + +
+ ); + } + + return ( + + + {detailContent} - {!isMobile && } - {!isMobile && ( + setSidebarOpen(size.inPixels > 0)} + onResize={(size) => setDesktopSidebarOpen(size.inPixels > 0)} >
@@ -1019,14 +1039,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
- )} - {isMobile && ( - - - {sidebarContent} - - - )}
); }