From ddd546ef88f9293da956ad0f235d2eaffd92b7e7 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Thu, 23 Apr 2026 14:46:54 +0800
Subject: [PATCH] fix(web): three small UX fixes on /datasets and /plugins
(#35514)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
eslint-suppressions.json | 5 -
.../service-api/__tests__/card.spec.tsx | 45 +-
.../service-api/__tests__/index.spec.tsx | 518 +-----------------
.../datasets/extra-info/service-api/card.tsx | 44 +-
.../datasets/extra-info/service-api/index.tsx | 17 +-
.../datasets/list/__tests__/index.spec.tsx | 8 +
.../steps/__tests__/setURL.spec.tsx | 11 +-
.../install-from-github/steps/setURL.tsx | 13 +
.../plugin-page/__tests__/debug-info.spec.tsx | 41 +-
.../plugins/plugin-page/debug-info.tsx | 81 +--
10 files changed, 168 insertions(+), 615 deletions(-)
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index dcb58d4b57..405ce77400 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -3542,11 +3542,6 @@
"count": 1
}
},
- "web/app/components/plugins/plugin-page/debug-info.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"web/app/components/plugins/plugin-page/empty/index.tsx": {
"react/set-state-in-effect": {
"count": 2
diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx
index 30f5f095eb..678434b8c0 100644
--- a/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx
+++ b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx
@@ -1,3 +1,4 @@
+import { Popover } from '@langgenius/dify-ui/popover'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -7,18 +8,15 @@ vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets',
}))
-vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
- default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) =>
- isShow ?
: null,
-}))
-
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return ({ children }: { children: React.ReactNode }) => (
- {children}
+
+ {children}
+
)
}
@@ -28,8 +26,10 @@ const renderWithProviders = (ui: React.ReactElement) => {
}
describe('Card (Service API)', () => {
+ const onOpenSecretKeyModal = vi.fn()
const defaultProps = {
apiBaseUrl: 'https://api.dify.ai/v1',
+ onOpenSecretKeyModal,
}
beforeEach(() => {
@@ -77,48 +77,33 @@ describe('Card (Service API)', () => {
// Props: tests different apiBaseUrl values
describe('Props', () => {
it('should display provided apiBaseUrl', () => {
- renderWithProviders()
+ renderWithProviders()
expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument()
})
it('should show green indicator when apiBaseUrl is provided', () => {
- renderWithProviders()
+ renderWithProviders()
// The Indicator component receives color="green" when apiBaseUrl is truthy
const statusText = screen.getByText(/serviceApi\.enabled/)
expect(statusText).toHaveClass('text-text-success')
})
it('should show yellow indicator when apiBaseUrl is empty', () => {
- renderWithProviders()
+ renderWithProviders()
// Still shows "enabled" text but indicator color differs
expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument()
})
})
- // User Interactions: tests button clicks and modal
+ // User Interactions: tests button clicks
describe('User Interactions', () => {
- it('should open secret key modal when API key button is clicked', () => {
- renderWithProviders()
-
- // Modal should not be visible before clicking
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
-
- const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button')
- fireEvent.click(apiKeyButton!)
-
- // Modal should appear after clicking
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
-
- it('should close secret key modal when onClose is called', () => {
+ it('should call onOpenSecretKeyModal when API key button is clicked', () => {
renderWithProviders()
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button')
fireEvent.click(apiKeyButton!)
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- fireEvent.click(screen.getByText('close'))
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
+ expect(onOpenSecretKeyModal).toHaveBeenCalledTimes(1)
})
it('should render API reference as a link', () => {
@@ -148,20 +133,20 @@ describe('Card (Service API)', () => {
// Edge Cases: tests empty/long URLs
describe('Edge Cases', () => {
it('should handle empty apiBaseUrl', () => {
- renderWithProviders()
+ renderWithProviders()
// Should still render the structure
expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument()
})
it('should handle very long apiBaseUrl', () => {
const longUrl = `https://api.dify.ai/${'path/'.repeat(50)}`
- renderWithProviders()
+ renderWithProviders()
expect(screen.getByText(longUrl)).toBeInTheDocument()
})
it('should handle apiBaseUrl with special characters', () => {
const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar'
- renderWithProviders()
+ renderWithProviders()
expect(screen.getByText(specialUrl)).toBeInTheDocument()
})
})
diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx
index 8137052383..93b752d9a0 100644
--- a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx
@@ -1,9 +1,8 @@
+import { Popover } from '@langgenius/dify-ui/popover'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-// Component Imports (after mocks)
-
import Card from '../card'
import ServiceApi from '../index'
@@ -43,7 +42,8 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
),
}))
-// ServiceApi Component Tests
+const renderCard = (ui: React.ReactElement) =>
+ render({ui})
describe('ServiceApi', () => {
beforeEach(() => {
@@ -80,18 +80,15 @@ describe('ServiceApi', () => {
})
})
- // Props Variations Tests
describe('Props Variations', () => {
- it('should show green Indicator when apiBaseUrl is provided', () => {
+ it('should show Indicator when apiBaseUrl is provided', () => {
const { container } = render()
- // When apiBaseUrl is truthy, Indicator color is green
const triggerContainer = container.querySelector('.relative.flex.h-8')
expect(triggerContainer).toBeInTheDocument()
})
- it('should show yellow Indicator when apiBaseUrl is empty', () => {
+ it('should show Indicator when apiBaseUrl is empty', () => {
const { container } = render()
- // When apiBaseUrl is falsy, Indicator color is yellow
const triggerContainer = container.querySelector('.relative.flex.h-8')
expect(triggerContainer).toBeInTheDocument()
})
@@ -110,28 +107,7 @@ describe('ServiceApi', () => {
})
describe('User Interactions', () => {
- it('should toggle popup open state on click', async () => {
- const user = userEvent.setup()
-
- render()
-
- const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
- expect(trigger).toBeInTheDocument()
-
- if (trigger)
- await user.click(trigger)
-
- // After click, the Card should be rendered
- })
-
- it('should apply hover styles on trigger', () => {
- render()
-
- const trigger = screen.getByText(/serviceApi\.title/i).closest('div[class*="cursor-pointer"]')
- expect(trigger).toHaveClass('cursor-pointer')
- })
-
- it('should toggle open state from false to true on first click', async () => {
+ it('should open popup on trigger click', async () => {
const user = userEvent.setup()
render()
@@ -140,56 +116,13 @@ describe('ServiceApi', () => {
if (trigger)
await user.click(trigger)
- // Card should be visible after clicking
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
})
-
- it('should toggle open state back to false on second click', async () => {
- const user = userEvent.setup()
-
- render()
-
- const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
- if (trigger) {
- await user.click(trigger) // open
- await user.click(trigger) // close
- }
-
- // Component should handle the toggle without errors
- })
-
- it('should apply open state styling when popup is open', async () => {
- const user = userEvent.setup()
-
- render()
-
- const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
- if (trigger)
- await user.click(trigger)
-
- // When open, the trigger should have hover background class
- })
})
- // Portal and Card Integration Tests
describe('Portal and Card Integration', () => {
- it('should render Card component inside portal when open', async () => {
- const user = userEvent.setup()
-
- render()
-
- const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
- if (trigger)
- await user.click(trigger)
-
- // Wait for portal content to appear
- await waitFor(() => {
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- })
- })
-
it('should pass apiBaseUrl prop to Card component', async () => {
const user = userEvent.setup()
const testUrl = 'https://test-api.example.com'
@@ -204,38 +137,9 @@ describe('ServiceApi', () => {
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
})
-
- it('should use correct portal placement configuration', () => {
- render()
- // PortalToFollowElem is configured with placement="top-start"
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
- })
-
- it('should use correct portal offset configuration', () => {
- render()
- // PortalToFollowElem is configured with offset={{ mainAxis: 4, crossAxis: -4 }}
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
- })
})
describe('Edge Cases', () => {
- it('should handle rapid toggle clicks gracefully', async () => {
- const user = userEvent.setup()
-
- render()
-
- const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
- if (trigger) {
- // Rapid clicks
- await user.click(trigger)
- await user.click(trigger)
- await user.click(trigger)
- }
-
- // Component should handle state changes without errors
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
- })
-
it('should render correctly with empty apiBaseUrl', () => {
render()
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
@@ -248,391 +152,60 @@ describe('ServiceApi', () => {
rerender()
- // Component should still render after prop change
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
- })
-
- it('should handle undefined-like apiBaseUrl values', () => {
- // Empty string is the closest to undefined for this prop
- render()
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
- })
- })
-
- describe('Memoization', () => {
- it('should be memoized with React.memo', () => {
- const { rerender } = render()
-
- rerender()
-
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
- })
-
- it('should not re-render unnecessarily with same props', () => {
- const { rerender } = render()
-
- rerender()
- rerender()
-
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
- })
-
- it('should update when apiBaseUrl prop changes', () => {
- const { rerender } = render()
-
- rerender()
-
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
})
-// Card Component Tests
-
describe('Card (service-api)', () => {
+ const onOpenSecretKeyModal = vi.fn()
+
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
- render()
+ renderCard()
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
- it('should display card title', () => {
- render()
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- })
-
- it('should display enabled status', () => {
- render()
- expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
- })
-
- it('should render endpoint label', () => {
- render()
- expect(screen.getByText(/serviceApi\.card\.endpoint/i)).toBeInTheDocument()
- })
-
it('should display apiBaseUrl in endpoint field', () => {
const testUrl = 'https://api.example.com'
- render()
+ renderCard()
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
- it('should render Indicator component', () => {
- const { container } = render()
- // Card container should be present
- const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
- expect(cardContainer).toBeInTheDocument()
- })
-
it('should render API Key button', () => {
- render()
+ renderCard()
expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
})
it('should render API Reference button', () => {
- render()
+ renderCard()
expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument()
})
-
- it('should render CopyFeedback component for endpoint', () => {
- const { container } = render()
- // CopyFeedback should be in the endpoint section
- const copyButton = container.querySelector('[class*="bg-components-input-bg-normal"]')
- expect(copyButton).toBeInTheDocument()
- })
-
- it('should render ApiAggregate icon in header', () => {
- const { container } = render()
- const icon = container.querySelector('svg')
- expect(icon).toBeInTheDocument()
- })
- })
-
- // Props Variations Tests
- describe('Props Variations', () => {
- it('should show green Indicator when apiBaseUrl is provided', () => {
- const { container } = render()
- const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
- expect(cardContainer).toBeInTheDocument()
- })
-
- it('should show yellow Indicator when apiBaseUrl is empty', () => {
- const { container } = render()
- const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
- expect(cardContainer).toBeInTheDocument()
- })
-
- it('should display different apiBaseUrl values correctly', () => {
- const testUrls = [
- 'https://api.example.com',
- 'https://localhost:3000',
- 'https://api.production.example.com/v1',
- ]
-
- testUrls.forEach((url) => {
- const { unmount } = render()
- expect(screen.getByText(url)).toBeInTheDocument()
- unmount()
- })
- })
-
- it('should handle empty apiBaseUrl', () => {
- render()
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- })
-
- it('should truncate long apiBaseUrl', () => {
- const longUrl = 'https://api.example.com/v1/very/long/path/to/endpoint/that/should/truncate'
- const { container } = render()
- const truncateElement = container.querySelector('.truncate')
- expect(truncateElement).toBeInTheDocument()
- })
})
describe('User Interactions', () => {
- it('should open SecretKeyModal when API Key button is clicked', async () => {
+ it('should call onOpenSecretKeyModal when API Key button is clicked', async () => {
const user = userEvent.setup()
- render()
-
- const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
- expect(apiKeyButton).toBeInTheDocument()
-
- if (apiKeyButton)
- await user.click(apiKeyButton)
-
- await waitFor(() => {
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
- })
-
- it('should close SecretKeyModal when close button is clicked', async () => {
- const user = userEvent.setup()
-
- render()
+ renderCard()
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
- await waitFor(() => {
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
-
- const closeButton = screen.getByTestId('close-modal-btn')
- await user.click(closeButton)
-
- await waitFor(() => {
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
- })
+ expect(onOpenSecretKeyModal).toHaveBeenCalledTimes(1)
})
it('should have correct href for API Reference link', () => {
- render()
+ renderCard()
const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
-
- it('should open API Reference in new tab', () => {
- render()
-
- const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
- expect(apiRefLink).toHaveAttribute('target', '_blank')
- expect(apiRefLink).toHaveAttribute('rel', 'noopener noreferrer')
- })
-
- it('should toggle modal visibility correctly', async () => {
- const user = userEvent.setup()
-
- render()
-
- // Initially modal should not be visible
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
-
- const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
- if (apiKeyButton)
- await user.click(apiKeyButton)
-
- // Modal should be visible
- await waitFor(() => {
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
-
- const closeButton = screen.getByTestId('close-modal-btn')
- await user.click(closeButton)
-
- // Modal should not be visible again
- await waitFor(() => {
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
- })
- })
- })
-
- // Modal State Tests
- describe('Modal State', () => {
- it('should initialize with modal closed', () => {
- render()
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
- })
-
- it('should open modal on handleOpenSecretKeyModal', async () => {
- const user = userEvent.setup()
-
- render()
-
- const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
- if (apiKeyButton)
- await user.click(apiKeyButton)
-
- await waitFor(() => {
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
- })
-
- it('should close modal on handleCloseSecretKeyModal', async () => {
- const user = userEvent.setup()
-
- render()
-
- // Open modal first
- const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
- if (apiKeyButton)
- await user.click(apiKeyButton)
-
- await waitFor(() => {
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
-
- const closeButton = screen.getByTestId('close-modal-btn')
- await user.click(closeButton)
-
- await waitFor(() => {
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
- })
- })
-
- it('should handle multiple open/close cycles', async () => {
- const user = userEvent.setup()
-
- render()
-
- const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
-
- // First cycle
- if (apiKeyButton)
- await user.click(apiKeyButton)
-
- await waitFor(() => {
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
-
- await user.click(screen.getByTestId('close-modal-btn'))
-
- await waitFor(() => {
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
- })
-
- // Second cycle
- if (apiKeyButton)
- await user.click(apiKeyButton)
-
- await waitFor(() => {
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
- })
- })
-
- describe('Edge Cases', () => {
- it('should handle empty apiBaseUrl gracefully', () => {
- render()
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- // Endpoint field should show empty string
- })
-
- it('should handle very long apiBaseUrl', () => {
- const longUrl = 'https://'.concat('a'.repeat(500), '.com')
- render()
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- })
-
- it('should handle special characters in apiBaseUrl', () => {
- const specialUrl = 'https://api.example.com/path?query=test¶m=value#anchor'
- render()
- expect(screen.getByText(specialUrl)).toBeInTheDocument()
- })
-
- it('should render without errors when all buttons are clickable', async () => {
- const user = userEvent.setup()
-
- render()
-
- const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
- if (apiKeyButton)
- await user.click(apiKeyButton)
-
- await waitFor(() => {
- expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- })
-
- await user.click(screen.getByTestId('close-modal-btn'))
-
- await waitFor(() => {
- expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
- })
-
- // Component should still be functional
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- })
- })
-
- describe('Memoization', () => {
- it('should be memoized with React.memo', () => {
- const { rerender } = render()
-
- rerender()
-
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- })
-
- it('should use useCallback for handlers', () => {
- const { rerender } = render()
-
- rerender()
-
- // Component should render without issues with memoized callbacks
- expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
- })
-
- it('should update when apiBaseUrl prop changes', () => {
- const { rerender } = render()
-
- expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
-
- rerender()
-
- expect(screen.getByText('https://new-api.example.com')).toBeInTheDocument()
- })
- })
-
- // Copy Functionality Tests
- describe('Copy Functionality', () => {
- it('should render CopyFeedback component for apiBaseUrl', () => {
- const { container } = render()
- const copyContainer = container.querySelector('[class*="bg-components-input-bg-normal"]')
- expect(copyContainer).toBeInTheDocument()
- })
-
- it('should pass apiBaseUrl to CopyFeedback component', () => {
- const testUrl = 'https://api.example.com'
- render()
- // The URL should be displayed in the copy section
- expect(screen.getByText(testUrl)).toBeInTheDocument()
- })
})
})
@@ -641,78 +214,33 @@ describe('ServiceApi Integration', () => {
vi.clearAllMocks()
})
- it('should open Card popup and display endpoint', async () => {
- const user = userEvent.setup()
- const testUrl = 'https://api.example.com'
-
- render()
-
- // Open popup
- const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
- if (trigger)
- await user.click(trigger)
-
- // Wait for Card to appear
- await waitFor(() => {
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- expect(screen.getByText(testUrl)).toBeInTheDocument()
- })
- })
-
- it('should complete full workflow: open -> view endpoint -> access API key', async () => {
+ it('should close popover and open modal when API Key button is clicked', async () => {
const user = userEvent.setup()
render()
- // Open popup
+ // Open popover
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
- // Verify Card content
await waitFor(() => {
- expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
- expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
+ expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
})
- // Open API Key modal
+ // Click API Key button (wrapped by PopoverClose)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
- // Verify modal appears
+ // Modal should appear
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
- })
- it('should navigate to API Reference from Card', async () => {
- const user = userEvent.setup()
-
- render()
-
- // Open popup
- const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
- if (trigger)
- await user.click(trigger)
-
- // Wait for Card to appear
+ // Popover should be closed — Card title no longer in document
await waitFor(() => {
- expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument()
+ expect(screen.queryByText(/serviceApi\.card\.title/i)).not.toBeInTheDocument()
})
-
- // Verify link
- const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
- expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
- })
-
- it('should reflect apiBaseUrl status in Indicator color', () => {
- // With URL - should be green
- const { rerender } = render()
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
-
- // Without URL - should be yellow
- rerender()
- expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
diff --git a/web/app/components/datasets/extra-info/service-api/card.tsx b/web/app/components/datasets/extra-info/service-api/card.tsx
index 6f024c3f5d..efa5cf7f6a 100644
--- a/web/app/components/datasets/extra-info/service-api/card.tsx
+++ b/web/app/components/datasets/extra-info/service-api/card.tsx
@@ -1,35 +1,27 @@
import { Button } from '@langgenius/dify-ui/button'
+import { PopoverClose } from '@langgenius/dify-ui/popover'
import { RiBookOpenLine, RiKey2Line } from '@remixicon/react'
import * as React from 'react'
-import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CopyFeedback from '@/app/components/base/copy-feedback'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
-import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import Link from '@/next/link'
type CardProps = {
apiBaseUrl: string
+ onOpenSecretKeyModal: () => void
}
const Card = ({
apiBaseUrl,
+ onOpenSecretKeyModal,
}: CardProps) => {
const { t } = useTranslation()
- const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const apiReferenceUrl = useDatasetApiAccessUrl()
- const handleOpenSecretKeyModal = useCallback(() => {
- setIsSecretKeyModalVisible(true)
- }, [])
-
- const handleCloseSecretKeyModal = useCallback(() => {
- setIsSecretKeyModalVisible(false)
- }, [])
-
return (
@@ -74,17 +66,21 @@ const Card = ({
{/* Actions */}
-
+
+
+
+ {t('serviceApi.card.apiKey', { ns: 'dataset' })}
+
+
+ )}
+ />
-
)
}
diff --git a/web/app/components/datasets/extra-info/service-api/index.tsx b/web/app/components/datasets/extra-info/service-api/index.tsx
index c3fe05248d..453715bfe3 100644
--- a/web/app/components/datasets/extra-info/service-api/index.tsx
+++ b/web/app/components/datasets/extra-info/service-api/index.tsx
@@ -1,8 +1,9 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
-import { useState } from 'react'
+import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import Card from './card'
@@ -15,6 +16,15 @@ const ServiceApi = ({
}: ServiceApiProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
+ const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
+
+ const handleOpenSecretKeyModal = useCallback(() => {
+ setIsSecretKeyModalVisible(true)
+ }, [])
+
+ const handleCloseSecretKeyModal = useCallback(() => {
+ setIsSecretKeyModalVisible(false)
+ }, [])
return (
@@ -49,9 +59,14 @@ const ServiceApi = ({
>
+
)
}
diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx
index beee35b06d..adc53debbd 100644
--- a/web/app/components/datasets/list/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/list/__tests__/index.spec.tsx
@@ -99,6 +99,14 @@ vi.mock('../../external-api/external-api-panel', () => ({
),
}))
+// Mock SecretKeyModal — it depends on user profile context and service APIs
+// not configured in this test. ServiceApi always mounts the modal (controlled
+// by `isShow`) so we provide a lightweight stub.
+vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
+ default: ({ isShow }: { isShow: boolean }) =>
+ isShow ? : null,
+}))
+
// Mock TagManagementModal
vi.mock('@/app/components/base/tag-management', () => ({
default: () => ,
diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx
index fca64ac096..327e0bddcf 100644
--- a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx
+++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SetURL from '../setURL'
@@ -53,6 +53,15 @@ describe('SetURL', () => {
const input = screen.getByLabelText('plugin.installFromGitHub.gitHubRepo')
expect(input).toBeInTheDocument()
})
+
+ it('should auto-focus the input on mount', async () => {
+ render()
+
+ const input = screen.getByRole('textbox')
+ await waitFor(() => {
+ expect(input).toHaveFocus()
+ })
+ })
})
// ================================
diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx
index f1b149a8bf..b4f0741eb2 100644
--- a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx
+++ b/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.tsx
@@ -13,6 +13,18 @@ type SetURLProps = {
const SetURL: React.FC = ({ repoUrl, onChange, onNext, onCancel }) => {
const { t } = useTranslation()
+ const inputRef = React.useRef(null)
+
+ // Focus the input after the dropdown's focus-return animation settles.
+ // Using rAF avoids racing the DropdownMenu FloatingFocusManager that returns
+ // focus to the trigger on close.
+ React.useEffect(() => {
+ const frame = requestAnimationFrame(() => {
+ inputRef.current?.focus()
+ })
+ return () => cancelAnimationFrame(frame)
+ }, [])
+
return (
<>
({
useDebugKey: () => mockDebugKey,
}))
-vi.mock('@langgenius/dify-ui/button', () => ({
- Button: ({ children }: { children: React.ReactNode }) => ,
-}))
-
-vi.mock('@/app/components/base/tooltip', () => ({
- default: ({
- children,
- disabled,
- popupContent,
- }: {
- children: React.ReactNode
- disabled?: boolean
- popupContent: React.ReactNode
- }) => (
-
- {children}
- {!disabled &&
{popupContent}
}
-
- ),
-}))
-
vi.mock('../../base/key-value-item', () => ({
default: ({
label,
@@ -68,16 +48,31 @@ describe('DebugInfo', () => {
expect(container.innerHTML).toBe('')
})
- it('renders debug metadata and masks the key when info is available', () => {
+ it('renders a disabled trigger when debug info is unavailable', () => {
+ render()
+
+ const trigger = screen.getByRole('button')
+ expect(trigger).toBeDisabled()
+ })
+
+ it('opens a popover with debug metadata and masks the key when info is available', async () => {
mockDebugKey.data = {
host: '127.0.0.1',
port: 5001,
key: '12345678abcdefghijklmnopqrst87654321',
}
+ const user = userEvent.setup()
render()
- expect(screen.getByTestId('debug-button')).toBeInTheDocument()
+ const trigger = screen.getByRole('button')
+ expect(trigger).toBeEnabled()
+
+ // Popover is closed initially — content not rendered yet
+ expect(screen.queryByText('plugin.debugInfo.title')).not.toBeInTheDocument()
+
+ await user.click(trigger)
+
expect(screen.getByText('plugin.debugInfo.title')).toBeInTheDocument()
expect(screen.getByRole('link')).toHaveAttribute(
'href',
diff --git a/web/app/components/plugins/plugin-page/debug-info.tsx b/web/app/components/plugins/plugin-page/debug-info.tsx
index 4e02fdc2be..9e4404c39c 100644
--- a/web/app/components/plugins/plugin-page/debug-info.tsx
+++ b/web/app/components/plugins/plugin-page/debug-info.tsx
@@ -1,13 +1,13 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiArrowRightUpLine,
RiBugLine,
} from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
-import Tooltip from '@/app/components/base/tooltip'
import { useDocLink } from '@/context/i18n'
import { useDebugKey } from '@/service/use-plugins'
import KeyValueItem from '../base/key-value-item'
@@ -25,41 +25,54 @@ const DebugInfo: FC = () => {
if (isLoading)
return null
- return (
-
-
-
-
-
-
- >
- )}
- popupClassName="flex flex-col items-start w-[256px] px-4 py-3.5 gap-1 border border-components-panel-border
- rounded-xl bg-components-tooltip-bg shadows-shadow-lg z-50"
- asChild={false}
- position="bottom"
- >
-
+ )
+ }
+
+ return (
+
+
+
+
+ )}
+ />
+
+
+
+
+
+
+
+
)
}