From 5419576f2903d057fe543859e26b73d64f8366b8 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 19 Apr 2026 12:20:31 -0400 Subject: [PATCH] fix: restore unauthenticated space switching --- frontend/src/App.test.tsx | 28 +++++- frontend/src/features/layout/AppLayout.tsx | 8 +- .../features/layout/SpaceSwitcher.test.tsx | 83 +++++++++++++++++ .../src/features/layout/SpaceSwitcher.tsx | 89 +++++++++++++++++++ frontend/src/features/sidebar/Sidebar.tsx | 9 ++ frontend/src/features/viewer/PageViewer.tsx | 4 +- frontend/src/index.css | 52 +++++++++-- 7 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 frontend/src/features/layout/SpaceSwitcher.test.tsx create mode 100644 frontend/src/features/layout/SpaceSwitcher.tsx diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7f93cd2..14aea07 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -16,17 +16,23 @@ * Contact: alex@kuleshov.tech */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import App from './App' import { useEditorStore } from './stores/editor' +import { useSpaceStore } from './stores/space' import { useTreeStore } from './stores/tree' import { useUiStore } from './stores/ui' import { useViewerStore } from './stores/viewer' vi.mock('./lib/api', () => ({ + setCurrentSpaceSlug: vi.fn(), + listSpaces: vi.fn(async () => [ + { id: 's1', slug: 'default', name: 'Default', createdAt: '2026-01-01T00:00:00Z' }, + { id: 's2', slug: 'docs', name: 'Docs', createdAt: '2026-01-01T00:00:00Z' }, + ]), getAuthConfig: vi.fn(async () => ({ authDisabled: true, publicAccess: true, @@ -130,6 +136,11 @@ describe('App', () => { loading: false, error: null, }) + useSpaceStore.setState({ + spaces: [], + activeSlug: 'default', + loaded: false, + }) useUiStore.setState({ isDark: false, sidebarVisible: true, @@ -148,7 +159,20 @@ describe('App', () => { , ) - expect(await screen.findByTitle('Image version sha-1234567')).toHaveTextContent('sha-1234567') + const sidebar = await screen.findByTestId('sidebar') + expect(within(sidebar).getByTitle('Image version sha-1234567')).toHaveTextContent('sha-1234567') + expect(within(screen.getByRole('banner')).queryByTitle('Image version sha-1234567')).not.toBeInTheDocument() + }) + + it('shows a standalone space switcher when there is no account menu', async () => { + render( + + + , + ) + + expect(await screen.findByRole('button', { name: 'Switch space, current space Default' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Account menu/ })).not.toBeInTheDocument() }) it('renders the shell and loads the root page without crashing', async () => { diff --git a/frontend/src/features/layout/AppLayout.tsx b/frontend/src/features/layout/AppLayout.tsx index cad62b5..9fd1a37 100644 --- a/frontend/src/features/layout/AppLayout.tsx +++ b/frontend/src/features/layout/AppLayout.tsx @@ -23,6 +23,7 @@ import { Link } from 'react-router-dom' import { Sidebar } from '../sidebar/Sidebar' import { Toolbar } from '../toolbar/Toolbar' import type { WikiNodeKind, WikiTreeNode } from '../../types' +import { SpaceSwitcher } from './SpaceSwitcher' import { UserMenu } from './UserMenu' interface AppLayoutProps { @@ -133,11 +134,7 @@ export function AppLayout({ Import ) : null} - {displayImageVersion ? ( - - {displayImageVersion} - - ) : null} + {currentUsername ? null : } {currentUsername ? ( diff --git a/frontend/src/features/layout/SpaceSwitcher.test.tsx b/frontend/src/features/layout/SpaceSwitcher.test.tsx new file mode 100644 index 0000000..1d87a4b --- /dev/null +++ b/frontend/src/features/layout/SpaceSwitcher.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSpaceStore } from '../../stores/space' +import { useTreeStore } from '../../stores/tree' +import type { Space } from '../../types' +import { SpaceSwitcher } from './SpaceSwitcher' + +const spaces: Space[] = [ + { id: 's1', slug: 'default', name: 'Default', createdAt: '2026-01-01T00:00:00Z' }, + { id: 's2', slug: 'product', name: 'Product', createdAt: '2026-01-01T00:00:00Z' }, +] + +function LocationProbe() { + const location = useLocation() + return
{location.pathname}
+} + +function renderSwitcher() { + return render( + + + + } /> + + , + ) +} + +describe('SpaceSwitcher', () => { + beforeEach(() => { + useSpaceStore.setState({ + spaces, + activeSlug: 'default', + loaded: true, + }) + useTreeStore.setState({ reloadTree: vi.fn(async () => undefined) }) + }) + + it('switches the active space, reloads the tree, and navigates home', async () => { + const reloadTree = vi.fn(async () => undefined) + useTreeStore.setState({ reloadTree }) + const user = userEvent.setup() + + renderSwitcher() + await user.click(screen.getByRole('button', { name: 'Switch space, current space Default' })) + await user.click(await screen.findByRole('menuitemradio', { name: 'Product' })) + + await waitFor(() => { + expect(useSpaceStore.getState().activeSlug).toBe('product') + }) + expect(reloadTree).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('location')).toHaveTextContent('/') + }) + + it('stays hidden when there is only one space', () => { + useSpaceStore.setState({ spaces: [spaces[0]], activeSlug: 'default', loaded: true }) + + renderSwitcher() + + expect(screen.queryByRole('button', { name: /Switch space/i })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/layout/SpaceSwitcher.tsx b/frontend/src/features/layout/SpaceSwitcher.tsx new file mode 100644 index 0000000..0826471 --- /dev/null +++ b/frontend/src/features/layout/SpaceSwitcher.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { Check, ChevronDown, Layers } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +import { useSpaceStore } from '../../stores/space' +import { useTreeStore } from '../../stores/tree' + +export function SpaceSwitcher() { + const spaces = useSpaceStore((state) => state.spaces) + const activeSlug = useSpaceStore((state) => state.activeSlug) + const setActiveSlug = useSpaceStore((state) => state.setActiveSlug) + const reloadTree = useTreeStore((state) => state.reloadTree) + const navigate = useNavigate() + + if (spaces.length <= 1) { + return null + } + + const activeSpace = spaces.find((space) => space.slug === activeSlug) ?? spaces[0] + const activeName = activeSpace?.name ?? activeSlug + + const handleSpaceChange = (slug: string) => { + if (!slug || slug === activeSlug) { + return + } + setActiveSlug(slug) + navigate('/') + void reloadTree() + } + + return ( + + + + + + + Switch space + + {spaces.map((space) => ( + + + ))} + + + + + ) +} diff --git a/frontend/src/features/sidebar/Sidebar.tsx b/frontend/src/features/sidebar/Sidebar.tsx index 9d09905..e35db10 100644 --- a/frontend/src/features/sidebar/Sidebar.tsx +++ b/frontend/src/features/sidebar/Sidebar.tsx @@ -41,6 +41,7 @@ interface SidebarProps { onExpandAll: () => void onCollapseAll: () => void onOpenSearch: () => void + imageVersion?: string | null } export function Sidebar({ @@ -61,6 +62,7 @@ export function Sidebar({ onExpandAll, onCollapseAll, onOpenSearch, + imageVersion, }: SidebarProps) { const [activeTab, setActiveTab] = useState<'tree' | 'search'>('tree') @@ -170,6 +172,13 @@ export function Sidebar({ /> )} + {imageVersion ? ( +
+ + {imageVersion} + +
+ ) : null} ) diff --git a/frontend/src/features/viewer/PageViewer.tsx b/frontend/src/features/viewer/PageViewer.tsx index 3261f57..7bcf21a 100644 --- a/frontend/src/features/viewer/PageViewer.tsx +++ b/frontend/src/features/viewer/PageViewer.tsx @@ -30,6 +30,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'sonner' import { ensurePage } from '../../lib/api' import { normalizeWikiPath } from '../../lib/paths' +import { useSpaceStore } from '../../stores/space' import { useViewerStore } from '../../stores/viewer' export function PageViewer() { @@ -39,6 +40,7 @@ export function PageViewer() { const error = useViewerStore((state) => state.error) const loading = useViewerStore((state) => state.loading) const loadPageData = useViewerStore((state) => state.loadPageData) + const activeSpaceSlug = useSpaceStore((state) => state.activeSlug) const setActiveNodeId = useTreeStore((state) => state.setActiveNodeId) const openAncestorsForPath = useTreeStore((state) => state.openAncestorsForPath) const getPageByPath = useTreeStore((state) => state.getPageByPath) @@ -55,7 +57,7 @@ export function PageViewer() { useEffect(() => { void loadPageData(currentPath) - }, [currentPath, loadPageData]) + }, [activeSpaceSlug, currentPath, loadPageData]) useEffect(() => { const treeNode = getPageByPath(currentPath) diff --git a/frontend/src/index.css b/frontend/src/index.css index 2221c73..f81b415 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -142,7 +142,7 @@ } .sidebar__inner { - @apply block h-full w-full bg-sidebar; + @apply flex h-full w-full flex-col bg-sidebar; } .sidebar__tabs { @@ -166,7 +166,15 @@ } .sidebar__content { - @apply h-[calc(100%-48px)] w-full; + @apply min-h-0 flex-1 w-full; + } + + .sidebar__footer { + @apply border-t border-surface-border px-3 py-2; + } + + .sidebar__image-version { + @apply inline-flex max-w-full truncate rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs font-medium text-sidebar-foreground/70; } .app-layout__header { @@ -201,10 +209,6 @@ @apply ml-auto flex min-h-full items-center gap-1 md:gap-2; } - .app-layout__image-version { - @apply max-w-28 truncate rounded-full border border-surface-border bg-background/50 px-2 py-1 text-xs font-medium text-muted; - } - .app-layout__header-spacer { @apply h-16 w-full md:h-[68px]; } @@ -273,6 +277,42 @@ @apply ml-auto inline-flex items-center justify-center text-accent; } + .space-switcher__trigger { + @apply max-w-[42vw] rounded-lg px-2 sm:max-w-56 sm:px-3; + } + + .space-switcher__trigger-label { + @apply hidden min-w-0 truncate sm:inline; + } + + .space-switcher__trigger-chevron { + @apply hidden sm:inline; + } + + .space-switcher__content { + @apply z-50 min-w-56 max-w-[calc(100vw-1rem)] rounded-lg border border-surface-border bg-surface p-2 shadow-2xl outline-none; + } + + .space-switcher__label { + @apply px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-muted; + } + + .space-switcher__item { + @apply flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground outline-none transition hover:bg-surface-alt focus:bg-surface-alt data-[highlighted]:bg-surface-alt; + } + + .space-switcher__item--radio { + @apply pr-8; + } + + .space-switcher__item-label { + @apply truncate; + } + + .space-switcher__item-indicator { + @apply ml-auto inline-flex items-center justify-center text-accent; + } + .app-layout__chromeless-main { @apply flex min-h-screen flex-col items-stretch overflow-auto; }