refactor(web): extract custom hooks from complex components and add comprehensive tests (#32301)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-02-13 17:21:34 +08:00
committed by GitHub
parent 98466e2d29
commit 210710e76d
21 changed files with 2595 additions and 983 deletions

View File

@ -53,6 +53,10 @@ vi.mock('@/hooks/use-theme', () => ({
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
getDocLanguage: (locale: string) => {
const map: Record<string, string> = { 'zh-Hans': 'zh', 'ja-JP': 'ja' }
return map[locale] || 'en'
},
}))
describe('Doc', () => {
@ -63,7 +67,7 @@ describe('Doc', () => {
prompt_variables: variables,
},
},
})
}) as unknown as Parameters<typeof Doc>[0]['appDetail']
beforeEach(() => {
vi.clearAllMocks()
@ -123,13 +127,13 @@ describe('Doc', () => {
describe('null/undefined appDetail', () => {
it('should render nothing when appDetail has no mode', () => {
render(<Doc appDetail={{}} />)
render(<Doc appDetail={{} as unknown as Parameters<typeof Doc>[0]['appDetail']} />)
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
expect(screen.queryByTestId('template-chat-en')).not.toBeInTheDocument()
})
it('should render nothing when appDetail is null', () => {
render(<Doc appDetail={null} />)
render(<Doc appDetail={null as unknown as Parameters<typeof Doc>[0]['appDetail']} />)
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,199 @@
import type { TocItem } from '../hooks/use-doc-toc'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TocPanel from '../toc-panel'
/**
* Unit tests for the TocPanel presentational component.
* Covers collapsed/expanded states, item rendering, active section, and callbacks.
*/
describe('TocPanel', () => {
const defaultProps = {
toc: [] as TocItem[],
activeSection: '',
isTocExpanded: false,
onToggle: vi.fn(),
onItemClick: vi.fn(),
}
const sampleToc: TocItem[] = [
{ href: '#introduction', text: 'Introduction' },
{ href: '#authentication', text: 'Authentication' },
{ href: '#endpoints', text: 'Endpoints' },
]
beforeEach(() => {
vi.clearAllMocks()
})
// Covers collapsed state rendering
describe('collapsed state', () => {
it('should render expand button when collapsed', () => {
render(<TocPanel {...defaultProps} />)
expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument()
})
it('should not render nav or toc items when collapsed', () => {
render(<TocPanel {...defaultProps} toc={sampleToc} />)
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
expect(screen.queryByText('Introduction')).not.toBeInTheDocument()
})
it('should call onToggle(true) when expand button is clicked', () => {
const onToggle = vi.fn()
render(<TocPanel {...defaultProps} onToggle={onToggle} />)
fireEvent.click(screen.getByLabelText('Open table of contents'))
expect(onToggle).toHaveBeenCalledWith(true)
})
})
// Covers expanded state with empty toc
describe('expanded state - empty', () => {
it('should render nav with empty message when toc is empty', () => {
render(<TocPanel {...defaultProps} isTocExpanded />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
expect(screen.getByText('appApi.develop.noContent')).toBeInTheDocument()
})
it('should render TOC header with title', () => {
render(<TocPanel {...defaultProps} isTocExpanded />)
expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument()
})
it('should call onToggle(false) when close button is clicked', () => {
const onToggle = vi.fn()
render(<TocPanel {...defaultProps} isTocExpanded onToggle={onToggle} />)
fireEvent.click(screen.getByLabelText('Close'))
expect(onToggle).toHaveBeenCalledWith(false)
})
})
// Covers expanded state with toc items
describe('expanded state - with items', () => {
it('should render all toc items as links', () => {
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
expect(screen.getByText('Introduction')).toBeInTheDocument()
expect(screen.getByText('Authentication')).toBeInTheDocument()
expect(screen.getByText('Endpoints')).toBeInTheDocument()
})
it('should render links with correct href attributes', () => {
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
const links = screen.getAllByRole('link')
expect(links).toHaveLength(3)
expect(links[0]).toHaveAttribute('href', '#introduction')
expect(links[1]).toHaveAttribute('href', '#authentication')
expect(links[2]).toHaveAttribute('href', '#endpoints')
})
it('should not render empty message when toc has items', () => {
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
expect(screen.queryByText('appApi.develop.noContent')).not.toBeInTheDocument()
})
})
// Covers active section highlighting
describe('active section', () => {
it('should apply active style to the matching toc item', () => {
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="authentication" />,
)
const activeLink = screen.getByText('Authentication').closest('a')
expect(activeLink?.className).toContain('font-medium')
expect(activeLink?.className).toContain('text-text-primary')
})
it('should apply inactive style to non-matching items', () => {
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="authentication" />,
)
const inactiveLink = screen.getByText('Introduction').closest('a')
expect(inactiveLink?.className).toContain('text-text-tertiary')
expect(inactiveLink?.className).not.toContain('font-medium')
})
it('should apply active indicator dot to active item', () => {
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="endpoints" />,
)
const activeLink = screen.getByText('Endpoints').closest('a')
const activeDot = activeLink?.querySelector('span:first-child')
expect(activeDot?.className).toContain('bg-text-accent')
})
})
// Covers click event delegation
describe('item click handling', () => {
it('should call onItemClick with the event and item when a link is clicked', () => {
const onItemClick = vi.fn()
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />,
)
fireEvent.click(screen.getByText('Authentication'))
expect(onItemClick).toHaveBeenCalledTimes(1)
expect(onItemClick).toHaveBeenCalledWith(
expect.any(Object),
{ href: '#authentication', text: 'Authentication' },
)
})
it('should call onItemClick for each clicked item independently', () => {
const onItemClick = vi.fn()
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />,
)
fireEvent.click(screen.getByText('Introduction'))
fireEvent.click(screen.getByText('Endpoints'))
expect(onItemClick).toHaveBeenCalledTimes(2)
})
})
// Covers edge cases
describe('edge cases', () => {
it('should handle single item toc', () => {
const singleItem = [{ href: '#only', text: 'Only Section' }]
render(<TocPanel {...defaultProps} isTocExpanded toc={singleItem} activeSection="only" />)
expect(screen.getByText('Only Section')).toBeInTheDocument()
expect(screen.getAllByRole('link')).toHaveLength(1)
})
it('should handle toc items with empty text', () => {
const emptyTextItem = [{ href: '#empty', text: '' }]
render(<TocPanel {...defaultProps} isTocExpanded toc={emptyTextItem} />)
expect(screen.getAllByRole('link')).toHaveLength(1)
})
it('should handle active section that does not match any item', () => {
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="nonexistent" />,
)
// All items should be in inactive style
const links = screen.getAllByRole('link')
links.forEach((link) => {
expect(link.className).toContain('text-text-tertiary')
expect(link.className).not.toContain('font-medium')
})
})
})
})

View File

@ -0,0 +1,425 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocToc } from '../hooks/use-doc-toc'
/**
* Unit tests for the useDocToc custom hook.
* Covers TOC extraction, viewport-based expansion, scroll tracking, and click handling.
*/
describe('useDocToc', () => {
const defaultOptions = { appDetail: { mode: 'chat' }, locale: 'en-US' }
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: false }),
})
})
// Covers initial state values based on viewport width
describe('initial state', () => {
it('should set isTocExpanded to false on narrow viewport', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(false)
expect(result.current.toc).toEqual([])
expect(result.current.activeSection).toBe('')
})
it('should set isTocExpanded to true on wide viewport', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(true)
})
})
// Covers TOC extraction from DOM article headings
describe('TOC extraction', () => {
it('should extract toc items from article h2 anchors', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#section-1'
anchor.textContent = 'Section 1'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([
{ href: '#section-1', text: 'Section 1' },
])
expect(result.current.activeSection).toBe('section-1')
document.body.removeChild(article)
vi.useRealTimers()
})
it('should return empty toc when no article exists', async () => {
vi.useFakeTimers()
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([])
expect(result.current.activeSection).toBe('')
vi.useRealTimers()
})
it('should skip h2 headings without anchors', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2NoAnchor = document.createElement('h2')
h2NoAnchor.textContent = 'No Anchor'
article.appendChild(h2NoAnchor)
const h2WithAnchor = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#valid'
anchor.textContent = 'Valid'
h2WithAnchor.appendChild(anchor)
article.appendChild(h2WithAnchor)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
expect(result.current.toc[0]).toEqual({ href: '#valid', text: 'Valid' })
document.body.removeChild(article)
vi.useRealTimers()
})
it('should re-extract toc when appDetail changes', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
document.body.appendChild(article)
const { result, rerender } = renderHook(
props => useDocToc(props),
{ initialProps: defaultOptions },
)
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([])
// Add a heading, then change appDetail to trigger re-extraction
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#new-section'
anchor.textContent = 'New Section'
h2.appendChild(anchor)
article.appendChild(h2)
rerender({ appDetail: { mode: 'workflow' }, locale: 'en-US' })
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
document.body.removeChild(article)
vi.useRealTimers()
})
it('should re-extract toc when locale changes', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#sec'
anchor.textContent = 'Sec'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result, rerender } = renderHook(
props => useDocToc(props),
{ initialProps: defaultOptions },
)
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
rerender({ appDetail: defaultOptions.appDetail, locale: 'zh-Hans' })
await act(async () => {
vi.runAllTimers()
})
// Should still have the toc item after re-extraction
expect(result.current.toc).toHaveLength(1)
document.body.removeChild(article)
vi.useRealTimers()
})
})
// Covers manual toggle via setIsTocExpanded
describe('setIsTocExpanded', () => {
it('should toggle isTocExpanded state', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(false)
act(() => {
result.current.setIsTocExpanded(true)
})
expect(result.current.isTocExpanded).toBe(true)
act(() => {
result.current.setIsTocExpanded(false)
})
expect(result.current.isTocExpanded).toBe(false)
})
})
// Covers smooth-scroll click handler
describe('handleTocClick', () => {
it('should prevent default and scroll to target element', () => {
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
scrollContainer.scrollTo = vi.fn()
document.body.appendChild(scrollContainer)
const target = document.createElement('div')
target.id = 'target-section'
Object.defineProperty(target, 'offsetTop', { value: 500 })
scrollContainer.appendChild(target)
const { result } = renderHook(() => useDocToc(defaultOptions))
const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>
act(() => {
result.current.handleTocClick(mockEvent, { href: '#target-section', text: 'Target' })
})
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(scrollContainer.scrollTo).toHaveBeenCalledWith({
top: 420, // 500 - 80 (HEADER_OFFSET)
behavior: 'smooth',
})
document.body.removeChild(scrollContainer)
})
it('should do nothing when target element does not exist', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>
act(() => {
result.current.handleTocClick(mockEvent, { href: '#nonexistent', text: 'Missing' })
})
expect(mockEvent.preventDefault).toHaveBeenCalled()
})
})
// Covers scroll-based active section tracking
describe('scroll tracking', () => {
// Helper: set up DOM with scroll container, article headings, and matching target elements
const setupScrollDOM = (sections: Array<{ id: string, text: string, top: number }>) => {
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
document.body.appendChild(scrollContainer)
const article = document.createElement('article')
sections.forEach(({ id, text, top }) => {
// Heading with anchor for TOC extraction
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = `#${id}`
anchor.textContent = text
h2.appendChild(anchor)
article.appendChild(h2)
// Target element for scroll tracking
const target = document.createElement('div')
target.id = id
target.getBoundingClientRect = vi.fn().mockReturnValue({ top })
scrollContainer.appendChild(target)
})
document.body.appendChild(article)
return {
scrollContainer,
article,
cleanup: () => {
document.body.removeChild(scrollContainer)
document.body.removeChild(article)
},
}
}
it('should register scroll listener when toc has items', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'sec-a', text: 'Section A', top: 0 },
])
const addSpy = vi.spyOn(scrollContainer, 'addEventListener')
const removeSpy = vi.spyOn(scrollContainer, 'removeEventListener')
const { unmount } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
unmount()
expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
cleanup()
vi.useRealTimers()
})
it('should update activeSection when scrolling past a section', async () => {
vi.useFakeTimers()
// innerHeight/2 = 384 in jsdom (default 768), so top <= 384 means "scrolled past"
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'intro', text: 'Intro', top: 100 },
{ id: 'details', text: 'Details', top: 600 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
// Extract TOC items
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(2)
expect(result.current.activeSection).toBe('intro')
// Fire scroll — 'intro' (top=100) is above midpoint, 'details' (top=600) is below
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe('intro')
cleanup()
vi.useRealTimers()
})
it('should track the last section above the viewport midpoint', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'sec-1', text: 'Section 1', top: 50 },
{ id: 'sec-2', text: 'Section 2', top: 200 },
{ id: 'sec-3', text: 'Section 3', top: 800 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
// Fire scroll — sec-1 (top=50) and sec-2 (top=200) are above midpoint (384),
// sec-3 (top=800) is below. The last one above midpoint wins.
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe('sec-2')
cleanup()
vi.useRealTimers()
})
it('should not update activeSection when no section is above midpoint', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'far-away', text: 'Far Away', top: 1000 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
// Initial activeSection is set by extraction
const initialSection = result.current.activeSection
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
// Should not change since the element is below midpoint
expect(result.current.activeSection).toBe(initialSection)
cleanup()
vi.useRealTimers()
})
it('should handle elements not found in DOM during scroll', async () => {
vi.useFakeTimers()
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
document.body.appendChild(scrollContainer)
// Article with heading but NO matching target element by id
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#missing-target'
anchor.textContent = 'Missing'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
const initialSection = result.current.activeSection
// Scroll fires but getElementById returns null — no crash, no change
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe(initialSection)
document.body.removeChild(scrollContainer)
document.body.removeChild(article)
vi.useRealTimers()
})
})
})

