fix(webui): return "Back to App" to the page the user came from

The settings shell's only exit was a "Back to App" link hardcoded to /flows, so opening
Settings or Profile from anywhere dropped the user at the flows list on the way out.

- the Settings and Profile entry links pass the current path as location.state.from
- SettingsLayout captures it once on entry (surviving sub-tab navigation, which drops
  state) and points "Back to App" there, falling back to /flows
- tests: SettingsLayout honors the origin / falls back / preserves it across sub-tabs;
  MainSidebar's Settings and Profile links carry the origin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-06-14 12:06:38 +07:00
co-authored by Claude Fable 5
parent 9b9a74bec0
commit 86c7616efa
4 changed files with 126 additions and 7 deletions
@@ -0,0 +1,62 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/providers/user-provider', () => ({
useUser: () => ({ authInfo: { user: { mail: 'me@example.com', name: 'Test User', type: 'local' } }, logout: vi.fn() }),
}));
vi.mock('@/hooks/use-theme', () => ({ useTheme: () => ({ setTheme: vi.fn(), theme: 'system' }) }));
vi.mock('@/providers/favorites-provider', () => ({
useFavorites: () => ({ addFavoriteFlow: vi.fn(), favoriteFlowIds: [], removeFavoriteFlow: vi.fn() }),
}));
vi.mock('@/providers/sidebar-flows-provider', () => ({ useSidebarFlows: () => ({ flows: [] }) }));
vi.mock('@/features/resources/use-resources-upload', () => ({
useResourcesUpload: () => ({ fileInputKey: 'k', fileInputProps: {}, openFilePicker: vi.fn() }),
}));
import { SidebarProvider } from '@/components/ui/sidebar';
import { MainSidebar } from './main-sidebar';
function FromProbe() {
const location = useLocation();
return <span data-testid="from">{(location.state as null | { from?: string })?.from ?? 'none'}</span>;
}
function renderSidebar() {
return render(
<MemoryRouter initialEntries={['/dashboard']}>
<SidebarProvider>
<MainSidebar />
</SidebarProvider>
<Routes>
<Route element={<div>dashboard</div>} path="/dashboard" />
<Route element={<FromProbe />} path="/settings" />
<Route element={<FromProbe />} path="/settings/account" />
</Routes>
</MemoryRouter>,
);
}
describe('MainSidebar settings entry points', () => {
it('the Settings link carries the current path as the return origin', async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole('link', { name: 'Settings' }));
expect(screen.getByTestId('from')).toHaveTextContent('/dashboard');
});
it('the Profile menu item carries the current path as the return origin', async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole('button', { name: /Test User/ }));
await user.click(screen.getByRole('menuitem', { name: 'Profile' }));
expect(screen.getByTestId('from')).toHaveTextContent('/dashboard');
});
});
@@ -18,7 +18,7 @@ import {
UserIcon,
} from 'lucide-react';
import { useMemo } from 'react';
import { Link, useMatch, useParams } from 'react-router-dom';
import { Link, useLocation, useMatch, useParams } from 'react-router-dom';
import type { Flow } from '@/providers/sidebar-flows-provider';
import type { Theme } from '@/providers/theme-provider';
@@ -61,6 +61,7 @@ interface FlowMenuItemProps {
}
export function MainSidebar() {
const location = useLocation();
const isDashboardActive = useMatch('/dashboard');
const isFlowsActive = useMatch('/flows/*');
const isTemplatesActive = useMatch('/templates/*');
@@ -268,7 +269,10 @@ export function MainSidebar() {
asChild
isActive={!!isSettingsActive}
>
<Link to="/settings">
<Link
state={{ from: location.pathname }}
to="/settings"
>
<Settings />
Settings
</Link>
@@ -354,7 +358,10 @@ export function MainSidebar() {
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/settings/account">
<Link
state={{ from: location.pathname }}
to="/settings/account"
>
<UserIcon className="mr-2 size-4" />
Profile
</Link>
@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import SettingsLayout from './settings-layout';
function renderAt(entry: { pathname: string; state?: unknown }) {
return render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route element={<SettingsLayout />} path="/settings">
<Route element={<div>account</div>} path="account" />
<Route element={<div>providers</div>} path="providers" />
</Route>
</Routes>
</MemoryRouter>,
);
}
const backToApp = () => screen.getByRole('link', { name: /Back to App/ });
describe('SettingsLayout "Back to App"', () => {
it('returns to the page the user came from', () => {
renderAt({ pathname: '/settings/account', state: { from: '/dashboard' } });
expect(backToApp()).toHaveAttribute('href', '/dashboard');
});
it('falls back to /flows when there is no origin', () => {
renderAt({ pathname: '/settings/account' });
expect(backToApp()).toHaveAttribute('href', '/flows');
});
it('keeps the origin after switching settings sub-tabs (which drop location.state)', async () => {
const user = userEvent.setup();
renderAt({ pathname: '/settings/account', state: { from: '/dashboard' } });
await user.click(screen.getByRole('link', { name: 'Providers' }));
expect(backToApp()).toHaveAttribute('href', '/dashboard');
});
});
@@ -1,5 +1,5 @@
import { ArrowLeft, FileText, Key, Plug, Settings as SettingsIcon, User } from 'lucide-react';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { NavLink, Outlet, useLocation, useParams } from 'react-router-dom';
import { Separator } from '@/components/ui/separator';
@@ -17,6 +17,7 @@ import {
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar';
import { getSafeReturnUrl } from '@/lib/utils/auth';
export interface MenuItem {
icon?: React.ReactNode;
@@ -97,10 +98,15 @@ function SettingsHeader() {
}
function SettingsLayout() {
const location = useLocation();
const [returnUrl] = useState(() =>
getSafeReturnUrl((location.state as null | { from?: string })?.from ?? null, '/flows'),
);
return (
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden">
<SettingsSidebar />
<SettingsSidebar returnUrl={returnUrl} />
<SidebarInset className="flex flex-1 flex-col">
<SettingsHeader />
<main className="min-h-0 flex-1 overflow-auto p-4">
@@ -112,7 +118,7 @@ function SettingsLayout() {
);
}
function SettingsSidebar() {
function SettingsSidebar({ returnUrl }: { returnUrl: string }) {
return (
<Sidebar collapsible="icon">
<SidebarHeader>
@@ -143,7 +149,7 @@ function SettingsSidebar() {
</SidebarContent>
<SidebarFooter>
<SidebarMenuButton asChild>
<NavLink to="/flows">
<NavLink to={returnUrl}>
<ArrowLeft className="size-4" />
Back to App
</NavLink>