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:
CodingOnStar
2026-02-13 12:46:06 +08:00
parent f3f56f03e3
commit 4c1174b11e
11 changed files with 1729 additions and 428 deletions

View File

@ -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 }),
}))

View File

@ -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])
})
})
})

View File

@ -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,
}
}

View File

@ -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}
/>
)
})}