mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
feat(docs): implement table of contents panel and related hooks for document navigation
- Added component for displaying a collapsible table of contents. - Introduced hook to manage TOC state, including extraction of headings and scroll tracking. - Updated component to utilize the new TOC functionality. - Enhanced tests for and to ensure proper functionality and edge case handling. - Removed unused ESLint suppressions related to file.
This commit is contained in:
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
199
web/app/components/develop/__tests__/toc-panel.spec.tsx
Normal file
199
web/app/components/develop/__tests__/toc-panel.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
425
web/app/components/develop/__tests__/use-doc-toc.spec.ts
Normal file
425
web/app/components/develop/__tests__/use-doc-toc.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
|
||||
115
web/app/components/develop/hooks/use-doc-toc.ts
Normal file
115
web/app/components/develop/hooks/use-doc-toc.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
96
web/app/components/develop/toc-panel.tsx
Normal file
96
web/app/components/develop/toc-panel.tsx
Normal 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
|
||||
@ -61,8 +61,8 @@ vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({}),
|
||||
}))
|
||||
|
||||
// Mock pluginInstallLimit
|
||||
vi.mock('../../../hooks/use-install-plugin-limit', () => ({
|
||||
// Mock pluginInstallLimit (imported by the useInstallMultiState hook via @/ path)
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
|
||||
pluginInstallLimit: () => ({ canInstall: true }),
|
||||
}))
|
||||
|
||||
|
||||
@ -0,0 +1,568 @@
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { getPluginKey, useInstallMultiState } from '../use-install-multi-state'
|
||||
|
||||
let mockMarketplaceData: ReturnType<typeof createMarketplaceApiData> | null = null
|
||||
let mockMarketplaceError: Error | null = null
|
||||
let mockInstalledInfo: Record<string, VersionInfo> = {}
|
||||
let mockCanInstall = true
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFetchPluginsInMarketPlaceByInfo: () => ({
|
||||
isLoading: false,
|
||||
data: mockMarketplaceData,
|
||||
error: mockMarketplaceError,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => ({
|
||||
installedInfo: mockInstalledInfo,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
|
||||
pluginInstallLimit: () => ({ canInstall: mockCanInstall }),
|
||||
}))
|
||||
|
||||
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-pkg-id',
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
label: { 'en-US': 'Test Plugin' },
|
||||
brief: { 'en-US': 'Brief' },
|
||||
description: { 'en-US': 'Description' },
|
||||
introduction: 'Intro',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 100,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPackageDependency = (index: number) => ({
|
||||
type: 'package',
|
||||
value: {
|
||||
unique_identifier: `package-plugin-${index}-uid`,
|
||||
manifest: {
|
||||
plugin_unique_identifier: `package-plugin-${index}-uid`,
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'icon.png',
|
||||
name: `Package Plugin ${index}`,
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': `Package Plugin ${index}` },
|
||||
description: { 'en-US': 'Test package plugin' },
|
||||
created_at: '2024-01-01',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {},
|
||||
},
|
||||
},
|
||||
} as unknown as PackageDependency)
|
||||
|
||||
const createMarketplaceDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: `test-org/plugin-${index}:1.0.0`,
|
||||
plugin_unique_identifier: `plugin-${index}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
})
|
||||
|
||||
const createGitHubDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: `test-org/plugin-${index}`,
|
||||
version: 'v1.0.0',
|
||||
package: `plugin-${index}.zip`,
|
||||
},
|
||||
})
|
||||
|
||||
const createMarketplaceApiData = (indexes: number[]) => ({
|
||||
data: {
|
||||
list: indexes.map(i => ({
|
||||
plugin: {
|
||||
plugin_id: `test-org/plugin-${i}`,
|
||||
org: 'test-org',
|
||||
name: `Test Plugin ${i}`,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
},
|
||||
version: {
|
||||
unique_identifier: `plugin-${i}-uid`,
|
||||
},
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
const createDefaultParams = (overrides = {}) => ({
|
||||
allPlugins: [createPackageDependency(0)] as Dependency[],
|
||||
selectedPlugins: [] as Plugin[],
|
||||
onSelect: vi.fn(),
|
||||
onLoadedAllPlugin: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ==================== getPluginKey Tests ====================
|
||||
|
||||
describe('getPluginKey', () => {
|
||||
it('should return org/name when org is available', () => {
|
||||
const plugin = createMockPlugin({ org: 'my-org', name: 'my-plugin' })
|
||||
|
||||
expect(getPluginKey(plugin)).toBe('my-org/my-plugin')
|
||||
})
|
||||
|
||||
it('should fall back to author when org is not available', () => {
|
||||
const plugin = createMockPlugin({ org: undefined, author: 'my-author', name: 'my-plugin' })
|
||||
|
||||
expect(getPluginKey(plugin)).toBe('my-author/my-plugin')
|
||||
})
|
||||
|
||||
it('should prefer org over author when both exist', () => {
|
||||
const plugin = createMockPlugin({ org: 'my-org', author: 'my-author', name: 'my-plugin' })
|
||||
|
||||
expect(getPluginKey(plugin)).toBe('my-org/my-plugin')
|
||||
})
|
||||
|
||||
it('should handle undefined plugin', () => {
|
||||
expect(getPluginKey(undefined)).toBe('undefined/undefined')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== useInstallMultiState Tests ====================
|
||||
|
||||
describe('useInstallMultiState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMarketplaceData = null
|
||||
mockMarketplaceError = null
|
||||
mockInstalledInfo = {}
|
||||
mockCanInstall = true
|
||||
})
|
||||
|
||||
// ==================== Initial State ====================
|
||||
describe('Initial State', () => {
|
||||
it('should initialize plugins from package dependencies', () => {
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(1)
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.plugins[0]?.plugin_id).toBe('package-plugin-0-uid')
|
||||
})
|
||||
|
||||
it('should have slots for all dependencies even when no packages exist', () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createGitHubDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
// Array has slots for all dependencies, but unresolved ones are undefined
|
||||
expect(result.current.plugins).toHaveLength(1)
|
||||
expect(result.current.plugins[0]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for non-package items in mixed dependencies', () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(2)
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.plugins[1]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should start with empty errorIndexes', () => {
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.errorIndexes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Marketplace Data Sync ====================
|
||||
describe('Marketplace Data Sync', () => {
|
||||
it('should update plugins when marketplace data loads by ID', async () => {
|
||||
mockMarketplaceData = createMarketplaceApiData([0])
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.plugins[0]?.version).toBe('1.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update plugins when marketplace data loads by meta', async () => {
|
||||
mockMarketplaceData = createMarketplaceApiData([0])
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
// The "by meta" effect sets plugin_id from version.unique_identifier
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should add to errorIndexes when marketplace item not found in response', async () => {
|
||||
mockMarketplaceData = { data: { list: [] } }
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiple marketplace plugins', async () => {
|
||||
mockMarketplaceData = createMarketplaceApiData([0, 1])
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createMarketplaceDependency(0),
|
||||
createMarketplaceDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.plugins[1]).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Error Handling ====================
|
||||
describe('Error Handling', () => {
|
||||
it('should mark all marketplace indexes as errors on fetch failure', async () => {
|
||||
mockMarketplaceError = new Error('Fetch failed')
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createMarketplaceDependency(0),
|
||||
createMarketplaceDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
expect(result.current.errorIndexes).toContain(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not affect non-marketplace indexes on marketplace fetch error', async () => {
|
||||
mockMarketplaceError = new Error('Fetch failed')
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createMarketplaceDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(1)
|
||||
expect(result.current.errorIndexes).not.toContain(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Loaded All Data Notification ====================
|
||||
describe('Loaded All Data Notification', () => {
|
||||
it('should call onLoadedAllPlugin when all data loaded', async () => {
|
||||
const params = createDefaultParams()
|
||||
renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(params.onLoadedAllPlugin).toHaveBeenCalledWith(mockInstalledInfo)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onLoadedAllPlugin when not all plugins resolved', () => {
|
||||
// GitHub plugin not fetched yet → isLoadedAllData = false
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(params.onLoadedAllPlugin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onLoadedAllPlugin after all errors are counted', async () => {
|
||||
mockMarketplaceError = new Error('Fetch failed')
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
renderHook(() => useInstallMultiState(params))
|
||||
|
||||
// Error fills errorIndexes → isLoadedAllData becomes true
|
||||
await waitFor(() => {
|
||||
expect(params.onLoadedAllPlugin).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== handleGitHubPluginFetched ====================
|
||||
describe('handleGitHubPluginFetched', () => {
|
||||
it('should update plugin at the specified index', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createGitHubDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
const mockPlugin = createMockPlugin({ plugin_id: 'github-plugin-0' })
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetched(0)(mockPlugin)
|
||||
})
|
||||
|
||||
expect(result.current.plugins[0]).toEqual(mockPlugin)
|
||||
})
|
||||
|
||||
it('should not affect other plugin slots', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
const originalPlugin0 = result.current.plugins[0]
|
||||
const mockPlugin = createMockPlugin({ plugin_id: 'github-plugin-1' })
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetched(1)(mockPlugin)
|
||||
})
|
||||
|
||||
expect(result.current.plugins[0]).toEqual(originalPlugin0)
|
||||
expect(result.current.plugins[1]).toEqual(mockPlugin)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== handleGitHubPluginFetchError ====================
|
||||
describe('handleGitHubPluginFetchError', () => {
|
||||
it('should add index to errorIndexes', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createGitHubDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetchError(0)()
|
||||
})
|
||||
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
|
||||
it('should accumulate multiple error indexes without stale closure', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createGitHubDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetchError(0)()
|
||||
})
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetchError(1)()
|
||||
})
|
||||
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
expect(result.current.errorIndexes).toContain(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== getVersionInfo ====================
|
||||
describe('getVersionInfo', () => {
|
||||
it('should return hasInstalled false when plugin not installed', () => {
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
const info = result.current.getVersionInfo('unknown/plugin')
|
||||
|
||||
expect(info.hasInstalled).toBe(false)
|
||||
expect(info.installedVersion).toBeUndefined()
|
||||
expect(info.toInstallVersion).toBe('')
|
||||
})
|
||||
|
||||
it('should return hasInstalled true with version when installed', () => {
|
||||
mockInstalledInfo = {
|
||||
'test-author/Package Plugin 0': {
|
||||
installedId: 'installed-1',
|
||||
installedVersion: '0.9.0',
|
||||
uniqueIdentifier: 'uid-1',
|
||||
},
|
||||
}
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
const info = result.current.getVersionInfo('test-author/Package Plugin 0')
|
||||
|
||||
expect(info.hasInstalled).toBe(true)
|
||||
expect(info.installedVersion).toBe('0.9.0')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== handleSelect ====================
|
||||
describe('handleSelect', () => {
|
||||
it('should call onSelect with plugin, index, and installable count', async () => {
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleSelect(0)()
|
||||
})
|
||||
|
||||
expect(params.onSelect).toHaveBeenCalledWith(
|
||||
result.current.plugins[0],
|
||||
0,
|
||||
expect.any(Number),
|
||||
)
|
||||
})
|
||||
|
||||
it('should filter installable plugins using pluginInstallLimit', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createPackageDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleSelect(0)()
|
||||
})
|
||||
|
||||
// mockCanInstall is true, so all 2 plugins are installable
|
||||
expect(params.onSelect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
0,
|
||||
2,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== isPluginSelected ====================
|
||||
describe('isPluginSelected', () => {
|
||||
it('should return true when plugin is in selectedPlugins', () => {
|
||||
const selectedPlugin = createMockPlugin({ plugin_id: 'package-plugin-0-uid' })
|
||||
const params = createDefaultParams({
|
||||
selectedPlugins: [selectedPlugin],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.isPluginSelected(0)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when plugin is not in selectedPlugins', () => {
|
||||
const params = createDefaultParams({ selectedPlugins: [] })
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.isPluginSelected(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when plugin at index is undefined', () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createGitHubDependency(0)] as Dependency[],
|
||||
selectedPlugins: [createMockPlugin()],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
// plugins[0] is undefined (GitHub not yet fetched)
|
||||
expect(result.current.isPluginSelected(0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== getInstallablePlugins ====================
|
||||
describe('getInstallablePlugins', () => {
|
||||
it('should return all plugins when canInstall is true', () => {
|
||||
mockCanInstall = true
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createPackageDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
|
||||
|
||||
expect(installablePlugins).toHaveLength(2)
|
||||
expect(selectedIndexes).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('should return empty arrays when canInstall is false', () => {
|
||||
mockCanInstall = false
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createPackageDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
|
||||
|
||||
expect(installablePlugins).toHaveLength(0)
|
||||
expect(selectedIndexes).toEqual([])
|
||||
})
|
||||
|
||||
it('should skip unloaded (undefined) plugins', () => {
|
||||
mockCanInstall = true
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
|
||||
|
||||
// Only package plugin is loaded; GitHub not yet fetched
|
||||
expect(installablePlugins).toHaveLength(1)
|
||||
expect(selectedIndexes).toEqual([0])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
|
||||
|
||||
type UseInstallMultiStateParams = {
|
||||
allPlugins: Dependency[]
|
||||
selectedPlugins: Plugin[]
|
||||
onSelect: (plugin: Plugin, selectedIndex: number, allCanInstallPluginsLength: number) => void
|
||||
onLoadedAllPlugin: (installedInfo: Record<string, VersionInfo>) => void
|
||||
}
|
||||
|
||||
export function getPluginKey(plugin: Plugin | undefined): string {
|
||||
return `${plugin?.org || plugin?.author}/${plugin?.name}`
|
||||
}
|
||||
|
||||
function parseMarketplaceIdentifier(identifier: string) {
|
||||
const [orgPart, nameAndVersionPart] = identifier.split('@')[0].split('/')
|
||||
const [name, version] = nameAndVersionPart.split(':')
|
||||
return { organization: orgPart, plugin: name, version }
|
||||
}
|
||||
|
||||
function initPluginsFromDependencies(allPlugins: Dependency[]): (Plugin | undefined)[] {
|
||||
if (!allPlugins.some(d => d.type === 'package'))
|
||||
return []
|
||||
|
||||
return allPlugins.map((d) => {
|
||||
if (d.type !== 'package')
|
||||
return undefined
|
||||
const { manifest, unique_identifier } = (d as PackageDependency).value
|
||||
return {
|
||||
...manifest,
|
||||
plugin_id: unique_identifier,
|
||||
} as unknown as Plugin
|
||||
})
|
||||
}
|
||||
|
||||
export function useInstallMultiState({
|
||||
allPlugins,
|
||||
selectedPlugins,
|
||||
onSelect,
|
||||
onLoadedAllPlugin,
|
||||
}: UseInstallMultiStateParams) {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
// Marketplace plugins filtering and index mapping
|
||||
const marketplacePlugins = useMemo(
|
||||
() => allPlugins.filter((d): d is GitHubItemAndMarketPlaceDependency => d.type === 'marketplace'),
|
||||
[allPlugins],
|
||||
)
|
||||
|
||||
const marketPlaceInDSLIndex = useMemo(() => {
|
||||
return allPlugins.reduce<number[]>((acc, d, index) => {
|
||||
if (d.type === 'marketplace')
|
||||
acc.push(index)
|
||||
return acc
|
||||
}, [])
|
||||
}, [allPlugins])
|
||||
|
||||
// Marketplace data fetching: by unique identifier and by meta info
|
||||
const {
|
||||
isLoading: isFetchingById,
|
||||
data: infoGetById,
|
||||
error: infoByIdError,
|
||||
} = useFetchPluginsInMarketPlaceByInfo(
|
||||
marketplacePlugins.map(d => parseMarketplaceIdentifier(d.value.marketplace_plugin_unique_identifier!)),
|
||||
)
|
||||
|
||||
const {
|
||||
isLoading: isFetchingByMeta,
|
||||
data: infoByMeta,
|
||||
error: infoByMetaError,
|
||||
} = useFetchPluginsInMarketPlaceByInfo(
|
||||
marketplacePlugins.map(d => d.value!),
|
||||
)
|
||||
|
||||
// Derive marketplace plugin data and errors from API responses
|
||||
const { marketplacePluginMap, marketplaceErrorIndexes } = useMemo(() => {
|
||||
const pluginMap = new Map<number, Plugin>()
|
||||
const errorSet = new Set<number>()
|
||||
|
||||
// Process "by ID" response
|
||||
if (!isFetchingById && infoGetById?.data.list) {
|
||||
const sortedList = marketplacePlugins.map((d) => {
|
||||
const id = d.value.marketplace_plugin_unique_identifier?.split(':')[0]
|
||||
const retPluginInfo = infoGetById.data.list.find(item => item.plugin.plugin_id === id)?.plugin
|
||||
return { ...retPluginInfo, from: d.type } as Plugin
|
||||
})
|
||||
marketPlaceInDSLIndex.forEach((index, i) => {
|
||||
if (sortedList[i]) {
|
||||
pluginMap.set(index, {
|
||||
...sortedList[i],
|
||||
version: sortedList[i]!.version || sortedList[i]!.latest_version,
|
||||
})
|
||||
}
|
||||
else { errorSet.add(index) }
|
||||
})
|
||||
}
|
||||
|
||||
// Process "by meta" response (may overwrite "by ID" results)
|
||||
if (!isFetchingByMeta && infoByMeta?.data.list) {
|
||||
const payloads = infoByMeta.data.list
|
||||
marketPlaceInDSLIndex.forEach((index, i) => {
|
||||
if (payloads[i]) {
|
||||
const item = payloads[i]
|
||||
pluginMap.set(index, {
|
||||
...item.plugin,
|
||||
plugin_id: item.version.unique_identifier,
|
||||
} as Plugin)
|
||||
}
|
||||
else { errorSet.add(index) }
|
||||
})
|
||||
}
|
||||
|
||||
// Mark all marketplace indexes as errors on fetch failure
|
||||
if (infoByMetaError || infoByIdError)
|
||||
marketPlaceInDSLIndex.forEach(index => errorSet.add(index))
|
||||
|
||||
return { marketplacePluginMap: pluginMap, marketplaceErrorIndexes: errorSet }
|
||||
}, [isFetchingById, isFetchingByMeta, infoGetById, infoByMeta, infoByMetaError, infoByIdError, marketPlaceInDSLIndex, marketplacePlugins])
|
||||
|
||||
// GitHub-fetched plugins and errors (imperative state from child callbacks)
|
||||
const [githubPluginMap, setGithubPluginMap] = useState<Map<number, Plugin>>(() => new Map())
|
||||
const [githubErrorIndexes, setGithubErrorIndexes] = useState<number[]>([])
|
||||
|
||||
// Merge all plugin sources into a single array
|
||||
const plugins = useMemo(() => {
|
||||
const initial = initPluginsFromDependencies(allPlugins)
|
||||
const result: (Plugin | undefined)[] = allPlugins.map((_, i) => initial[i])
|
||||
marketplacePluginMap.forEach((plugin, index) => {
|
||||
result[index] = plugin
|
||||
})
|
||||
githubPluginMap.forEach((plugin, index) => {
|
||||
result[index] = plugin
|
||||
})
|
||||
return result
|
||||
}, [allPlugins, marketplacePluginMap, githubPluginMap])
|
||||
|
||||
// Merge all error sources
|
||||
const errorIndexes = useMemo(() => {
|
||||
return [...marketplaceErrorIndexes, ...githubErrorIndexes]
|
||||
}, [marketplaceErrorIndexes, githubErrorIndexes])
|
||||
|
||||
// Check installed status after all data is loaded
|
||||
const isLoadedAllData = (plugins.filter(Boolean).length + errorIndexes.length) === allPlugins.length
|
||||
|
||||
const { installedInfo } = useCheckInstalled({
|
||||
pluginIds: plugins.filter(Boolean).map(d => getPluginKey(d)) || [],
|
||||
enabled: isLoadedAllData,
|
||||
})
|
||||
|
||||
// Notify parent when all plugin data and install info is ready
|
||||
useEffect(() => {
|
||||
if (isLoadedAllData && installedInfo)
|
||||
onLoadedAllPlugin(installedInfo!)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoadedAllData, installedInfo])
|
||||
|
||||
// Callback: handle GitHub plugin fetch success
|
||||
const handleGitHubPluginFetched = useCallback((index: number) => {
|
||||
return (p: Plugin) => {
|
||||
setGithubPluginMap(prev => new Map(prev).set(index, p))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Callback: handle GitHub plugin fetch error
|
||||
const handleGitHubPluginFetchError = useCallback((index: number) => {
|
||||
return () => {
|
||||
setGithubErrorIndexes(prev => [...prev, index])
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Callback: get version info for a plugin by its key
|
||||
const getVersionInfo = useCallback((pluginId: string) => {
|
||||
const pluginDetail = installedInfo?.[pluginId]
|
||||
return {
|
||||
hasInstalled: !!pluginDetail,
|
||||
installedVersion: pluginDetail?.installedVersion,
|
||||
toInstallVersion: '',
|
||||
}
|
||||
}, [installedInfo])
|
||||
|
||||
// Callback: handle plugin selection
|
||||
const handleSelect = useCallback((index: number) => {
|
||||
return () => {
|
||||
const canSelectPlugins = plugins.filter((p) => {
|
||||
const { canInstall } = pluginInstallLimit(p!, systemFeatures)
|
||||
return canInstall
|
||||
})
|
||||
onSelect(plugins[index]!, index, canSelectPlugins.length)
|
||||
}
|
||||
}, [onSelect, plugins, systemFeatures])
|
||||
|
||||
// Callback: check if a plugin at given index is selected
|
||||
const isPluginSelected = useCallback((index: number) => {
|
||||
return !!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)
|
||||
}, [selectedPlugins, plugins])
|
||||
|
||||
// Callback: get all installable plugins with their indexes
|
||||
const getInstallablePlugins = useCallback(() => {
|
||||
const selectedIndexes: number[] = []
|
||||
const installablePlugins: Plugin[] = []
|
||||
allPlugins.forEach((_d, index) => {
|
||||
const p = plugins[index]
|
||||
if (!p)
|
||||
return
|
||||
const { canInstall } = pluginInstallLimit(p, systemFeatures)
|
||||
if (canInstall) {
|
||||
selectedIndexes.push(index)
|
||||
installablePlugins.push(p)
|
||||
}
|
||||
})
|
||||
return { selectedIndexes, installablePlugins }
|
||||
}, [allPlugins, plugins, systemFeatures])
|
||||
|
||||
return {
|
||||
plugins,
|
||||
errorIndexes,
|
||||
handleGitHubPluginFetched,
|
||||
handleGitHubPluginFetchError,
|
||||
getVersionInfo,
|
||||
handleSelect,
|
||||
isPluginSelected,
|
||||
getInstallablePlugins,
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,12 @@
|
||||
'use client'
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
|
||||
import { useImperativeHandle } from 'react'
|
||||
import LoadingError from '../../base/loading-error'
|
||||
import { pluginInstallLimit } from '../../hooks/use-install-plugin-limit'
|
||||
import GithubItem from '../item/github-item'
|
||||
import MarketplaceItem from '../item/marketplace-item'
|
||||
import PackageItem from '../item/package-item'
|
||||
import { getPluginKey, useInstallMultiState } from './hooks/use-install-multi-state'
|
||||
|
||||
type Props = {
|
||||
allPlugins: Dependency[]
|
||||
@ -38,206 +34,50 @@ const InstallByDSLList = ({
|
||||
isFromMarketPlace,
|
||||
ref,
|
||||
}: Props) => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
// DSL has id, to get plugin info to show more info
|
||||
const { isLoading: isFetchingMarketplaceDataById, data: infoGetById, error: infoByIdError } = useFetchPluginsInMarketPlaceByInfo(allPlugins.filter(d => d.type === 'marketplace').map((d) => {
|
||||
const dependecy = (d as GitHubItemAndMarketPlaceDependency).value
|
||||
// split org, name, version by / and :
|
||||
// and remove @ and its suffix
|
||||
const [orgPart, nameAndVersionPart] = dependecy.marketplace_plugin_unique_identifier!.split('@')[0].split('/')
|
||||
const [name, version] = nameAndVersionPart.split(':')
|
||||
return {
|
||||
organization: orgPart,
|
||||
plugin: name,
|
||||
version,
|
||||
}
|
||||
}))
|
||||
// has meta(org,name,version), to get id
|
||||
const { isLoading: isFetchingDataByMeta, data: infoByMeta, error: infoByMetaError } = useFetchPluginsInMarketPlaceByInfo(allPlugins.filter(d => d.type === 'marketplace').map(d => (d as GitHubItemAndMarketPlaceDependency).value!))
|
||||
|
||||
const [plugins, doSetPlugins] = useState<(Plugin | undefined)[]>((() => {
|
||||
const hasLocalPackage = allPlugins.some(d => d.type === 'package')
|
||||
if (!hasLocalPackage)
|
||||
return []
|
||||
|
||||
const _plugins = allPlugins.map((d) => {
|
||||
if (d.type === 'package') {
|
||||
return {
|
||||
...(d as any).value.manifest,
|
||||
plugin_id: (d as any).value.unique_identifier,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
return _plugins
|
||||
})())
|
||||
|
||||
const pluginsRef = React.useRef<(Plugin | undefined)[]>(plugins)
|
||||
|
||||
const setPlugins = useCallback((p: (Plugin | undefined)[]) => {
|
||||
doSetPlugins(p)
|
||||
pluginsRef.current = p
|
||||
}, [])
|
||||
|
||||
const [errorIndexes, setErrorIndexes] = useState<number[]>([])
|
||||
|
||||
const handleGitHubPluginFetched = useCallback((index: number) => {
|
||||
return (p: Plugin) => {
|
||||
const nextPlugins = produce(pluginsRef.current, (draft) => {
|
||||
draft[index] = p
|
||||
})
|
||||
setPlugins(nextPlugins)
|
||||
}
|
||||
}, [setPlugins])
|
||||
|
||||
const handleGitHubPluginFetchError = useCallback((index: number) => {
|
||||
return () => {
|
||||
setErrorIndexes([...errorIndexes, index])
|
||||
}
|
||||
}, [errorIndexes])
|
||||
|
||||
const marketPlaceInDSLIndex = useMemo(() => {
|
||||
const res: number[] = []
|
||||
allPlugins.forEach((d, index) => {
|
||||
if (d.type === 'marketplace')
|
||||
res.push(index)
|
||||
})
|
||||
return res
|
||||
}, [allPlugins])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingMarketplaceDataById && infoGetById?.data.list) {
|
||||
const sortedList = allPlugins.filter(d => d.type === 'marketplace').map((d) => {
|
||||
const p = d as GitHubItemAndMarketPlaceDependency
|
||||
const id = p.value.marketplace_plugin_unique_identifier?.split(':')[0]
|
||||
const retPluginInfo = infoGetById.data.list.find(item => item.plugin.plugin_id === id)?.plugin
|
||||
return { ...retPluginInfo, from: d.type } as Plugin
|
||||
})
|
||||
const payloads = sortedList
|
||||
const failedIndex: number[] = []
|
||||
const nextPlugins = produce(pluginsRef.current, (draft) => {
|
||||
marketPlaceInDSLIndex.forEach((index, i) => {
|
||||
if (payloads[i]) {
|
||||
draft[index] = {
|
||||
...payloads[i],
|
||||
version: payloads[i]!.version || payloads[i]!.latest_version,
|
||||
}
|
||||
}
|
||||
else { failedIndex.push(index) }
|
||||
})
|
||||
})
|
||||
setPlugins(nextPlugins)
|
||||
|
||||
if (failedIndex.length > 0)
|
||||
setErrorIndexes([...errorIndexes, ...failedIndex])
|
||||
}
|
||||
}, [isFetchingMarketplaceDataById])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingDataByMeta && infoByMeta?.data.list) {
|
||||
const payloads = infoByMeta?.data.list
|
||||
const failedIndex: number[] = []
|
||||
const nextPlugins = produce(pluginsRef.current, (draft) => {
|
||||
marketPlaceInDSLIndex.forEach((index, i) => {
|
||||
if (payloads[i]) {
|
||||
const item = payloads[i]
|
||||
draft[index] = {
|
||||
...item.plugin,
|
||||
plugin_id: item.version.unique_identifier,
|
||||
}
|
||||
}
|
||||
else {
|
||||
failedIndex.push(index)
|
||||
}
|
||||
})
|
||||
})
|
||||
setPlugins(nextPlugins)
|
||||
if (failedIndex.length > 0)
|
||||
setErrorIndexes([...errorIndexes, ...failedIndex])
|
||||
}
|
||||
}, [isFetchingDataByMeta])
|
||||
|
||||
useEffect(() => {
|
||||
// get info all failed
|
||||
if (infoByMetaError || infoByIdError)
|
||||
setErrorIndexes([...errorIndexes, ...marketPlaceInDSLIndex])
|
||||
}, [infoByMetaError, infoByIdError])
|
||||
|
||||
const isLoadedAllData = (plugins.filter(p => !!p).length + errorIndexes.length) === allPlugins.length
|
||||
|
||||
const { installedInfo } = useCheckInstalled({
|
||||
pluginIds: plugins?.filter(p => !!p).map((d) => {
|
||||
return `${d?.org || d?.author}/${d?.name}`
|
||||
}) || [],
|
||||
enabled: isLoadedAllData,
|
||||
const {
|
||||
plugins,
|
||||
errorIndexes,
|
||||
handleGitHubPluginFetched,
|
||||
handleGitHubPluginFetchError,
|
||||
getVersionInfo,
|
||||
handleSelect,
|
||||
isPluginSelected,
|
||||
getInstallablePlugins,
|
||||
} = useInstallMultiState({
|
||||
allPlugins,
|
||||
selectedPlugins,
|
||||
onSelect,
|
||||
onLoadedAllPlugin,
|
||||
})
|
||||
|
||||
const getVersionInfo = useCallback((pluginId: string) => {
|
||||
const pluginDetail = installedInfo?.[pluginId]
|
||||
const hasInstalled = !!pluginDetail
|
||||
return {
|
||||
hasInstalled,
|
||||
installedVersion: pluginDetail?.installedVersion,
|
||||
toInstallVersion: '',
|
||||
}
|
||||
}, [installedInfo])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadedAllData && installedInfo)
|
||||
onLoadedAllPlugin(installedInfo!)
|
||||
}, [isLoadedAllData, installedInfo])
|
||||
|
||||
const handleSelect = useCallback((index: number) => {
|
||||
return () => {
|
||||
const canSelectPlugins = plugins.filter((p) => {
|
||||
const { canInstall } = pluginInstallLimit(p!, systemFeatures)
|
||||
return canInstall
|
||||
})
|
||||
onSelect(plugins[index]!, index, canSelectPlugins.length)
|
||||
}
|
||||
}, [onSelect, plugins, systemFeatures])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
selectAllPlugins: () => {
|
||||
const selectedIndexes: number[] = []
|
||||
const selectedPlugins: Plugin[] = []
|
||||
allPlugins.forEach((d, index) => {
|
||||
const p = plugins[index]
|
||||
if (!p)
|
||||
return
|
||||
const { canInstall } = pluginInstallLimit(p, systemFeatures)
|
||||
if (canInstall) {
|
||||
selectedIndexes.push(index)
|
||||
selectedPlugins.push(p)
|
||||
}
|
||||
})
|
||||
onSelectAll(selectedPlugins, selectedIndexes)
|
||||
},
|
||||
deSelectAllPlugins: () => {
|
||||
onDeSelectAll()
|
||||
const { installablePlugins, selectedIndexes } = getInstallablePlugins()
|
||||
onSelectAll(installablePlugins, selectedIndexes)
|
||||
},
|
||||
deSelectAllPlugins: onDeSelectAll,
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
{allPlugins.map((d, index) => {
|
||||
if (errorIndexes.includes(index)) {
|
||||
return (
|
||||
<LoadingError key={index} />
|
||||
)
|
||||
}
|
||||
if (errorIndexes.includes(index))
|
||||
return <LoadingError key={index} />
|
||||
|
||||
const plugin = plugins[index]
|
||||
const checked = isPluginSelected(index)
|
||||
const versionInfo = getVersionInfo(getPluginKey(plugin))
|
||||
|
||||
if (d.type === 'github') {
|
||||
return (
|
||||
<GithubItem
|
||||
key={index}
|
||||
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
|
||||
checked={checked}
|
||||
onCheckedChange={handleSelect(index)}
|
||||
dependency={d as GitHubItemAndMarketPlaceDependency}
|
||||
onFetchedPayload={handleGitHubPluginFetched(index)}
|
||||
onFetchError={handleGitHubPluginFetchError(index)}
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
versionInfo={versionInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -246,24 +86,23 @@ const InstallByDSLList = ({
|
||||
return (
|
||||
<MarketplaceItem
|
||||
key={index}
|
||||
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
|
||||
checked={checked}
|
||||
onCheckedChange={handleSelect(index)}
|
||||
payload={{ ...plugin, from: d.type } as Plugin}
|
||||
version={(d as GitHubItemAndMarketPlaceDependency).value.version! || plugin?.version || ''}
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
versionInfo={versionInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Local package
|
||||
return (
|
||||
<PackageItem
|
||||
key={index}
|
||||
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
|
||||
checked={checked}
|
||||
onCheckedChange={handleSelect(index)}
|
||||
payload={d as PackageDependency}
|
||||
isFromMarketPlace={isFromMarketPlace}
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
versionInfo={versionInfo}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -3050,11 +3050,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/custom/custom-web-app-brand/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"app/components/custom/custom-web-app-brand/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 12
|
||||
@ -4081,14 +4076,6 @@
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"app/components/develop/doc.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/develop/md.tsx": {
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
@ -4743,14 +4730,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 5
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/plugins/install-plugin/install-bundle/steps/install.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
@ -5815,11 +5794,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow-app/hooks/use-DSL.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user