Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion frontend/src/v2/__tests__/V2LayoutSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
MemoryRouter, Route, Routes, useLocation,
MemoryRouter, Route, Routes, useLocation, useNavigate,
} from 'react-router-dom';
import V2Layout from '../components/V2Layout';
import type { V2Pod } from '../hooks/useV2Pods';
Expand Down Expand Up @@ -52,6 +52,31 @@ const CurrentPath = () => {
return <span data-testid="current-path">{location.pathname}</span>;
};

// A path probe that can also step back through the router's own history, which
// is the only way jsdom can tell a `replace` from a `push`.
const CurrentPathWithBack = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<span data-testid="current-path">{location.pathname}</span>
<button type="button" onClick={() => navigate(-1)}>back</button>
</>
);
};

const usePhoneViewport = () => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: jest.fn((query: string) => ({
matches: query === '(max-width: 760px)',
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})),
});
};

const hqPod: V2Pod = {
_id: 'hq',
name: 'Commonly HQ',
Expand Down Expand Up @@ -86,6 +111,21 @@ const renderAutoLayout = () => render(
</MemoryRouter>,
);

// The app's real shell: `/v2` mounts the auto selector, and the pod route mounts
// the param shell (V2App.tsx:127 / :165), which is the instance that records the
// visit. A test that only mounts the auto layout cannot see LAST_POD_KEY at all.
const renderAutoIntoParamShell = () => render(
<MemoryRouter initialEntries={['/v2']}>
<Routes>
<Route path="/v2" element={<V2Layout selectionMode="auto" />} />
<Route
path="/v2/pods/:podId"
element={<><V2Layout selectionMode="param" /><CurrentPath /></>}
/>
</Routes>
</MemoryRouter>,
);

describe('V2Layout default pod selection', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -119,6 +159,54 @@ describe('V2Layout default pod selection', () => {
expect(screen.queryByRole('button', { name: 'toggle inspector' })).not.toBeInTheDocument();
});

test('a phone first login lands in the workspace thread, and the list is where you come back to', async () => {
// TASK-144. The list is the right landing for a phone that has a pod to
// return to; a device that has never opened one has nothing to list, so the
// welcome thread is the first screen. The same redirect is what records the
// pod — which is why the back arrow lands on a list that then stays put.
usePhoneViewport();
expect(localStorage.getItem('v2:lastPodId')).toBeNull();

const first = renderAutoIntoParamShell();
await waitFor(() => {
expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/workspace');
});
// Written by the param shell that the redirect lands on — the same effect
// that runs when a returning user taps a pod.
await waitFor(() => expect(localStorage.getItem('v2:lastPodId')).toBe('workspace'));
first.unmount();

// ...and the next /v2 on this device is the list, with no second redirect.
renderAutoLayout();
await waitFor(() => {
expect(screen.getByTestId('pods-sidebar')).toHaveAttribute('data-variant', 'page');
});
expect(screen.queryByTestId('current-path')).not.toBeInTheDocument();
});

test('a phone first login replaces, so back does not bounce through the list', async () => {
usePhoneViewport();
render(
<MemoryRouter initialEntries={['/v2']}>
<Routes>
<Route path="/v2" element={<V2Layout selectionMode="auto" />} />
<Route
path="/v2/pods/:podId"
element={<><V2Layout selectionMode="param" /><CurrentPathWithBack /></>}
/>
</Routes>
</MemoryRouter>,
);

await waitFor(() => {
expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/workspace');
});
fireEvent.click(screen.getByRole('button', { name: 'back' }));
// A push would leave /v2 underneath, so one step back would re-resolve the
// landing; a replace leaves nothing to step back into.
expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/workspace');
});

test('keeps the create-pod query on /v2 until the sidebar finishes the round trip', async () => {
render(
<MemoryRouter initialEntries={['/v2?newPod=1']}>
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/v2/components/V2Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,21 @@ const V2Layout: React.FC<V2LayoutProps> = ({ selectionMode = 'auto' }) => {
recordPodVisit(paramPodId);
}, [paramPodId]);

// Desktop only: resume the last valid pod. A new user without history lands
// in their self-created invite-only workspace rather than the auto-joined HQ.
// The phone never redirects: `/v2` is the pods list page there.
// Resume the last valid pod; a new user without history lands in their
// self-created invite-only workspace rather than the auto-joined HQ.
//
// The phone normally stays on the list — `/v2` IS the pods list page there
// (#1578 direction C) — and that stays true for anyone who has opened a pod on
// this device. The exception is a device that has never opened one, which is
// every first login: the list has nothing to return to, and the workspace
// thread with its welcome and composer is the actual first screen. So the same
// resolution runs there too, exactly once. The redirect is self-limiting: it
// sets LAST_POD_KEY (the paramPodId effect above), so the next `/v2` — the
// thread's back arrow — finds the key set and stays on the list, and the
// redirect is a `replace`, so browser-back does not bounce through it either.
useEffect(() => {
if (selectionMode !== 'auto' || paramPodId || loading || phone || createFromConnectors) return;
if (selectionMode !== 'auto' || paramPodId || loading || createFromConnectors) return;
if (phone && readLastPodId()) return;
if (pods.length === 0) return;

const lastPodId = readLastPodId();
Expand Down
Loading