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 { ...@@ -37,10 +37,11 @@ import {
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { motion, useReducedMotion } from 'motion/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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { SectionPageLayout } from '@/components/layout'
import { import {
CardStaggerContainer, CardStaggerContainer,
CardStaggerItem, CardStaggerItem,
...@@ -457,6 +458,8 @@ function CompactQuickAction(props: { action: QuickAction }) { ...@@ -457,6 +458,8 @@ function CompactQuickAction(props: { action: QuickAction }) {
export function OverviewDashboard() { export function OverviewDashboard() {
const { t } = useTranslation() const { t } = useTranslation()
const setupGuideId = useId()
const setupGuideToggleRef = useRef<HTMLButtonElement>(null)
const user = useAuthStore((state) => state.auth.user) const user = useAuthStore((state) => state.auth.user)
const { items: apiInfoItems } = useApiInfo() const { items: apiInfoItems } = useApiInfo()
const { const {
...@@ -615,11 +618,33 @@ export function OverviewDashboard() { ...@@ -615,11 +618,33 @@ export function OverviewDashboard() {
const nextExpanded = !setupGuideExpanded const nextExpanded = !setupGuideExpanded
setManualSetupGuideExpanded(nextExpanded) setManualSetupGuideExpanded(nextExpanded)
saveSetupGuideExpanded(nextExpanded) saveSetupGuideExpanded(nextExpanded)
if (!nextExpanded && setupComplete) {
setupGuideToggleRef.current?.focus()
}
} }
return ( return (
<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 className='flex flex-col gap-4'>
{setupGuideExpanded ? ( <div id={setupGuideId} hidden={!setupGuideExpanded}>
{setupGuideExpanded && (
<CardStaggerContainer className='grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'> <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'> <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'> <div className='relative h-full overflow-hidden p-4 sm:p-5'>
...@@ -629,7 +654,10 @@ export function OverviewDashboard() { ...@@ -629,7 +654,10 @@ export function OverviewDashboard() {
<div className='flex flex-wrap items-start justify-between gap-3'> <div className='flex flex-wrap items-start justify-between gap-3'>
<div className='flex max-w-2xl flex-col gap-1'> <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'> <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' /> <ListChecks
className='size-3.5'
aria-hidden='true'
/>
{t('Get started')} {t('Get started')}
</div> </div>
<h3 className='text-xl font-semibold tracking-tight sm:text-2xl'> <h3 className='text-xl font-semibold tracking-tight sm:text-2xl'>
...@@ -645,6 +673,8 @@ export function OverviewDashboard() { ...@@ -645,6 +673,8 @@ export function OverviewDashboard() {
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
aria-expanded={setupGuideExpanded}
aria-controls={setupGuideId}
onClick={handleSetupGuideToggle} onClick={handleSetupGuideToggle}
> >
<ChevronUp data-icon='inline-start' /> <ChevronUp data-icon='inline-start' />
...@@ -695,7 +725,9 @@ export function OverviewDashboard() { ...@@ -695,7 +725,9 @@ export function OverviewDashboard() {
</div> </div>
</CardStaggerItem> </CardStaggerItem>
</CardStaggerContainer> </CardStaggerContainer>
) : ( )}
</div>
{!setupGuideExpanded && !setupComplete && (
<CardStaggerContainer> <CardStaggerContainer>
<CardStaggerItem className='bg-card overflow-hidden rounded-2xl border shadow-xs'> <CardStaggerItem className='bg-card overflow-hidden rounded-2xl border shadow-xs'>
<div className='relative overflow-hidden px-4 py-3 sm:px-5'> <div className='relative overflow-hidden px-4 py-3 sm:px-5'>
...@@ -703,14 +735,15 @@ export function OverviewDashboard() { ...@@ -703,14 +735,15 @@ export function OverviewDashboard() {
<div className='relative flex flex-wrap items-center justify-between gap-3'> <div className='relative flex flex-wrap items-center justify-between gap-3'>
<div className='flex min-w-0 items-center 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'> <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' /> <Check
className='text-success size-4'
aria-hidden='true'
/>
</span> </span>
<div className='min-w-0'> <div className='min-w-0'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<h3 className='truncate text-sm font-semibold'> <h3 className='truncate text-sm font-semibold'>
{setupComplete {t('Setup guide')}
? t('Setup guide complete')
: t('Setup guide')}
</h3> </h3>
<span className='text-muted-foreground bg-background/60 rounded-md border px-2 py-0.5 text-xs'> <span className='text-muted-foreground bg-background/60 rounded-md border px-2 py-0.5 text-xs'>
{t('Setup progress: {{completed}}/{{total}}', { {t('Setup progress: {{completed}}/{{total}}', {
...@@ -720,23 +753,24 @@ export function OverviewDashboard() { ...@@ -720,23 +753,24 @@ export function OverviewDashboard() {
</span> </span>
</div> </div>
<p className='text-muted-foreground line-clamp-1 text-xs'> <p className='text-muted-foreground line-clamp-1 text-xs'>
{setupComplete {t('Setup guide is collapsed. Expand it anytime.')}
? t(
'Your setup guide is collapsed so usage stays in focus.'
)
: t('Setup guide is collapsed. Expand it anytime.')}
</p> </p>
</div> </div>
</div> </div>
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
{visibleQuickActions.map((action) => ( {visibleQuickActions.map((action) => (
<CompactQuickAction key={action.title} action={action} /> <CompactQuickAction
key={action.title}
action={action}
/>
))} ))}
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
className='bg-background/70 h-8 min-w-28' className='bg-background/70 h-8 min-w-28'
aria-expanded={setupGuideExpanded}
aria-controls={setupGuideId}
onClick={handleSetupGuideToggle} onClick={handleSetupGuideToggle}
> >
<ChevronDown data-icon='inline-start' /> <ChevronDown data-icon='inline-start' />
...@@ -764,7 +798,9 @@ export function OverviewDashboard() { ...@@ -764,7 +798,9 @@ export function OverviewDashboard() {
<div <div
className={cn( className={cn(
'grid min-w-0 grid-cols-1 gap-4', 'grid min-w-0 grid-cols-1 gap-4',
(showApiInfoPanel || showAnnouncementsPanel || showFAQPanel) && (showApiInfoPanel ||
showAnnouncementsPanel ||
showFAQPanel) &&
'lg:grid-cols-2' 'lg:grid-cols-2'
)} )}
> >
...@@ -798,5 +834,7 @@ export function OverviewDashboard() { ...@@ -798,5 +834,7 @@ export function OverviewDashboard() {
</CardStaggerContainer> </CardStaggerContainer>
)} )}
</div> </div>
</SectionPageLayout.Content>
</SectionPageLayout>
) )
} }
...@@ -317,12 +317,15 @@ export function Dashboard() { ...@@ -317,12 +317,15 @@ export function Dashboard() {
) : null ) : null
const sectionActions = modelActions ?? flowActions const sectionActions = modelActions ?? flowActions
if (activeSection === 'overview') {
return <OverviewDashboard />
}
return ( return (
<SectionPageLayout> <SectionPageLayout>
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title> <SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-3 sm:space-y-4'> <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'> <div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
{showSectionTabs ? ( {showSectionTabs ? (
<Tabs value={activeSection} onValueChange={handleSectionChange}> <Tabs value={activeSection} onValueChange={handleSectionChange}>
...@@ -343,8 +346,6 @@ export function Dashboard() { ...@@ -343,8 +346,6 @@ export function Dashboard() {
</div> </div>
)} )}
</div> </div>
)}
{activeSection === 'overview' && <OverviewDashboard />}
{activeSection === 'models' && ( {activeSection === 'models' && (
<> <>
<FadeIn> <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