View File

@ -1,12 +1,13 @@
'use client'
import { RiCloseLine, RiListUnordered } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ComponentType } from 'react'
import type { App, AppSSO } from '@/types/app'
import { useMemo } from 'react'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { LanguagesSupported } from '@/i18n-config/language'
import { getDocLanguage } from '@/i18n-config/language'
import { AppModeEnum, Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import { useDocToc } from './hooks/use-doc-toc'
import TemplateEn from './template/template.en.mdx'
import TemplateJa from './template/template.ja.mdx'
import TemplateZh from './template/template.zh.mdx'
@ -19,225 +20,75 @@ import TemplateChatZh from './template/template_chat.zh.mdx'
import TemplateWorkflowEn from './template/template_workflow.en.mdx'
import TemplateWorkflowJa from './template/template_workflow.ja.mdx'
import TemplateWorkflowZh from './template/template_workflow.zh.mdx'
import TocPanel from './toc-panel'
type AppDetail = App & Partial<AppSSO>
type PromptVariable = { key: string, name: string }
type IDocProps = {
appDetail: any
appDetail: AppDetail
}
// Shared props shape for all MDX template components
type TemplateProps = {
appDetail: AppDetail
variables: PromptVariable[]
inputs: Record<string, string>
}
// Lookup table: [appMode][docLanguage] → template component
// MDX components accept arbitrary props at runtime but expose a narrow static type,
// so we assert the map type to allow passing TemplateProps when rendering.
const TEMPLATE_MAP = {
[AppModeEnum.CHAT]: { zh: TemplateChatZh, ja: TemplateChatJa, en: TemplateChatEn },
[AppModeEnum.AGENT_CHAT]: { zh: TemplateChatZh, ja: TemplateChatJa, en: TemplateChatEn },
[AppModeEnum.ADVANCED_CHAT]: { zh: TemplateAdvancedChatZh, ja: TemplateAdvancedChatJa, en: TemplateAdvancedChatEn },
[AppModeEnum.WORKFLOW]: { zh: TemplateWorkflowZh, ja: TemplateWorkflowJa, en: TemplateWorkflowEn },
[AppModeEnum.COMPLETION]: { zh: TemplateZh, ja: TemplateJa, en: TemplateEn },
} as Record<string, Record<string, ComponentType<TemplateProps>>>
const resolveTemplate = (mode: string | undefined, locale: string): ComponentType<TemplateProps> | null => {
if (!mode)
return null
const langTemplates = TEMPLATE_MAP[mode]
if (!langTemplates)
return null
const docLang = getDocLanguage(locale)
return langTemplates[docLang] ?? langTemplates.en ?? null
}
const Doc = ({ appDetail }: IDocProps) => {
const locale = useLocale()
const { t } = useTranslation()
const [toc, setToc] = useState<Array<{ href: string, text: string }>>([])
const [isTocExpanded, setIsTocExpanded] = useState(false)
const [activeSection, setActiveSection] = useState<string>('')
const { theme } = useTheme()
const { toc, isTocExpanded, setIsTocExpanded, activeSection, handleTocClick } = useDocToc({ appDetail, locale })
const variables = appDetail?.model_config?.configs?.prompt_variables || []
const inputs = variables.reduce((res: any, variable: any) => {
// model_config.configs.prompt_variables exists in the raw API response but is not modeled in ModelConfig type
const variables: PromptVariable[] = (
appDetail?.model_config as unknown as Record<string, Record<string, PromptVariable[]>> | undefined
)?.configs?.prompt_variables ?? []
const inputs = variables.reduce<Record<string, string>>((res, variable) => {
res[variable.key] = variable.name || ''
return res
}, {})
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1280px)')
setIsTocExpanded(mediaQuery.matches)
}, [])
useEffect(() => {
const extractTOC = () => {
const article = document.querySelector('article')
if (article) {
const headings = article.querySelectorAll('h2')
const tocItems = Array.from(headings).map((heading) => {
const anchor = heading.querySelector('a')
if (anchor) {
return {
href: anchor.getAttribute('href') || '',
text: anchor.textContent || '',
}
}
return null
}).filter((item): item is { href: string, text: string } => item !== null)
setToc(tocItems)
if (tocItems.length > 0)
setActiveSection(tocItems[0].href.replace('#', ''))
}
}
setTimeout(extractTOC, 0)
}, [appDetail, locale])
useEffect(() => {
const handleScroll = () => {
const scrollContainer = document.querySelector('.overflow-auto')
if (!scrollContainer || toc.length === 0)
return
let currentSection = ''
toc.forEach((item) => {
const targetId = item.href.replace('#', '')
const element = document.getElementById(targetId)
if (element) {
const rect = element.getBoundingClientRect()
if (rect.top <= window.innerHeight / 2)
currentSection = targetId
}
})
if (currentSection && currentSection !== activeSection)
setActiveSection(currentSection)
}
const scrollContainer = document.querySelector('.overflow-auto')
if (scrollContainer) {
scrollContainer.addEventListener('scroll', handleScroll)
handleScroll()
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [toc, activeSection])
const handleTocClick = (e: React.MouseEvent<HTMLAnchorElement>, item: { href: string, text: string }) => {
e.preventDefault()
const targetId = item.href.replace('#', '')
const element = document.getElementById(targetId)
if (element) {
const scrollContainer = document.querySelector('.overflow-auto')
if (scrollContainer) {
const headerOffset = 80
const elementTop = element.offsetTop - headerOffset
scrollContainer.scrollTo({
top: elementTop,
behavior: 'smooth',
})
}
}
}
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT || appDetail?.mode === AppModeEnum.AGENT_CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateChatJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateChatEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateAdvancedChatZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateAdvancedChatJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateAdvancedChatEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === AppModeEnum.WORKFLOW) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateWorkflowZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateWorkflowJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateWorkflowEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === AppModeEnum.COMPLETION) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
return null
}, [appDetail, locale, variables, inputs])
const TemplateComponent = useMemo(
() => resolveTemplate(appDetail?.mode, locale),
[appDetail?.mode, locale],
)
return (
<div className="flex">
<div className={`fixed right-20 top-32 z-10 transition-all duration-150 ease-out ${isTocExpanded ? 'w-[280px]' : 'w-11'}`}>
{isTocExpanded
? (
<nav className="toc flex max-h-[calc(100vh-150px)] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-background-default-hover shadow-xl">
<div className="relative z-10 flex items-center justify-between border-b border-components-panel-border-subtle bg-background-default-hover px-4 py-2.5">
<span className="text-xs font-medium uppercase tracking-wide text-text-tertiary">
{t('develop.toc', { ns: 'appApi' })}
</span>
<button
type="button"
onClick={() => setIsTocExpanded(false)}
className="group flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-state-base-hover"
aria-label="Close"
>
<RiCloseLine className="h-3 w-3 text-text-quaternary transition-colors group-hover:text-text-secondary" />
</button>
</div>
<div className="from-components-panel-border-subtle/20 pointer-events-none absolute left-0 right-0 top-[41px] z-10 h-2 bg-gradient-to-b to-transparent"></div>
<div className="pointer-events-none absolute left-0 right-0 top-[43px] z-10 h-3 bg-gradient-to-b from-background-default-hover to-transparent"></div>
<div className="relative flex-1 overflow-y-auto px-3 py-3 pt-1">
{toc.length === 0
? (
<div className="px-2 py-8 text-center text-xs text-text-quaternary">
{t('develop.noContent', { ns: 'appApi' })}
</div>
)
: (
<ul className="space-y-0.5">
{toc.map((item, index) => {
const isActive = activeSection === item.href.replace('#', '')
return (
<li key={index}>
<a
href={item.href}
onClick={e => handleTocClick(e, item)}
className={cn(
'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200',
isActive
? 'bg-state-base-hover font-medium text-text-primary'
: 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
)}
>
<span
className={cn(
'mr-2 h-1.5 w-1.5 rounded-full transition-all duration-200',
isActive
? 'scale-100 bg-text-accent'
: 'scale-75 bg-components-panel-border',
)}
/>
<span className="flex-1 truncate">
{item.text}
</span>
</a>
</li>
)
})}
</ul>
)}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-4 rounded-b-xl bg-gradient-to-t from-background-default-hover to-transparent"></div>
</nav>
)
: (
<button
type="button"
onClick={() => setIsTocExpanded(true)}
className="group flex h-11 w-11 items-center justify-center rounded-full border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-all duration-150 hover:bg-background-default-hover hover:shadow-xl"
aria-label="Open table of contents"
>
<RiListUnordered className="h-5 w-5 text-text-tertiary transition-colors group-hover:text-text-secondary" />
</button>
)}
<TocPanel
toc={toc}
activeSection={activeSection}
isTocExpanded={isTocExpanded}
onToggle={setIsTocExpanded}
onItemClick={handleTocClick}
/>
</div>
<article className={cn('prose-xl prose', theme === Theme.dark && 'prose-invert')}>
{Template}
{TemplateComponent && <TemplateComponent appDetail={appDetail} variables={variables} inputs={inputs} />}
</article>
</div>
)

