diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 55ad423d88..1aa6706b82 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -277,7 +277,10 @@ describe('App Card Operations Flow', () => { } }) - // -- Basic rendering -- + afterEach(() => { + vi.restoreAllMocks() + }) + describe('Card Rendering', () => { it('should render app name and description', () => { renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' }) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index 32aaddf251..9450d13670 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -187,7 +187,10 @@ describe('App List Browsing Flow', () => { mockShowTagManagementModal = false }) - // -- Loading and Empty states -- + afterEach(() => { + vi.restoreAllMocks() + }) + describe('Loading and Empty States', () => { it('should show skeleton cards during initial loading', () => { mockIsLoading = true diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index 23017d3c76..556c973b06 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -237,7 +237,6 @@ describe('Create App Flow', () => { mockShowTagManagementModal = false }) - // -- NewAppCard rendering -- describe('NewAppCard Rendering', () => { it('should render the "Create App" card with all options', () => { renderList() diff --git a/web/__tests__/develop/develop-page-flow.test.tsx b/web/__tests__/develop/develop-page-flow.test.tsx index 6b46ee025c..703f7362f1 100644 --- a/web/__tests__/develop/develop-page-flow.test.tsx +++ b/web/__tests__/develop/develop-page-flow.test.tsx @@ -12,7 +12,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DevelopMain from '@/app/components/develop' import { AppModeEnum, Theme } from '@/types/app' -// ---------- fake timers ---------- beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }) }) @@ -28,8 +27,6 @@ async function flushUI() { }) } -// ---------- store mock ---------- - let storeAppDetail: unknown vi.mock('@/app/components/app/store', () => ({ @@ -38,8 +35,6 @@ vi.mock('@/app/components/app/store', () => ({ }, })) -// ---------- Doc dependencies ---------- - vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', })) @@ -48,11 +43,12 @@ vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: Theme.light }), })) -vi.mock('@/i18n-config/language', () => ({ - LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'], -})) - -// ---------- SecretKeyModal dependencies ---------- +vi.mock('@/i18n-config/language', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + } +}) vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx index 3546c642a6..0bbed83a99 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx @@ -11,7 +11,7 @@ import { RETRIEVE_METHOD } from '@/types/app' import Item from './index' vi.mock('../settings-modal', () => ({ - default: ({ onSave, onCancel, currentDataset }: any) => ( + default: ({ onSave, onCancel, currentDataset }: { currentDataset: DataSet, onCancel: () => void, onSave: (newDataset: DataSet) => void }) => (
Mock settings modal
@@ -177,7 +177,7 @@ describe('dataset-config/card-item', () => { expect(screen.getByRole('dialog')).toBeVisible() }) - await user.click(screen.getByText('Save changes')) + fireEvent.click(screen.getByText('Save changes')) await waitFor(() => { expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' })) diff --git a/web/app/components/develop/__tests__/doc.spec.tsx b/web/app/components/develop/__tests__/doc.spec.tsx index eaccdfe2f1..b5db99974a 100644 --- a/web/app/components/develop/__tests__/doc.spec.tsx +++ b/web/app/components/develop/__tests__/doc.spec.tsx @@ -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 = { '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[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() + render([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() + render([0]['appDetail']} />) expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/develop/__tests__/toc-panel.spec.tsx b/web/app/components/develop/__tests__/toc-panel.spec.tsx new file mode 100644 index 0000000000..1c5143320f --- /dev/null +++ b/web/app/components/develop/__tests__/toc-panel.spec.tsx @@ -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() + + expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument() + }) + + it('should not render nav or toc items when collapsed', () => { + render() + + 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() + + 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() + + expect(screen.getByRole('navigation')).toBeInTheDocument() + expect(screen.getByText('appApi.develop.noContent')).toBeInTheDocument() + }) + + it('should render TOC header with title', () => { + render() + + expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument() + }) + + it('should call onToggle(false) when close button is clicked', () => { + const onToggle = vi.fn() + render() + + 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() + + expect(screen.getByText('Introduction')).toBeInTheDocument() + expect(screen.getByText('Authentication')).toBeInTheDocument() + expect(screen.getByText('Endpoints')).toBeInTheDocument() + }) + + it('should render links with correct href attributes', () => { + render() + + 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() + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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() + + 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() + + expect(screen.getAllByRole('link')).toHaveLength(1) + }) + + it('should handle active section that does not match any item', () => { + render( + , + ) + + // 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') + }) + }) + }) +}) diff --git a/web/app/components/develop/__tests__/use-doc-toc.spec.ts b/web/app/components/develop/__tests__/use-doc-toc.spec.ts new file mode 100644 index 0000000000..e437e13065 --- /dev/null +++ b/web/app/components/develop/__tests__/use-doc-toc.spec.ts @@ -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 + 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 + 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() + }) + }) +}) diff --git a/web/app/components/develop/doc.tsx b/web/app/components/develop/doc.tsx index 4e853113d4..2f6a069b45 100644 --- a/web/app/components/develop/doc.tsx +++ b/web/app/components/develop/doc.tsx @@ -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 +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 +} + +// 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>> + +const resolveTemplate = (mode: string | undefined, locale: string): ComponentType | 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>([]) - const [isTocExpanded, setIsTocExpanded] = useState(false) - const [activeSection, setActiveSection] = useState('') 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> | undefined + )?.configs?.prompt_variables ?? [] + const inputs = variables.reduce>((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, 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 - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.WORKFLOW) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.COMPLETION) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - return null - }, [appDetail, locale, variables, inputs]) + const TemplateComponent = useMemo( + () => resolveTemplate(appDetail?.mode, locale), + [appDetail?.mode, locale], + ) return (
- {isTocExpanded - ? ( - - ) - : ( - - )} +
- {Template} + {TemplateComponent && }
) diff --git a/web/app/components/develop/hooks/use-doc-toc.ts b/web/app/components/develop/hooks/use-doc-toc.ts new file mode 100644 index 0000000000..d42cb68b00 --- /dev/null +++ b/web/app/components/develop/hooks/use-doc-toc.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useState } from 'react' + +export type TocItem = { + href: string + text: string +} + +type UseDocTocOptions = { + appDetail: Record | 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
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([]) + const [isTocExpanded, setIsTocExpanded] = useState(() => { + if (typeof window === 'undefined') + return false + return window.matchMedia('(min-width: 1280px)').matches + }) + const [activeSection, setActiveSection] = useState('') + + // 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, 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, + } +} diff --git a/web/app/components/develop/toc-panel.tsx b/web/app/components/develop/toc-panel.tsx new file mode 100644 index 0000000000..8879dc454a --- /dev/null +++ b/web/app/components/develop/toc-panel.tsx @@ -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, item: TocItem) => void +} + +const TocPanel = ({ toc, activeSection, isTocExpanded, onToggle, onItemClick }: TocPanelProps) => { + const { t } = useTranslation() + + if (!isTocExpanded) { + return ( + + ) + } + + return ( + + ) +} + +export default TocPanel diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx index cdaa471496..4507c1295b 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx @@ -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 }), })) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts new file mode 100644 index 0000000000..1950a47f6d --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts @@ -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 | null = null +let mockMarketplaceError: Error | null = null +let mockInstalledInfo: Record = {} +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 => ({ + 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]) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts new file mode 100644 index 0000000000..b430d47afd --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts @@ -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) => 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((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() + const errorSet = new Set() + + // 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>(() => new Map()) + const [githubErrorIndexes, setGithubErrorIndexes] = useState([]) + + // 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, + } +} diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx index 1b08ca5a04..49055f90a5 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx @@ -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([]) - - 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 ( - - ) - } + if (errorIndexes.includes(index)) + return + const plugin = plugins[index] + const checked = isPluginSelected(index) + const versionInfo = getVersionInfo(getPluginKey(plugin)) + if (d.type === 'github') { return ( 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 ( 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 ( 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} /> ) })} diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx index eb646fd8c3..e3bdb4e58a 100644 --- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx @@ -30,19 +30,21 @@ vi.mock('@/context/app-context', () => ({ })) // Mock API services - only mock external services -const mockFetchWorkflowToolDetailByAppID = vi.fn() const mockCreateWorkflowToolProvider = vi.fn() const mockSaveWorkflowToolProvider = vi.fn() vi.mock('@/service/tools', () => ({ - fetchWorkflowToolDetailByAppID: (...args: unknown[]) => mockFetchWorkflowToolDetailByAppID(...args), createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args), saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args), })) -// Mock invalidate workflow tools hook +// Mock service hooks const mockInvalidateAllWorkflowTools = vi.fn() +const mockInvalidateWorkflowToolDetailByAppID = vi.fn() +const mockUseWorkflowToolDetailByAppID = vi.fn() vi.mock('@/service/use-tools', () => ({ useInvalidateAllWorkflowTools: () => mockInvalidateAllWorkflowTools, + useInvalidateWorkflowToolDetailByAppID: () => mockInvalidateWorkflowToolDetailByAppID, + useWorkflowToolDetailByAppID: (...args: unknown[]) => mockUseWorkflowToolDetailByAppID(...args), })) // Mock Toast - need to verify notification calls @@ -242,7 +244,10 @@ describe('WorkflowToolConfigureButton', () => { vi.clearAllMocks() mockPortalOpenState = false mockIsCurrentWorkspaceManager.mockReturnValue(true) - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(createMockWorkflowToolDetail()) + mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({ + data: enabled ? createMockWorkflowToolDetail() : undefined, + isLoading: false, + })) }) // Rendering Tests (REQUIRED) @@ -307,19 +312,17 @@ describe('WorkflowToolConfigureButton', () => { expect(screen.getByText('Please save the workflow first')).toBeInTheDocument() }) - it('should render loading state when published and fetching details', async () => { + it('should render loading state when published and fetching details', () => { // Arrange - mockFetchWorkflowToolDetailByAppID.mockImplementation(() => new Promise(() => { })) // Never resolves + mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true }) const props = createDefaultConfigureButtonProps({ published: true }) // Act render() // Assert - await waitFor(() => { - const loadingElement = document.querySelector('.pt-2') - expect(loadingElement).toBeInTheDocument() - }) + const loadingElement = document.querySelector('.pt-2') + expect(loadingElement).toBeInTheDocument() }) it('should render configure and manage buttons when published', async () => { @@ -381,76 +384,10 @@ describe('WorkflowToolConfigureButton', () => { // Act & Assert expect(() => render()).not.toThrow() }) - - it('should call handlePublish when updating workflow tool', async () => { - // Arrange - const user = userEvent.setup() - const handlePublish = vi.fn().mockResolvedValue(undefined) - mockSaveWorkflowToolProvider.mockResolvedValue({}) - const props = createDefaultConfigureButtonProps({ published: true, handlePublish }) - - // Act - render() - await waitFor(() => { - expect(screen.getByText('workflow.common.configure')).toBeInTheDocument() - }) - await user.click(screen.getByText('workflow.common.configure')) - - // Fill required fields and save - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - }) - const saveButton = screen.getByText('common.operation.save') - await user.click(saveButton) - - // Confirm in modal - await waitFor(() => { - expect(screen.getByText('tools.createTool.confirmTitle')).toBeInTheDocument() - }) - await user.click(screen.getByText('common.operation.confirm')) - - // Assert - await waitFor(() => { - expect(handlePublish).toHaveBeenCalled() - }) - }) }) - // State Management Tests - describe('State Management', () => { - it('should fetch detail when published and mount', async () => { - // Arrange - const props = createDefaultConfigureButtonProps({ published: true }) - - // Act - render() - - // Assert - await waitFor(() => { - expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledWith('workflow-app-123') - }) - }) - - it('should refetch detail when detailNeedUpdate changes to true', async () => { - // Arrange - const props = createDefaultConfigureButtonProps({ published: true, detailNeedUpdate: false }) - - // Act - const { rerender } = render() - - await waitFor(() => { - expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(1) - }) - - // Rerender with detailNeedUpdate true - rerender() - - // Assert - await waitFor(() => { - expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(2) - }) - }) - + // Modal behavior tests + describe('Modal Behavior', () => { it('should toggle modal visibility', async () => { // Arrange const user = userEvent.setup() @@ -513,85 +450,6 @@ describe('WorkflowToolConfigureButton', () => { }) }) - // Memoization Tests - describe('Memoization - outdated detection', () => { - it('should detect outdated when parameter count differs', async () => { - // Arrange - const detail = createMockWorkflowToolDetail() - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail) - const props = createDefaultConfigureButtonProps({ - published: true, - inputs: [ - createMockInputVar({ variable: 'test_var' }), - createMockInputVar({ variable: 'extra_var' }), - ], - }) - - // Act - render() - - // Assert - should show outdated warning - await waitFor(() => { - expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument() - }) - }) - - it('should detect outdated when parameter not found', async () => { - // Arrange - const detail = createMockWorkflowToolDetail() - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail) - const props = createDefaultConfigureButtonProps({ - published: true, - inputs: [createMockInputVar({ variable: 'different_var' })], - }) - - // Act - render() - - // Assert - await waitFor(() => { - expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument() - }) - }) - - it('should detect outdated when required property differs', async () => { - // Arrange - const detail = createMockWorkflowToolDetail() - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail) - const props = createDefaultConfigureButtonProps({ - published: true, - inputs: [createMockInputVar({ variable: 'test_var', required: false })], // Detail has required: true - }) - - // Act - render() - - // Assert - await waitFor(() => { - expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument() - }) - }) - - it('should not show outdated when parameters match', async () => { - // Arrange - const detail = createMockWorkflowToolDetail() - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail) - const props = createDefaultConfigureButtonProps({ - published: true, - inputs: [createMockInputVar({ variable: 'test_var', required: true, type: InputVarType.textInput })], - }) - - // Act - render() - - // Assert - await waitFor(() => { - expect(screen.getByText('workflow.common.configure')).toBeInTheDocument() - }) - expect(screen.queryByText('workflow.common.workflowAsToolTip')).not.toBeInTheDocument() - }) - }) - // User Interactions Tests describe('User Interactions', () => { it('should navigate to tools page when manage button clicked', async () => { @@ -611,174 +469,10 @@ describe('WorkflowToolConfigureButton', () => { // Assert expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow') }) - - it('should create workflow tool provider on first publish', async () => { - // Arrange - const user = userEvent.setup() - mockCreateWorkflowToolProvider.mockResolvedValue({}) - const props = createDefaultConfigureButtonProps() - - // Act - render() - - // Open modal - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - }) - - // Fill in required name field - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.type(nameInput, 'my_tool') - - // Click save - await user.click(screen.getByText('common.operation.save')) - - // Assert - await waitFor(() => { - expect(mockCreateWorkflowToolProvider).toHaveBeenCalled() - }) - }) - - it('should show success toast after creating workflow tool', async () => { - // Arrange - const user = userEvent.setup() - mockCreateWorkflowToolProvider.mockResolvedValue({}) - const props = createDefaultConfigureButtonProps() - - // Act - render() - - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - }) - - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.type(nameInput, 'my_tool') - - await user.click(screen.getByText('common.operation.save')) - - // Assert - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'common.api.actionSuccess', - }) - }) - }) - - it('should show error toast when create fails', async () => { - // Arrange - const user = userEvent.setup() - mockCreateWorkflowToolProvider.mockRejectedValue(new Error('Create failed')) - const props = createDefaultConfigureButtonProps() - - // Act - render() - - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - }) - - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.type(nameInput, 'my_tool') - - await user.click(screen.getByText('common.operation.save')) - - // Assert - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Create failed', - }) - }) - }) - - it('should call onRefreshData after successful create', async () => { - // Arrange - const user = userEvent.setup() - const onRefreshData = vi.fn() - mockCreateWorkflowToolProvider.mockResolvedValue({}) - const props = createDefaultConfigureButtonProps({ onRefreshData }) - - // Act - render() - - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - }) - - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.type(nameInput, 'my_tool') - - await user.click(screen.getByText('common.operation.save')) - - // Assert - await waitFor(() => { - expect(onRefreshData).toHaveBeenCalled() - }) - }) - - it('should invalidate all workflow tools after successful create', async () => { - // Arrange - const user = userEvent.setup() - mockCreateWorkflowToolProvider.mockResolvedValue({}) - const props = createDefaultConfigureButtonProps() - - // Act - render() - - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - }) - - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.type(nameInput, 'my_tool') - - await user.click(screen.getByText('common.operation.save')) - - // Assert - await waitFor(() => { - expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() - }) - }) }) // Edge Cases (REQUIRED) describe('Edge Cases', () => { - it('should handle API returning undefined', async () => { - // Arrange - API returns undefined (simulating empty response or handled error) - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(undefined) - const props = createDefaultConfigureButtonProps({ published: true }) - - // Act - render() - - // Assert - should not crash and wait for API call - await waitFor(() => { - expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled() - }) - - // Component should still render without crashing - await waitFor(() => { - expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument() - }) - }) - it('should handle rapid publish/unpublish state changes', async () => { // Arrange const props = createDefaultConfigureButtonProps({ published: false }) @@ -798,35 +492,7 @@ describe('WorkflowToolConfigureButton', () => { }) // Assert - should not crash - expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled() - }) - - it('should handle detail with empty parameters', async () => { - // Arrange - const detail = createMockWorkflowToolDetail() - detail.tool.parameters = [] - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail) - const props = createDefaultConfigureButtonProps({ published: true, inputs: [] }) - - // Act - render() - - // Assert - await waitFor(() => { - expect(screen.getByText('workflow.common.configure')).toBeInTheDocument() - }) - }) - - it('should handle detail with undefined output_schema', async () => { - // Arrange - const detail = createMockWorkflowToolDetail() - // @ts-expect-error - testing undefined case - detail.tool.output_schema = undefined - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail) - const props = createDefaultConfigureButtonProps({ published: true }) - - // Act & Assert - expect(() => render()).not.toThrow() + expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument() }) it('should handle paragraph type input conversion', async () => { @@ -1853,7 +1519,10 @@ describe('Integration Tests', () => { vi.clearAllMocks() mockPortalOpenState = false mockIsCurrentWorkspaceManager.mockReturnValue(true) - mockFetchWorkflowToolDetailByAppID.mockResolvedValue(createMockWorkflowToolDetail()) + mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({ + data: enabled ? createMockWorkflowToolDetail() : undefined, + isLoading: false, + })) }) // Complete workflow: open modal -> fill form -> save diff --git a/web/app/components/tools/workflow-tool/configure-button.tsx b/web/app/components/tools/workflow-tool/configure-button.tsx index 6526722b63..84fc3fd96d 100644 --- a/web/app/components/tools/workflow-tool/configure-button.tsx +++ b/web/app/components/tools/workflow-tool/configure-button.tsx @@ -1,22 +1,16 @@ 'use client' -import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types' +import type { Emoji } from '@/app/components/tools/types' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react' -import { useRouter } from 'next/navigation' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' -import Toast from '@/app/components/base/toast' import Indicator from '@/app/components/header/indicator' import WorkflowToolModal from '@/app/components/tools/workflow-tool' -import { useAppContext } from '@/context/app-context' -import { createWorkflowToolProvider, fetchWorkflowToolDetailByAppID, saveWorkflowToolProvider } from '@/service/tools' -import { useInvalidateAllWorkflowTools } from '@/service/use-tools' import { cn } from '@/utils/classnames' import Divider from '../../base/divider' +import { useConfigureButton } from './hooks/use-configure-button' type Props = { disabled: boolean @@ -48,153 +42,29 @@ const WorkflowToolConfigureButton = ({ disabledReason, }: Props) => { const { t } = useTranslation() - const router = useRouter() - const [showModal, setShowModal] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [detail, setDetail] = useState() - const { isCurrentWorkspaceManager } = useAppContext() - const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools() - - const outdated = useMemo(() => { - if (!detail) - return false - if (detail.tool.parameters.length !== inputs?.length) { - return true - } - else { - for (const item of inputs || []) { - const param = detail.tool.parameters.find(toolParam => toolParam.name === item.variable) - if (!param) { - return true - } - else if (param.required !== item.required) { - return true - } - else { - if (item.type === 'paragraph' && param.type !== 'string') - return true - if (item.type === 'text-input' && param.type !== 'string') - return true - } - } - } - return false - }, [detail, inputs]) - - const payload = useMemo(() => { - let parameters: WorkflowToolProviderParameter[] = [] - let outputParameters: WorkflowToolProviderOutputParameter[] = [] - - if (!published) { - parameters = (inputs || []).map((item) => { - return { - name: item.variable, - description: '', - form: 'llm', - required: item.required, - type: item.type, - } - }) - outputParameters = (outputs || []).map((item) => { - return { - name: item.variable, - description: '', - type: item.value_type, - } - }) - } - else if (detail && detail.tool) { - parameters = (inputs || []).map((item) => { - return { - name: item.variable, - required: item.required, - type: item.type === 'paragraph' ? 'string' : item.type, - description: detail.tool.parameters.find(param => param.name === item.variable)?.llm_description || '', - form: detail.tool.parameters.find(param => param.name === item.variable)?.form || 'llm', - } - }) - outputParameters = (outputs || []).map((item) => { - const found = detail.tool.output_schema?.properties?.[item.variable] - return { - name: item.variable, - description: found ? found.description : '', - type: item.value_type, - } - }) - } - return { - icon: detail?.icon || icon, - label: detail?.label || name, - name: detail?.name || '', - description: detail?.description || description, - parameters, - outputParameters, - labels: detail?.tool?.labels || [], - privacy_policy: detail?.privacy_policy || '', - ...(published - ? { - workflow_tool_id: detail?.workflow_tool_id, - } - : { - workflow_app_id: workflowAppId, - }), - } - }, [detail, published, workflowAppId, icon, name, description, inputs]) - - const getDetail = useCallback(async (workflowAppId: string) => { - setIsLoading(true) - const res = await fetchWorkflowToolDetailByAppID(workflowAppId) - setDetail(res) - setIsLoading(false) - }, []) - - useEffect(() => { - if (published) - getDetail(workflowAppId) - }, [getDetail, published, workflowAppId]) - - useEffect(() => { - if (detailNeedUpdate) - getDetail(workflowAppId) - }, [detailNeedUpdate, getDetail, workflowAppId]) - - const createHandle = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => { - try { - await createWorkflowToolProvider(data) - invalidateAllWorkflowTools() - onRefreshData?.() - getDetail(workflowAppId) - Toast.notify({ - type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), - }) - setShowModal(false) - } - catch (e) { - Toast.notify({ type: 'error', message: (e as Error).message }) - } - } - - const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{ - workflow_app_id: string - workflow_tool_id: string - }>) => { - try { - await handlePublish() - await saveWorkflowToolProvider(data) - onRefreshData?.() - invalidateAllWorkflowTools() - getDetail(workflowAppId) - Toast.notify({ - type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), - }) - setShowModal(false) - } - catch (e) { - Toast.notify({ type: 'error', message: (e as Error).message }) - } - } + const { + showModal, + isLoading, + outdated, + payload, + isCurrentWorkspaceManager, + openModal, + closeModal, + handleCreate, + handleUpdate, + navigateToTools, + } = useConfigureButton({ + published, + detailNeedUpdate, + workflowAppId, + icon, + name, + description, + inputs, + outputs, + handlePublish, + onRefreshData, + }) return ( <> @@ -210,17 +80,17 @@ const WorkflowToolConfigureButton = ({ ? (
!disabled && !published && setShowModal(true)} + onClick={() => !disabled && !published && openModal()} >
{t('common.workflowAsTool', { ns: 'workflow' })}
{!published && ( - + {t('common.configureRequired', { ns: 'workflow' })} )} @@ -233,7 +103,7 @@ const WorkflowToolConfigureButton = ({
{t('common.workflowAsTool', { ns: 'workflow' })}
@@ -250,7 +120,7 @@ const WorkflowToolConfigureButton = ({