From 3e86a8d5c9a773d261946cf6b0edec017d5cb3aa Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 30 Apr 2026 17:53:38 +0200 Subject: [PATCH] feat(ST-1116): mobile auto-switch tabs + back button in BacklogSplitPane Co-Authored-By: Claude Sonnet 4.6 --- .../backlog/backlog-split-pane.test.tsx | 85 +++++++++++++++++++ __tests__/components/split-pane.test.tsx | 74 ++++++++++++++++ app/(app)/products/[id]/page.tsx | 4 +- components/backlog/backlog-split-pane.tsx | 34 ++++++++ components/split-pane/split-pane.tsx | 26 +++++- 5 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 __tests__/components/backlog/backlog-split-pane.test.tsx create mode 100644 components/backlog/backlog-split-pane.tsx diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx new file mode 100644 index 0000000..f57e53f --- /dev/null +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useSelectionStore } from '@/stores/selection-store' +import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' + +const PANES = [ +
PBI pane
, +
Stories pane
, +
Tasks pane
, +] + +function renderPane() { + return render( + + ) +} + +beforeEach(() => { + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + // Force mobile viewport + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) +}) + +describe('BacklogSplitPane auto-switch', () => { + it('starts on tab 0 with no selection', () => { + renderPane() + expect(screen.getByText('PBI pane')).toBeTruthy() + expect(screen.queryByText('Stories pane')).toBeNull() + }) + + it('auto-switches to tab 1 when PBI is selected', () => { + const { rerender } = renderPane() + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null }) + rerender( + + ) + expect(screen.getByText('Stories pane')).toBeTruthy() + expect(screen.queryByText('PBI pane')).toBeNull() + }) + + it('auto-switches to tab 2 when story is selected', () => { + const { rerender } = renderPane() + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + rerender( + + ) + expect(screen.getByText('Tasks pane')).toBeTruthy() + expect(screen.queryByText('PBI pane')).toBeNull() + }) + + it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => { + // Start with story selected (tab 2) + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + const { rerender } = renderPane() + + // Cascade-reset: new PBI → story clears + useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null }) + rerender( + + ) + expect(screen.getByText('Stories pane')).toBeTruthy() + }) +}) diff --git a/__tests__/components/split-pane.test.tsx b/__tests__/components/split-pane.test.tsx index 208304a..cd166c0 100644 --- a/__tests__/components/split-pane.test.tsx +++ b/__tests__/components/split-pane.test.tsx @@ -135,6 +135,80 @@ describe('SplitPane', () => { expect(screen.getByText('Content B')).toBeTruthy() }) + it('back button not visible on tab 0 in mobile', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-back-hidden" + tabLabels={['T1', 'T2', 'T3']} + /> + ) + + // On tab 0, no back button + expect(screen.queryByLabelText('Terug')).toBeNull() + }) + + it('back button visible on tab > 0 and navigates back', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-back-nav" + tabLabels={['T1', 'T2', 'T3']} + /> + ) + + // Switch to tab 2 + fireEvent.click(screen.getByText('T3')) + expect(screen.getByText('C')).toBeTruthy() + expect(screen.getByLabelText('Terug')).toBeTruthy() + + // Click back → tab 1 + fireEvent.click(screen.getByLabelText('Terug')) + expect(screen.getByText('B')).toBeTruthy() + + // Click back again → tab 0, no back button + fireEvent.click(screen.getByLabelText('Terug')) + expect(screen.getByText('A')).toBeTruthy() + expect(screen.queryByLabelText('Terug')).toBeNull() + }) + + it('controlled activeTab prop switches the active pane', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + const { rerender } = render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-controlled" + tabLabels={['T1', 'T2', 'T3']} + activeTab={0} + onActiveTabChange={vi.fn()} + /> + ) + expect(screen.getByText('A')).toBeTruthy() + + rerender( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-controlled" + tabLabels={['T1', 'T2', 'T3']} + activeTab={2} + onActiveTabChange={vi.fn()} + /> + ) + expect(screen.getByText('C')).toBeTruthy() + }) + it('does not render dividers on mobile', () => { Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) window.dispatchEvent(new Event('resize')) diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 33484ca..647d8c6 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -4,7 +4,7 @@ import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' -import { SplitPane } from '@/components/split-pane/split-pane' +import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' @@ -130,7 +130,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props tasksByStory, }} > - + +export function BacklogSplitPane(props: Props) { + const { selectedPbiId, selectedStoryId } = useSelectionStore() + const [activeTab, setActiveTab] = useState(0) + + // React-recommended "derived state from props" pattern: update state during render + // instead of useEffect to avoid cascading renders. + const [prevPbiId, setPrevPbiId] = useState(selectedPbiId) + const [prevStoryId, setPrevStoryId] = useState(selectedStoryId) + + if (selectedStoryId !== prevStoryId) { + setPrevStoryId(selectedStoryId) + if (selectedStoryId) setActiveTab(2) + } + if (selectedPbiId !== prevPbiId) { + setPrevPbiId(selectedPbiId) + if (selectedPbiId && !selectedStoryId) setActiveTab(1) + } + + return ( + + ) +} diff --git a/components/split-pane/split-pane.tsx b/components/split-pane/split-pane.tsx index 1df7940..13dac6f 100644 --- a/components/split-pane/split-pane.tsx +++ b/components/split-pane/split-pane.tsx @@ -39,6 +39,8 @@ export interface SplitPaneProps { tabLabels?: string[] // mobile tab labels, defaults to "Pane N" minSize?: number // minimum px per pane, default 200 mobileBreakpoint?: number // default 1024 + activeTab?: number // controlled: parent manages which tab is active + onActiveTabChange?: (index: number) => void } export function SplitPane({ @@ -48,7 +50,10 @@ export function SplitPane({ tabLabels, minSize = 200, mobileBreakpoint = 1024, + activeTab: activeTabProp, + onActiveTabChange, }: SplitPaneProps) { + const isControlled = activeTabProp !== undefined const n = panes.length const containerRef = useRef(null) const splitsRef = useRef(defaultSplit) @@ -58,7 +63,13 @@ export function SplitPane({ }) const [dragging, setDragging] = useState(null) // divider index (0..n-2) const [isMobile, setIsMobile] = useState(false) - const [activeTab, setActiveTab] = useState(0) + const [internalTab, setInternalTab] = useState(0) + const activeTab = isControlled ? activeTabProp : internalTab + + const handleTabChange = (i: number) => { + if (!isControlled) setInternalTab(i) + onActiveTabChange?.(i) + } useEffect(() => { splitsRef.current = splits }, [splits]) @@ -113,11 +124,20 @@ export function SplitPane({ if (isMobile) { return (
-
+
+ {activeTab > 0 && ( + + )} {panes.map((_, i) => (