View File

@ -0,0 +1,115 @@
import { useCallback, useEffect, useState } from 'react'
export type TocItem = {
href: string
text: string
}
type UseDocTocOptions = {
appDetail: Record<string, unknown> | null
locale: string
}
const HEADER_OFFSET = 80
const SCROLL_CONTAINER_SELECTOR = '.overflow-auto'
const getTargetId = (href: string) => href.replace('#', '')
/**
* Extract heading anchors from the rendered <article> as TOC items.
*/
const extractTocFromArticle = (): TocItem[] => {
const article = document.querySelector('article')
if (!article)
return []
return Array.from(article.querySelectorAll('h2'))
.map((heading) => {
const anchor = heading.querySelector('a')
if (!anchor)
return null
return {
href: anchor.getAttribute('href') || '',
text: anchor.textContent || '',
}
})
.filter((item): item is TocItem => item !== null)
}
/**
* Custom hook that manages table-of-contents state:
* - Extracts TOC items from rendered headings
* - Tracks the active section on scroll
* - Auto-expands the panel on wide viewports
*/
export const useDocToc = ({ appDetail, locale }: UseDocTocOptions) => {
const [toc, setToc] = useState<TocItem[]>([])
const [isTocExpanded, setIsTocExpanded] = useState(() => {
if (typeof window === 'undefined')
return false
return window.matchMedia('(min-width: 1280px)').matches
})
const [activeSection, setActiveSection] = useState<string>('')
// Re-extract TOC items whenever the doc content changes
useEffect(() => {
const timer = setTimeout(() => {
const tocItems = extractTocFromArticle()
setToc(tocItems)
if (tocItems.length > 0)
setActiveSection(getTargetId(tocItems[0].href))
}, 0)
return () => clearTimeout(timer)
}, [appDetail, locale])
// Track active section based on scroll position
useEffect(() => {
const scrollContainer = document.querySelector(SCROLL_CONTAINER_SELECTOR)
if (!scrollContainer || toc.length === 0)
return
const handleScroll = () => {
let currentSection = ''
for (const item of toc) {
const targetId = getTargetId(item.href)
const element = document.getElementById(targetId)
if (element) {
const rect = element.getBoundingClientRect()
if (rect.top <= window.innerHeight / 2)
currentSection = targetId
}
}
if (currentSection && currentSection !== activeSection)
setActiveSection(currentSection)
}
scrollContainer.addEventListener('scroll', handleScroll)
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}, [toc, activeSection])
// Smooth-scroll to a TOC target on click
const handleTocClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: TocItem) => {
e.preventDefault()
const targetId = getTargetId(item.href)
const element = document.getElementById(targetId)
if (!element)
return
const scrollContainer = document.querySelector(SCROLL_CONTAINER_SELECTOR)
if (scrollContainer) {
scrollContainer.scrollTo({
top: element.offsetTop - HEADER_OFFSET,
behavior: 'smooth',
})
}
}, [])
return {
toc,
isTocExpanded,
setIsTocExpanded,
activeSection,
handleTocClick,
}
}

View File

@ -0,0 +1,96 @@
'use client'
import type { TocItem } from './hooks/use-doc-toc'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
type TocPanelProps = {
toc: TocItem[]
activeSection: string
isTocExpanded: boolean
onToggle: (expanded: boolean) => void
onItemClick: (e: React.MouseEvent<HTMLAnchorElement>, item: TocItem) => void
}
const TocPanel = ({ toc, activeSection, isTocExpanded, onToggle, onItemClick }: TocPanelProps) => {
const { t } = useTranslation()
if (!isTocExpanded) {
return (
<button
type="button"
onClick={() => onToggle(true)}
className="group flex h-11 w-11 items-center justify-center rounded-full border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-all duration-150 hover:bg-background-default-hover hover:shadow-xl"
aria-label="Open table of contents"
>
<span className="i-ri-list-unordered h-5 w-5 text-text-tertiary transition-colors group-hover:text-text-secondary" />
</button>
)
}
return (
<nav className="toc flex max-h-[calc(100vh-150px)] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-background-default-hover shadow-xl">
<div className="relative z-10 flex items-center justify-between border-b border-components-panel-border-subtle bg-background-default-hover px-4 py-2.5">
<span className="text-xs font-medium uppercase tracking-wide text-text-tertiary">
{t('develop.toc', { ns: 'appApi' })}
</span>
<button
type="button"
onClick={() => onToggle(false)}
className="group flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-state-base-hover"
aria-label="Close"
>
<span className="i-ri-close-line h-3 w-3 text-text-quaternary transition-colors group-hover:text-text-secondary" />
</button>
</div>
<div className="from-components-panel-border-subtle/20 pointer-events-none absolute left-0 right-0 top-[41px] z-10 h-2 bg-gradient-to-b to-transparent"></div>
<div className="pointer-events-none absolute left-0 right-0 top-[43px] z-10 h-3 bg-gradient-to-b from-background-default-hover to-transparent"></div>
<div className="relative flex-1 overflow-y-auto px-3 py-3 pt-1">
{toc.length === 0
? (
<div className="px-2 py-8 text-center text-xs text-text-quaternary">
{t('develop.noContent', { ns: 'appApi' })}
</div>
)
: (
<ul className="space-y-0.5">
{toc.map((item) => {
const isActive = activeSection === item.href.replace('#', '')
return (
<li key={item.href}>
<a
href={item.href}
onClick={e => onItemClick(e, item)}
className={cn(
'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200',
isActive
? 'bg-state-base-hover font-medium text-text-primary'
: 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
)}
>
<span
className={cn(
'mr-2 h-1.5 w-1.5 rounded-full transition-all duration-200',
isActive
? 'scale-100 bg-text-accent'
: 'scale-75 bg-components-panel-border',
)}
/>
<span className="flex-1 truncate">
{item.text}
</span>
</a>
</li>
)
})}
</ul>
)}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-4 rounded-b-xl bg-gradient-to-t from-background-default-hover to-transparent"></div>
</nav>
)
}
export default TocPanel