refactor(web): align integrations sidebar components (#39202)

Co-authored-by: Jingyi <jingyi.qi@dify.ai>
This commit is contained in:
yyh 2026-07-18 06:52:47 +08:00 committed by GitHub
parent 63ca2b94b5
commit fa65f45bff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 90 additions and 145 deletions

View File

@ -1,4 +1,5 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithNuqs } from '@/test/nuqs-testing'
import IntegrationsPage from '../page'
@ -476,10 +477,11 @@ describe('IntegrationsPage', () => {
['extension', 'empty marketplace', '/plugins/extension'],
] as const)(
'opens the %s marketplace path from integrations',
(section, buttonName, marketplacePath) => {
async (section, buttonName, marketplacePath) => {
const user = userEvent.setup()
renderIntegrationsPage({ section })
fireEvent.click(screen.getByRole('button', { name: buttonName }))
await user.click(screen.getByRole('button', { name: buttonName }))
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(`${marketplacePath}?source=`),
@ -490,11 +492,12 @@ describe('IntegrationsPage', () => {
},
)
it('passes marketplace platform paths to external marketplace callbacks', () => {
it('passes marketplace platform paths to external marketplace callbacks', async () => {
const user = userEvent.setup()
const onSwitchToMarketplace = vi.fn()
renderIntegrationsPage({ section: 'trigger' }, { onSwitchToMarketplace })
fireEvent.click(screen.getByRole('button', { name: 'empty marketplace' }))
await user.click(screen.getByRole('button', { name: 'empty marketplace' }))
expect(onSwitchToMarketplace).toHaveBeenCalledWith('/plugins/trigger')
expect(mockRouterPush).not.toHaveBeenCalled()
@ -633,60 +636,8 @@ describe('IntegrationsPage', () => {
).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})
it('uses hover-only arrows for the tools parent icon', () => {
const view = renderIntegrationsPage({ section: 'provider' })
const collapsedToolsButton = screen.getByRole('button', { name: 'common.menus.tools' })
const collapsedDisclosureIcon = collapsedToolsButton.querySelector(
'svg[viewBox="0 0 12 14.0003"]',
)
expect(collapsedToolsButton).toHaveAttribute('aria-expanded', 'false')
expect(collapsedDisclosureIcon).toBeInTheDocument()
expect(collapsedDisclosureIcon).toHaveClass('h-3.5', 'w-3', 'group-hover:hidden')
expect(collapsedToolsButton.querySelector('[data-icon="MagicBox"]')).not.toBeInTheDocument()
expect(
collapsedToolsButton.querySelector('.i-custom-vender-solid-mediaAndDevices-magic-box'),
).not.toBeInTheDocument()
expect(
collapsedToolsButton.querySelector('.i-custom-vender-plugin-box-sparkle-fill'),
).not.toBeInTheDocument()
expect(collapsedToolsButton.querySelector('.i-ri-arrow-down-s-line')).toHaveClass(
'hidden',
'group-hover:inline-block',
)
expect(collapsedToolsButton.querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument()
expect(
screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' }),
).not.toBeInTheDocument()
view.unmount()
renderIntegrationsPage({ section: 'mcp' })
const expandedToolsButton = screen.getByRole('button', { name: 'common.menus.tools' })
const expandedDisclosureIcon = expandedToolsButton.querySelector(
'svg[viewBox="0 0 12 14.0003"]',
)
expect(expandedToolsButton).toHaveAttribute('aria-expanded', 'true')
expect(expandedToolsButton).not.toHaveClass('bg-state-base-active')
expect(expandedToolsButton).not.toHaveAttribute('aria-current')
expect(expandedDisclosureIcon).toBeInTheDocument()
expect(expandedToolsButton.querySelector('.i-ri-arrow-up-s-line')).toHaveClass(
'hidden',
'group-hover:inline-block',
)
expect(expandedToolsButton.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
expect(
expandedToolsButton.querySelector('.i-custom-vender-integrations-tools-active'),
).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute(
'href',
'/integrations/tools/built-in',
)
})
it('toggles the tools submenu without other nav items closing it', () => {
it('toggles the tools submenu from the keyboard without other nav items closing it', async () => {
const user = userEvent.setup()
const onSectionChange = vi.fn()
renderWithNuqs(<IntegrationsPage section="provider" onSectionChange={onSectionChange} />)
@ -699,31 +650,34 @@ describe('IntegrationsPage', () => {
expect(toolsButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: 'MCP' })).not.toBeInTheDocument()
fireEvent.click(toolsButton)
toolsButton.focus()
await user.keyboard('{Enter}')
expect(onSectionChange).toHaveBeenCalledWith('builtin')
expect(toolsButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.settings.provider' }))
await user.click(screen.getByRole('button', { name: 'common.settings.provider' }))
expect(onSectionChange).toHaveBeenCalledWith('provider')
expect(toolsButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument()
fireEvent.click(toolsButton)
toolsButton.focus()
await user.keyboard(' ')
expect(toolsButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: 'MCP' })).not.toBeInTheDocument()
expect(onSectionChange).toHaveBeenCalledTimes(2)
})
it('keeps custom, workflow, and MCP tool entries visible without manage permissions', () => {
it('keeps custom, workflow, and MCP tool entries visible without manage permissions', async () => {
const user = userEvent.setup()
mockAppContextState.workspacePermissionKeys = ['mcp.manage']
renderIntegrationsPage(undefined, { section: 'provider', onSectionChange: vi.fn() })
fireEvent.click(screen.getByRole('button', { name: 'common.menus.tools' }))
await user.click(screen.getByRole('button', { name: 'common.menus.tools' }))
expect(screen.getByRole('button', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument()
@ -735,15 +689,17 @@ describe('IntegrationsPage', () => {
).toBeInTheDocument()
})
it('opens tools to the tools plugin page when the parent tools nav is clicked', () => {
it('opens tools to the tools plugin page when the parent tools nav is clicked', async () => {
const user = userEvent.setup()
renderIntegrationsPage(undefined, 'provider')
fireEvent.click(screen.getByRole('button', { name: 'common.menus.tools' }))
await user.click(screen.getByRole('button', { name: 'common.menus.tools' }))
expect(mockRouterPush).toHaveBeenCalledWith('/integrations/tools/built-in')
})
it('keeps the tools disclosure independent from route section changes', () => {
it('keeps the tools disclosure independent from route section changes', async () => {
const user = userEvent.setup()
const view = renderIntegrationsPage(undefined, 'mcp')
expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp')
@ -754,7 +710,7 @@ describe('IntegrationsPage', () => {
expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'MCP' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.menus.tools' }))
await user.click(screen.getByRole('button', { name: 'common.menus.tools' }))
expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp')
expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute(
@ -938,10 +894,11 @@ describe('IntegrationsPage', () => {
},
)
it('opens the integrations marketplace path from the install dropdown marketplace action', () => {
it('opens the integrations marketplace path from the install dropdown marketplace action', async () => {
const user = userEvent.setup()
renderIntegrationsPage({ section: 'builtin' })
fireEvent.click(screen.getByRole('button', { name: 'plugin install' }))
await user.click(screen.getByRole('button', { name: 'plugin install' }))
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining('/plugins/tool?source='),
@ -985,27 +942,18 @@ describe('IntegrationsPage', () => {
expect(screen.queryByTestId('update-setting-dialog')).not.toBeInTheDocument()
})
it('opens the sidebar plugin permissions quick settings and updates permissions', () => {
it('opens the sidebar plugin permissions quick settings and updates permissions', async () => {
const user = userEvent.setup()
renderIntegrationsPage({ section: 'provider' })
fireEvent.click(screen.getByRole('button', { name: 'plugin.privilege.permissions' }))
await user.click(screen.getByRole('button', { name: 'plugin.privilege.permissions' }))
expect(screen.getAllByText('plugin.privilege.permissions').length).toBeGreaterThan(0)
expect(screen.getByText('plugin.privilege.quickWhoCanInstall')).toBeInTheDocument()
expect(screen.getByText('plugin.privilege.quickWhoCanDebug')).toBeInTheDocument()
const dialog = screen.getByRole('dialog', { name: 'plugin.privilege.permissions' })
expect(within(dialog).getByText('plugin.privilege.quickWhoCanInstall')).toBeInTheDocument()
expect(within(dialog).getByText('plugin.privilege.quickWhoCanDebug')).toBeInTheDocument()
const dialog = screen.getByRole('dialog')
expect(
within(dialog).getByText('plugin.privilege.permissions').closest('.w-\\[360px\\]'),
).toHaveClass('rounded-2xl', 'shadow-2xl')
expect(
screen.getByRole('radio', {
name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.everyone',
}),
).toHaveClass('w-[104px]', 'h-8')
fireEvent.click(
screen.getByRole('radio', {
await user.click(
within(dialog).getByRole('radio', {
name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.noone',
}),
)
@ -1014,6 +962,12 @@ describe('IntegrationsPage', () => {
install_permission: 'noone',
debug_permission: 'admins',
})
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
expect(
screen.queryByRole('dialog', { name: 'plugin.privilege.permissions' }),
).not.toBeInTheDocument()
})
it('hides the sidebar plugin permissions quick settings when permission management is unavailable', () => {

View File

@ -4,6 +4,7 @@ import type { CSSProperties, ReactNode } from 'react'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -191,10 +192,9 @@ export default function IntegrationsPage({
router.push(buildIntegrationPath(nextSection))
}
const handleToggleTools = () => {
const willExpand = !isToolsExpanded
setIsToolsExpanded(willExpand)
if (willExpand && section !== 'builtin') handleSelectSection('builtin')
const handleToolsOpenChange = (open: boolean) => {
setIsToolsExpanded(open)
if (open && section !== 'builtin') handleSelectSection('builtin')
}
const toolsNavItemClassName = cn(
integrationSidebarNavItemClassName,
@ -204,12 +204,8 @@ export default function IntegrationsPage({
const toolsNavItemContent = (
<>
<span aria-hidden className="flex size-5 shrink-0 items-center justify-center">
<ToolsDisclosureIcon className="h-3.5 w-3 group-hover:hidden" />
{isToolsExpanded ? (
<span className="i-ri-arrow-up-s-line hidden size-4 group-hover:inline-block" />
) : (
<span className="i-ri-arrow-down-s-line hidden size-4 group-hover:inline-block" />
)}
<ToolsDisclosureIcon className="h-3.5 w-3 group-hover:hidden group-focus-visible:hidden" />
<span className="i-ri-arrow-down-s-line hidden size-4 transition-transform duration-100 ease-out group-hover:inline-block group-focus-visible:inline-block group-data-panel-open:rotate-180 motion-reduce:transition-none" />
</span>
<span className="min-w-0 flex-1 truncate">
{t(($) => $['menus.tools'], { ns: 'common' })}
@ -254,29 +250,27 @@ export default function IntegrationsPage({
onSelect={onSectionChange}
section={section}
/>
<div>
<button
type="button"
<Collapsible open={isToolsExpanded} onOpenChange={handleToolsOpenChange}>
<CollapsibleTrigger
aria-label={t(($) => $['menus.tools'], { ns: 'common' })}
aria-expanded={isToolsExpanded}
className={cn(toolsNavItemClassName, 'border-none bg-transparent')}
onClick={handleToggleTools}
className={cn(
toolsNavItemClassName,
'border-none bg-transparent data-panel-open:text-components-menu-item-text',
)}
>
{toolsNavItemContent}
</button>
{isToolsExpanded && (
<div className="relative space-y-px before:absolute before:top-[-1px] before:bottom-0 before:left-[17.5px] before:w-px before:bg-divider-regular">
{toolItems.map((item) => (
<IntegrationSidebarNavItem
key={item.label}
item={item}
onSelect={onSectionChange}
section={section}
/>
))}
</div>
)}
</div>
</CollapsibleTrigger>
<CollapsiblePanel className="relative space-y-px before:absolute before:top-[-1px] before:bottom-0 before:left-[17.5px] before:w-px before:bg-divider-regular">
{toolItems.map((item) => (
<IntegrationSidebarNavItem
key={item.label}
item={item}
onSelect={onSectionChange}
section={section}
/>
))}
</CollapsiblePanel>
</Collapsible>
<IntegrationSidebarNavItem
item={dataSourceItem}
onSelect={onSectionChange}

View File

@ -1,5 +1,5 @@
export const integrationSidebarNavItemClassName =
'flex h-8 w-full items-center gap-2 rounded-lg py-1 pr-1 pl-2 text-left system-sm-medium transition-colors'
'flex h-8 w-full items-center gap-2 rounded-lg py-1 pr-1 pl-2 text-left system-sm-medium outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-state-accent-solid'
export const integrationSidebarActiveNavItemClassName =
'bg-state-base-active text-components-menu-item-text-active'
export const integrationSidebarInactiveNavItemClassName =

View File

@ -6,14 +6,6 @@ vi.mock('../../../base/icons/src/vender/line/files', () => ({
CopyCheck: () => <span />,
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, onClick, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}))
const mockCopy = vi.fn()
vi.mock('copy-to-clipboard', () => ({
default: (...args: unknown[]) => mockCopy(...args),

View File

@ -1,12 +1,10 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { CopyCheck } from '../../base/icons/src/vender/line/files'
type Props = Readonly<{
@ -17,13 +15,13 @@ type Props = Readonly<{
valueMaxWidthClassName?: string
}>
const KeyValueItem: FC<Props> = ({
function KeyValueItem({
label,
labelWidthClassName = 'w-10',
value,
maskedValue,
valueMaxWidthClassName = 'max-w-[162px]',
}) => {
}: Props) {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const handleCopy = useCallback(() => {
@ -63,7 +61,12 @@ const KeyValueItem: FC<Props> = ({
<Tooltip>
<TooltipTrigger
render={
<ActionButton aria-label={copyLabel} onClick={handleCopy}>
<Button
variant="ghost"
aria-label={copyLabel}
className="size-6 p-0"
onClick={handleCopy}
>
{isCopied ? (
<CopyCheck aria-hidden className="size-3.5 shrink-0 text-text-tertiary" />
) : (
@ -72,7 +75,7 @@ const KeyValueItem: FC<Props> = ({
className="i-ri-clipboard-line size-3.5 shrink-0 text-text-tertiary"
/>
)}
</ActionButton>
</Button>
}
/>
<TooltipContent placement="top">{copyLabel}</TooltipContent>
@ -82,4 +85,4 @@ const KeyValueItem: FC<Props> = ({
)
}
export default React.memo(KeyValueItem)
export default KeyValueItem

View File

@ -76,7 +76,7 @@ describe('DebugInfo', () => {
'rounded-2xl',
'shadow-2xl',
)
expect(screen.getByRole('link')).toHaveAttribute(
expect(screen.getByRole('link', { name: 'plugin.debugInfo.viewDocs' })).toHaveAttribute(
'href',
'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin',
)

View File

@ -4,7 +4,6 @@ import type { ComponentProps, ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiArrowRightUpLine, RiBugLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { useDebugKey } from '@/service/use-plugins'
@ -29,7 +28,7 @@ function DebugInfo({
const { t } = useTranslation()
const docLink = useDocLink()
const { data: info, isLoading } = useDebugKey()
const trigger = triggerContent ?? <RiBugLine className="size-4" />
const trigger = triggerContent ?? <span aria-hidden className="i-ri-bug-line size-4" />
const triggerClassNames = cn(
!triggerClassName && 'size-full p-2 text-components-button-secondary-text',
triggerClassName,
@ -73,10 +72,10 @@ function DebugInfo({
)}
target="_blank"
rel="noopener noreferrer"
className="flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent"
className="flex cursor-pointer items-center gap-1 rounded-xs system-xs-regular text-text-accent outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span>{t(($) => $[`${i18nPrefix}.viewDocs`], { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="size-3" />
<span aria-hidden className="i-ri-arrow-right-up-line size-3" />
</a>
</div>
</div>

View File

@ -1,7 +1,8 @@
'use client'
import type { ReactNode } from 'react'
import { PopoverClose } from '@langgenius/dify-ui/popover'
import { Button } from '@langgenius/dify-ui/button'
import { PopoverClose, PopoverTitle } from '@langgenius/dify-ui/popover'
import { useTranslation } from 'react-i18next'
type PluginSidecarPanelProps = {
@ -18,18 +19,20 @@ export function PluginSidecarPanel({ children, footer, title }: PluginSidecarPan
<div className="relative flex w-full shrink-0 flex-col gap-0.5 px-3 pt-3.5 pb-1">
<div className="flex w-full shrink-0 items-start">
<div className="flex min-w-0 flex-1 flex-col items-start pr-8 pl-1">
<div className="w-full system-xl-semibold text-text-primary">{title}</div>
<PopoverTitle className="w-full system-xl-semibold text-text-primary">
{title}
</PopoverTitle>
</div>
</div>
<PopoverClose
render={
<button
type="button"
<Button
variant="ghost"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="absolute top-2.5 right-2.5 flex size-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
className="absolute top-2.5 right-2.5 size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
</Button>
}
/>
</div>