Commit 521cebf5 by CaIon

fix(dashboard): simplify completed setup guide

parent 3f8a50cf
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { OverviewDashboard } from '../overview-dashboard'
const storageKey = 'dashboard_overview_setup_guide_expanded'
let client: QueryClient
let keyLookupError: Error | null
beforeEach(() => {
window.localStorage.clear()
useSystemConfigStore.setState(useSystemConfigStore.getInitialState(), true)
useAuthStore.getState().auth.setUser({
id: 1,
username: 'dashboard-user',
role: 1,
quota: 1000000,
used_quota: 1000,
request_count: 1,
})
client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
keyLookupError = null
vi.spyOn(api, 'get').mockImplementation(async (url) => {
switch (url) {
case '/api/token/?p=1&size=10':
if (keyLookupError) throw keyLookupError
return {
data: {
success: true,
data: {
items: [{ id: 1, name: 'App key', key: 'masked', status: 1 }],
},
},
}
case '/api/status':
return {
data: {
data: {
api_info_enabled: false,
announcements_enabled: false,
faq_enabled: false,
uptime_kuma_enabled: false,
},
},
}
case '/api/user/models':
return { data: { success: true, data: ['gpt-4o-mini'] } }
case '/api/data/self':
return { data: { success: true, data: [] } }
default:
throw new Error(`Unexpected dashboard request: ${url}`)
}
})
})
afterEach(() => {
cleanup()
client.clear()
useAuthStore.setState(useAuthStore.getInitialState(), true)
useSystemConfigStore.setState(useSystemConfigStore.getInitialState(), true)
window.localStorage.clear()
})
async function renderOverview() {
const router = createRouter({
routeTree: createRootRoute({ component: OverviewDashboard }),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
await router.load()
return render(
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
)
}
describe('overview setup guide', () => {
it('shows usage first and only a header entry when setup is complete', async () => {
await renderOverview()
const toggle = await screen.findByRole('button', { name: 'Setup guide' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(
screen.getAllByRole('heading').map((heading) => heading.textContent)
).toEqual(['Overview', 'Usage at a glance'])
expect(screen.queryByText('Setup guide complete')).not.toBeInTheDocument()
expect(screen.queryByText('Setup progress: 3/3')).not.toBeInTheDocument()
for (const name of ['API Keys', 'Channels', 'Usage Logs', 'Pricing']) {
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
}
const panel = document.getElementById(
toggle.getAttribute('aria-controls') ?? ''
)
expect(panel).toBeInTheDocument()
expect(panel).not.toBeVisible()
})
it('toggles the completed guide with the keyboard and restores focus after hiding it', async () => {
const user = userEvent.setup()
await renderOverview()
const toggle = await screen.findByRole('button', { name: 'Setup guide' })
await user.tab()
expect(toggle).toHaveFocus()
await user.keyboard('{Enter}')
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(
document.getElementById(toggle.getAttribute('aria-controls') ?? '')
).toBeVisible()
expect(
screen.getByRole('heading', {
name: 'Build on your API gateway in minutes',
})
).toBeVisible()
await waitFor(() =>
expect(screen.getByRole('button', { name: /^API Keys/ })).toBeVisible()
)
await user.keyboard(' ')
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(toggle).toHaveFocus()
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Hide setup guide' }))
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(toggle).toHaveFocus()
})
it('restores the completed guide preference after remounting', async () => {
const user = userEvent.setup()
const first = await renderOverview()
await user.click(await screen.findByRole('button', { name: 'Setup guide' }))
first.unmount()
const second = await renderOverview()
expect(
await screen.findByRole('button', { name: 'Setup guide' })
).toHaveAttribute('aria-expanded', 'true')
await user.click(screen.getByRole('button', { name: 'Hide setup guide' }))
second.unmount()
await renderOverview()
expect(
await screen.findByRole('button', { name: 'Setup guide' })
).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Setup guide complete')).not.toBeInTheDocument()
})
it('keeps the existing progress banner when an incomplete guide is manually collapsed', async () => {
const user = userEvent.setup()
useAuthStore
.getState()
.auth.setUser({ id: 1, username: 'new-user', role: 1 })
await renderOverview()
await user.click(
await screen.findByRole('button', { name: 'Hide setup guide' })
)
expect(screen.getByText('Setup progress: 1/3')).toBeVisible()
expect(
screen.getByText('Setup guide is collapsed. Expand it anytime.')
).toBeVisible()
expect(screen.getByRole('button', { name: 'API Keys' })).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Setup guide' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Show setup guide' }))
expect(
screen.getByRole('button', { name: 'Hide setup guide' })
).toBeVisible()
})
it('removes the collapsed progress banner when the remaining setup step completes', async () => {
useAuthStore.getState().auth.setUser({
id: 1,
username: 'dashboard-user',
role: 1,
quota: 1000000,
})
window.localStorage.setItem(storageKey, 'collapsed')
await renderOverview()
expect(await screen.findByText('Setup progress: 2/3')).toBeVisible()
act(() => {
useAuthStore.getState().auth.setUser({
id: 1,
username: 'dashboard-user',
role: 1,
quota: 1000000,
request_count: 1,
})
})
expect(
await screen.findByRole('button', { name: 'Setup guide' })
).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText(/Setup progress:/)).not.toBeInTheDocument()
})
it('does not show a completed setup entry when the key lookup fails', async () => {
keyLookupError = new Error('Key lookup unavailable')
await renderOverview()
expect(
await screen.findByRole('button', { name: 'Hide setup guide' })
).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Setup guide' })
).not.toBeInTheDocument()
})
})
......@@ -37,10 +37,11 @@ import {
type LucideIcon,
} from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react'
import { useMemo, useState } from 'react'
import { useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { SectionPageLayout } from '@/components/layout'
import {
CardStaggerContainer,
CardStaggerItem,
......@@ -457,6 +458,8 @@ function CompactQuickAction(props: { action: QuickAction }) {
export function OverviewDashboard() {
const { t } = useTranslation()
const setupGuideId = useId()
const setupGuideToggleRef = useRef<HTMLButtonElement>(null)
const user = useAuthStore((state) => state.auth.user)
const { items: apiInfoItems } = useApiInfo()
const {
......@@ -615,188 +618,223 @@ export function OverviewDashboard() {
const nextExpanded = !setupGuideExpanded
setManualSetupGuideExpanded(nextExpanded)
saveSetupGuideExpanded(nextExpanded)
if (!nextExpanded && setupComplete) {
setupGuideToggleRef.current?.focus()
}
}
return (
<div className='flex flex-col gap-4'>
{setupGuideExpanded ? (
<CardStaggerContainer className='grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'>
<CardStaggerItem className='bg-card h-full overflow-hidden rounded-2xl border shadow-xs'>
<div className='relative h-full overflow-hidden p-4 sm:p-5'>
<SetupGuideBackdrop />
<div className='relative grid gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]'>
<div className='flex min-w-0 flex-col gap-5'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='flex max-w-2xl flex-col gap-1'>
<div className='text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase'>
<ListChecks className='size-3.5' aria-hidden='true' />
{t('Get started')}
<SectionPageLayout>
<SectionPageLayout.Title>{t('Overview')}</SectionPageLayout.Title>
<SectionPageLayout.Actions>
{setupStatusReady && setupComplete && (
<Button
ref={setupGuideToggleRef}
variant='ghost'
size='sm'
className='text-muted-foreground hover:text-foreground h-auto min-h-7 max-w-[60vw] whitespace-normal'
aria-expanded={setupGuideExpanded}
aria-controls={setupGuideId}
onClick={handleSetupGuideToggle}
>
{t('Setup guide')}
</Button>
)}
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<div className='flex flex-col gap-4'>
<div id={setupGuideId} hidden={!setupGuideExpanded}>
{setupGuideExpanded && (
<CardStaggerContainer className='grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'>
<CardStaggerItem className='bg-card h-full overflow-hidden rounded-2xl border shadow-xs'>
<div className='relative h-full overflow-hidden p-4 sm:p-5'>
<SetupGuideBackdrop />
<div className='relative grid gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]'>
<div className='flex min-w-0 flex-col gap-5'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='flex max-w-2xl flex-col gap-1'>
<div className='text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase'>
<ListChecks
className='size-3.5'
aria-hidden='true'
/>
{t('Get started')}
</div>
<h3 className='text-xl font-semibold tracking-tight sm:text-2xl'>
{t('Build on your API gateway in minutes')}
</h3>
<p className='text-muted-foreground max-w-xl text-sm leading-relaxed'>
{t(
'A focused home for keys, balance, routing, and service health.'
)}
</p>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Button
variant='outline'
size='sm'
aria-expanded={setupGuideExpanded}
aria-controls={setupGuideId}
onClick={handleSetupGuideToggle}
>
<ChevronUp data-icon='inline-start' />
{t('Hide setup guide')}
</Button>
<Button size='sm' render={<Link to='/keys' />}>
<KeyRound data-icon='inline-start' />
{t('Create API Key')}
</Button>
</div>
</div>
<ol className='bg-background/45 rounded-2xl border p-2 backdrop-blur'>
{startSteps.map((step, index) => (
<StartStepItem
key={step.title}
step={step}
index={index}
isLast={index === startSteps.length - 1}
/>
))}
</ol>
</div>
<RequestPreview
example={requestExample}
signals={heroSignals}
/>
</div>
</div>
</CardStaggerItem>
<CardStaggerItem className='bg-card h-full rounded-2xl border p-4 shadow-xs sm:p-5'>
<div className='flex h-full flex-col gap-4'>
<div className='flex flex-col gap-1'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Recommended actions')}
</div>
<h3 className='text-xl font-semibold tracking-tight sm:text-2xl'>
{t('Build on your API gateway in minutes')}
<h3 className='text-lg font-semibold tracking-tight'>
{t('Keep the platform ready')}
</h3>
<p className='text-muted-foreground max-w-xl text-sm leading-relaxed'>
{t(
'A focused home for keys, balance, routing, and service health.'
)}
</p>
</div>
<div className='grid gap-2'>
{visibleQuickActions.map((action) => (
<QuickActionItem key={action.title} action={action} />
))}
</div>
</div>
</CardStaggerItem>
</CardStaggerContainer>
)}
</div>
{!setupGuideExpanded && !setupComplete && (
<CardStaggerContainer>
<CardStaggerItem className='bg-card overflow-hidden rounded-2xl border shadow-xs'>
<div className='relative overflow-hidden px-4 py-3 sm:px-5'>
<SetupGuideBackdrop compact />
<div className='relative flex flex-wrap items-center justify-between gap-3'>
<div className='flex min-w-0 items-center gap-3'>
<span className='bg-background/70 flex size-9 shrink-0 items-center justify-center rounded-xl border shadow-xs'>
<Check
className='text-success size-4'
aria-hidden='true'
/>
</span>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<h3 className='truncate text-sm font-semibold'>
{t('Setup guide')}
</h3>
<span className='text-muted-foreground bg-background/60 rounded-md border px-2 py-0.5 text-xs'>
{t('Setup progress: {{completed}}/{{total}}', {
completed: completedStepCount,
total: startSteps.length,
})}
</span>
</div>
<p className='text-muted-foreground line-clamp-1 text-xs'>
{t('Setup guide is collapsed. Expand it anytime.')}
</p>
</div>
</div>
<div className='flex flex-wrap items-center gap-2'>
{visibleQuickActions.map((action) => (
<CompactQuickAction
key={action.title}
action={action}
/>
))}
<Button
variant='outline'
size='sm'
className='bg-background/70 h-8 min-w-28'
aria-expanded={setupGuideExpanded}
aria-controls={setupGuideId}
onClick={handleSetupGuideToggle}
>
<ChevronUp data-icon='inline-start' />
{t('Hide setup guide')}
</Button>
<Button size='sm' render={<Link to='/keys' />}>
<KeyRound data-icon='inline-start' />
{t('Create API Key')}
<ChevronDown data-icon='inline-start' />
{t('Show setup guide')}
</Button>
</div>
</div>
<ol className='bg-background/45 rounded-2xl border p-2 backdrop-blur'>
{startSteps.map((step, index) => (
<StartStepItem
key={step.title}
step={step}
index={index}
isLast={index === startSteps.length - 1}
/>
))}
</ol>
</div>
<RequestPreview
example={requestExample}
signals={heroSignals}
/>
</div>
</div>
</CardStaggerItem>
<CardStaggerItem className='bg-card h-full rounded-2xl border p-4 shadow-xs sm:p-5'>
<div className='flex h-full flex-col gap-4'>
<div className='flex flex-col gap-1'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Recommended actions')}
</div>
<h3 className='text-lg font-semibold tracking-tight'>
{t('Keep the platform ready')}
</h3>
</div>
<div className='grid gap-2'>
{visibleQuickActions.map((action) => (
<QuickActionItem key={action.title} action={action} />
))}
</div>
</div>
</CardStaggerItem>
</CardStaggerContainer>
) : (
<CardStaggerContainer>
<CardStaggerItem className='bg-card overflow-hidden rounded-2xl border shadow-xs'>
<div className='relative overflow-hidden px-4 py-3 sm:px-5'>
<SetupGuideBackdrop compact />
<div className='relative flex flex-wrap items-center justify-between gap-3'>
<div className='flex min-w-0 items-center gap-3'>
<span className='bg-background/70 flex size-9 shrink-0 items-center justify-center rounded-xl border shadow-xs'>
<Check className='text-success size-4' aria-hidden='true' />
</span>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<h3 className='truncate text-sm font-semibold'>
{setupComplete
? t('Setup guide complete')
: t('Setup guide')}
</h3>
<span className='text-muted-foreground bg-background/60 rounded-md border px-2 py-0.5 text-xs'>
{t('Setup progress: {{completed}}/{{total}}', {
completed: completedStepCount,
total: startSteps.length,
})}
</span>
</div>
<p className='text-muted-foreground line-clamp-1 text-xs'>
{setupComplete
? t(
'Your setup guide is collapsed so usage stays in focus.'
)
: t('Setup guide is collapsed. Expand it anytime.')}
</p>
</div>
</div>
<div className='flex flex-wrap items-center gap-2'>
{visibleQuickActions.map((action) => (
<CompactQuickAction key={action.title} action={action} />
))}
<Button
variant='outline'
size='sm'
className='bg-background/70 h-8 min-w-28'
onClick={handleSetupGuideToggle}
>
<ChevronDown data-icon='inline-start' />
{t('Show setup guide')}
</Button>
</div>
</div>
</div>
</CardStaggerItem>
</CardStaggerContainer>
)}
</CardStaggerItem>
</CardStaggerContainer>
)}
<SummaryCards />
<SummaryCards />
{showContentPanels && (
<CardStaggerContainer
className={cn(
'grid grid-cols-1 gap-4',
showLeftContentPanels &&
showUptimePanel &&
'xl:grid-cols-[minmax(0,1fr)_22rem]'
)}
>
{showLeftContentPanels && (
<div
{showContentPanels && (
<CardStaggerContainer
className={cn(
'grid min-w-0 grid-cols-1 gap-4',
(showApiInfoPanel || showAnnouncementsPanel || showFAQPanel) &&
'lg:grid-cols-2'
'grid grid-cols-1 gap-4',
showLeftContentPanels &&
showUptimePanel &&
'xl:grid-cols-[minmax(0,1fr)_22rem]'
)}
>
{isAdmin && (
<CardStaggerItem className='lg:col-span-2'>
<PerformanceHealthPanel />
</CardStaggerItem>
)}
{showApiInfoPanel && (
<CardStaggerItem>
<ApiInfoPanel />
</CardStaggerItem>
)}
{showAnnouncementsPanel && (
<CardStaggerItem>
<AnnouncementsPanel />
</CardStaggerItem>
{showLeftContentPanels && (
<div
className={cn(
'grid min-w-0 grid-cols-1 gap-4',
(showApiInfoPanel ||
showAnnouncementsPanel ||
showFAQPanel) &&
'lg:grid-cols-2'
)}
>
{isAdmin && (
<CardStaggerItem className='lg:col-span-2'>
<PerformanceHealthPanel />
</CardStaggerItem>
)}
{showApiInfoPanel && (
<CardStaggerItem>
<ApiInfoPanel />
</CardStaggerItem>
)}
{showAnnouncementsPanel && (
<CardStaggerItem>
<AnnouncementsPanel />
</CardStaggerItem>
)}
{showFAQPanel && (
<CardStaggerItem>
<FAQPanel />
</CardStaggerItem>
)}
</div>
)}
{showFAQPanel && (
{showUptimePanel && (
<CardStaggerItem>
<FAQPanel />
<UptimePanel />
</CardStaggerItem>
)}
</div>
</CardStaggerContainer>
)}
{showUptimePanel && (
<CardStaggerItem>
<UptimePanel />
</CardStaggerItem>
)}
</CardStaggerContainer>
)}
</div>
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
)
}
......@@ -317,34 +317,35 @@ export function Dashboard() {
) : null
const sectionActions = modelActions ?? flowActions
if (activeSection === 'overview') {
return <OverviewDashboard />
}
return (
<SectionPageLayout>
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Content>
<div className='space-y-3 sm:space-y-4'>
{activeSection !== 'overview' && (
<div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
{showSectionTabs ? (
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : (
<div />
)}
{sectionActions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-1.5 sm:gap-2'>
{sectionActions}
</div>
)}
</div>
)}
{activeSection === 'overview' && <OverviewDashboard />}
<div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
{showSectionTabs ? (
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : (
<div />
)}
{sectionActions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-1.5 sm:gap-2'>
{sectionActions}
</div>
)}
</div>
{activeSection === 'models' && (
<>
<FadeIn>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment