diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 1a2d175b..64df10cf 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -393,6 +393,9 @@ const dashboardStarsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/stars', beforeLoad: requireAuth, + validateSearch: (search: Record): { page?: number } => ({ + page: typeof search.page === 'number' && search.page > 0 ? search.page : undefined, + }), component: MyStarsPage, }) @@ -400,6 +403,9 @@ const dashboardSubscriptionsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/subscriptions', beforeLoad: requireAuth, + validateSearch: (search: Record): { page?: number } => ({ + page: typeof search.page === 'number' && search.page > 0 ? search.page : undefined, + }), component: MySubscriptionsPage, }) diff --git a/web/src/pages/dashboard/stars.test.ts b/web/src/pages/dashboard/stars.test.ts index 20e7279f..907e5935 100644 --- a/web/src/pages/dashboard/stars.test.ts +++ b/web/src/pages/dashboard/stars.test.ts @@ -1,7 +1,30 @@ -import { describe, expect, it, vi } from 'vitest' +// @vitest-environment jsdom + +import { createElement } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const navigate = vi.fn() +const { useMyStarsPage } = vi.hoisted(() => ({ + useMyStarsPage: vi.fn(() => ({ + data: { + items: [{ id: 1, namespace: 'team-a', slug: 'demo skill' }], + total: 1, + page: 0, + size: 12, + }, + isLoading: false, + })), +})) vi.mock('@tanstack/react-router', () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => navigate, + useSearch: () => ({ page: 2 }), + useLocation: () => ({ + pathname: '/dashboard/stars', + searchStr: '?page=2', + hash: '#saved', + }), })) vi.mock('react-i18next', async () => { @@ -15,7 +38,7 @@ vi.mock('react-i18next', async () => { }) vi.mock('@/features/skill/skill-card', () => ({ - SkillCard: () => null, + SkillCard: ({ onClick }: { onClick?: () => void }) => createElement('button', { onClick }, 'skill-card'), })) vi.mock('@/shared/components/pagination', () => ({ @@ -23,10 +46,7 @@ vi.mock('@/shared/components/pagination', () => ({ })) vi.mock('@/shared/hooks/use-user-queries', () => ({ - useMyStarsPage: () => ({ - data: { items: [], total: 0, page: 0, size: 12 }, - isLoading: false, - }), + useMyStarsPage, })) vi.mock('@/shared/ui/card', () => ({ @@ -40,7 +60,26 @@ vi.mock('@/shared/components/dashboard-page-header', () => ({ import { MyStarsPage } from './stars' describe('MyStarsPage', () => { + beforeEach(() => navigate.mockClear()) + it('exports a named component function', () => { expect(typeof MyStarsPage).toBe('function') }) + + it('preserves the favorites page when opening a skill', () => { + render(createElement(MyStarsPage)) + + fireEvent.click(screen.getByRole('button', { name: 'skill-card' })) + + expect(navigate).toHaveBeenCalledWith({ + to: '/space/team-a/demo%20skill', + search: { returnTo: '/dashboard/stars?page=2#saved' }, + }) + }) + + it('uses the URL page as the query source', () => { + render(createElement(MyStarsPage)) + + expect(useMyStarsPage).toHaveBeenCalledWith({ page: 2, size: 12 }) + }) }) diff --git a/web/src/pages/dashboard/stars.tsx b/web/src/pages/dashboard/stars.tsx index 36a5dc33..cbaacab5 100644 --- a/web/src/pages/dashboard/stars.tsx +++ b/web/src/pages/dashboard/stars.tsx @@ -1,18 +1,20 @@ -import { useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { SkillCard } from '@/features/skill/skill-card' import { Pagination } from '@/shared/components/pagination' import { useMyStarsPage } from '@/shared/hooks/use-user-queries' import { Card } from '@/shared/ui/card' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { buildReturnTo } from '@/shared/lib/auth-route' const PAGE_SIZE = 12 export function MyStarsPage() { const { t } = useTranslation() const navigate = useNavigate() - const [page, setPage] = useState(0) + const location = useLocation() + const search = useSearch({ from: '/dashboard/stars' }) + const page = search.page ?? 0 const { data, isLoading } = useMyStarsPage({ page, size: PAGE_SIZE }) const skills = data?.items ?? [] const totalPages = data ? Math.max(Math.ceil(data.total / data.size), 1) : 1 @@ -40,12 +42,22 @@ export function MyStarsPage() { navigate({ to: `/space/${skill.namespace}/${encodeURIComponent(skill.slug)}` })} + onClick={() => navigate({ + to: `/space/${skill.namespace}/${encodeURIComponent(skill.slug)}`, + search: { returnTo: buildReturnTo(location) }, + })} /> ))} {data && data.total > PAGE_SIZE ? ( - + navigate({ + to: '/dashboard/stars', + search: { page: nextPage > 0 ? nextPage : undefined }, + })} + /> ) : null} )} diff --git a/web/src/pages/dashboard/subscriptions.test.ts b/web/src/pages/dashboard/subscriptions.test.ts new file mode 100644 index 00000000..922278ed --- /dev/null +++ b/web/src/pages/dashboard/subscriptions.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom + +import { createElement } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const navigate = vi.fn() +const { useMySubscriptionsPage } = vi.hoisted(() => ({ + useMySubscriptionsPage: vi.fn(() => ({ + data: { + items: [{ id: 1, namespace: 'team-a', slug: 'demo-skill' }], + total: 1, + page: 0, + size: 12, + }, + isLoading: false, + })), +})) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => navigate, + useSearch: () => ({ page: 1 }), + useLocation: () => ({ + pathname: '/dashboard/subscriptions', + searchStr: '?page=1', + hash: '', + }), +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key }), + } +}) + +vi.mock('@/features/skill/skill-card', () => ({ + SkillCard: ({ onClick }: { onClick?: () => void }) => createElement('button', { onClick }, 'skill-card'), +})) + +vi.mock('@/shared/components/pagination', () => ({ Pagination: () => null })) +vi.mock('@/shared/hooks/use-user-queries', () => ({ + useMySubscriptionsPage, +})) +vi.mock('@/shared/ui/card', () => ({ Card: ({ children }: { children: unknown }) => children })) +vi.mock('@/shared/components/dashboard-page-header', () => ({ DashboardPageHeader: () => null })) + +import { MySubscriptionsPage } from './subscriptions' + +describe('MySubscriptionsPage', () => { + beforeEach(() => navigate.mockClear()) + + it('preserves the subscriptions page when opening a skill', () => { + render(createElement(MySubscriptionsPage)) + + fireEvent.click(screen.getByRole('button', { name: 'skill-card' })) + + expect(navigate).toHaveBeenCalledWith({ + to: '/space/team-a/demo-skill', + search: { returnTo: '/dashboard/subscriptions?page=1' }, + }) + }) + + it('uses the URL page as the query source', () => { + render(createElement(MySubscriptionsPage)) + + expect(useMySubscriptionsPage).toHaveBeenCalledWith({ page: 1, size: 12 }) + }) +}) diff --git a/web/src/pages/dashboard/subscriptions.tsx b/web/src/pages/dashboard/subscriptions.tsx index 6c0523dd..85955a28 100644 --- a/web/src/pages/dashboard/subscriptions.tsx +++ b/web/src/pages/dashboard/subscriptions.tsx @@ -1,18 +1,20 @@ -import { useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { SkillCard } from '@/features/skill/skill-card' import { Pagination } from '@/shared/components/pagination' import { useMySubscriptionsPage } from '@/shared/hooks/use-user-queries' import { Card } from '@/shared/ui/card' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { buildReturnTo } from '@/shared/lib/auth-route' const PAGE_SIZE = 12 export function MySubscriptionsPage() { const { t } = useTranslation() const navigate = useNavigate() - const [page, setPage] = useState(0) + const location = useLocation() + const search = useSearch({ from: '/dashboard/subscriptions' }) + const page = search.page ?? 0 const { data, isLoading } = useMySubscriptionsPage({ page, size: PAGE_SIZE }) const skills = data?.items ?? [] const totalPages = data ? Math.max(Math.ceil(data.total / data.size), 1) : 1 @@ -40,12 +42,22 @@ export function MySubscriptionsPage() { navigate({ to: `/space/${skill.namespace}/${encodeURIComponent(skill.slug)}` })} + onClick={() => navigate({ + to: `/space/${skill.namespace}/${encodeURIComponent(skill.slug)}`, + search: { returnTo: buildReturnTo(location) }, + })} /> ))} {data && data.total > PAGE_SIZE ? ( - + navigate({ + to: '/dashboard/subscriptions', + search: { page: nextPage > 0 ? nextPage : undefined }, + })} + /> ) : null} )}