fix(web): improve data source integrations accessibility (SNP-872) (#42911)

This commit is contained in:
Joel authored and GitHub committed 2026-09-24 09:47:16 +00:00
1 parent 7ee0f6af7e
commit 18e0cb102b
24 files changed
+453 -164

No files matched your search

-24
View File
@@ -1984,22 +1984,6 @@
"count": 2
}
},
"web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/header/account-setting/data-source-page-new/card.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@@ -2010,14 +1994,6 @@
"count": 1
}
},
"web/app/components/header/account-setting/data-source-page-new/plugin-actions.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/header/account-setting/data-source-page-new/types.ts": {
"typescript/no-explicit-any": {
"count": 2
@@ -209,6 +209,7 @@ describe('Card Component', () => {
// Assert
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: 'Test Label' })).toBeInTheDocument()
expect(screen.queryByText(/Test Author/))!.not.toBeInTheDocument()
expect(screen.queryByText(/test-name/))!.not.toBeInTheDocument()
expect(screen.getByText('1.2.0'))!.toBeInTheDocument()
@@ -95,6 +95,7 @@ describe('Configure Component', () => {
}
render(<Configure item={itemWithApiKey} pluginPayload={mockPluginPayload} />)
const trigger = screen.getByRole('button', { name: /dataSource.configure/i })
expect(trigger).toHaveAccessibleName('common.dataSource.configure Test Label')
// Assert: Initially closed (button from content should not be present)
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
@@ -2,6 +2,7 @@ import type { UseQueryResult } from '@tanstack/react-query'
import type { DataSourceAuth } from '../types'
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useTheme } from 'next-themes'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
@@ -331,6 +332,28 @@ describe('DataSourcePage Component', () => {
expect(screen.getByText('Partner Source')).toBeInTheDocument()
expect(useMarketplaceAllPlugins).toHaveBeenLastCalledWith(mockProviders, 'partner')
})
it('announces the filtered installed data source count while searching', async () => {
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders },
isLoading: false,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
const user = userEvent.setup()
renderWithConsoleQuery(<DataSourcePage />, {
systemFeatures: { enable_marketplace: false },
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
await user.type(screen.getByRole('searchbox'), 'partner')
expect(screen.getByRole('status')).toHaveTextContent(
'plugin.category.datasources: plugin.marketplace.pluginsResult:{"num":1}',
)
await user.clear(screen.getByRole('searchbox'))
await user.type(screen.getByRole('searchbox'), 'missing')
expect(screen.getByRole('status')).toHaveTextContent(
'plugin.category.datasources: plugin.marketplace.pluginsResult:{"num":0}',
)
})
})
describe('Marketplace Integration', () => {
@@ -116,6 +116,19 @@ describe('InstallFromMarketplace Component', () => {
})
describe('Rendering', () => {
it('announces the number of recommended integrations after a search finishes', () => {
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: mockPlugins,
isLoading: false,
})
render(<InstallFromMarketplace providers={mockProviders} searchText="plugin" />)
expect(screen.getByRole('status')).toHaveTextContent(
'modelProvider.modelProvider.installDataSource: plugin.marketplace.pluginsResult:{"num":1}',
)
})
it('should render correctly when not loading and not collapsed', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
@@ -134,7 +147,14 @@ describe('InstallFromMarketplace Component', () => {
'https://marketplace.url/plugins/datasource?theme=light',
)
expect(screen.getByTestId('mock-list')).toBeInTheDocument()
expect(screen.getByTestId('mock-list')).toHaveClass('grid', 'grid-cols-3', 'gap-2')
expect(screen.getByTestId('mock-list')).toHaveClass(
'grid',
'grid-cols-1',
'sm:grid-cols-2',
'lg:grid-cols-3',
'xl:grid-cols-4',
'gap-2',
)
expect(screen.getByTestId('mock-provider-card-plugin-1')).toHaveClass('h-36.5')
expect(screen.queryByTestId('mock-provider-card-bundle-1')).not.toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
@@ -1,5 +1,6 @@
import type { DataSourceCredential } from '../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Item from '../item'
@@ -65,6 +66,31 @@ describe('Item Component', () => {
})
describe('Rename Mode Interactions', () => {
it.each(['save', 'cancel'])(
'keeps keyboard focus in the credential row after %s',
async (action) => {
const user = userEvent.setup()
render(
<Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />,
)
await user.click(screen.getByRole('button'))
await user.click(await screen.findByText('common.operation.rename'))
const input = await screen.findByRole('textbox', {
name: 'common.operation.rename Test Credential',
})
expect(input).toHaveFocus()
await user.click(screen.getByRole('button', { name: `common.operation.${action}` }))
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'common.operation.more Test Credential' }),
).toHaveFocus()
})
},
)
it('should switch to rename mode when Trigger Rename is clicked', async () => {
// Arrange
render(
@@ -140,6 +166,7 @@ describe('Item Component', () => {
// Arrange
const parentClick = vi.fn()
render(
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Test-only ancestor observes click propagation without being a control.
<div onClick={parentClick}>
<Item credentialItem={mockCredentialItem} onAction={mockOnAction} canManageCredential />
</div>,
@@ -27,6 +27,31 @@ describe('Operator Component', () => {
vi.clearAllMocks()
})
it('identifies the credential in each action button name', () => {
render(
<>
<Operator
credentialItem={createMockCredential(CredentialTypeEnum.API_KEY)}
onAction={mockOnAction}
/>
<Operator
credentialItem={{
...createMockCredential(CredentialTypeEnum.OAUTH2),
name: 'Work Account',
}}
onAction={mockOnAction}
/>
</>,
)
expect(
screen.getByRole('button', { name: 'common.operation.more Test Credential' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.operation.more Work Account' }),
).toBeInTheDocument()
})
describe('Conditional Action Rendering', () => {
it('should render correct actions for API_KEY type', async () => {
// Arrange
@@ -133,7 +133,9 @@ describe('DataSourcePluginActions', () => {
systemFeatures: { enable_marketplace: true },
})
fireEvent.click(
screen.getByRole('button', { name: 'plugin.detailPanel.operation.moreActions' }),
screen.getByRole('button', {
name: 'common.operation.moreActionsFor:{"name":"Data Source Plugin"}',
}),
)
fireEvent.click(screen.getByText('plugin.detailPanel.operation.viewReadme'))
@@ -144,4 +146,16 @@ describe('DataSourcePluginActions', () => {
}),
)
})
it('identifies the plugin and current version in the version action', () => {
renderWithConsoleQuery(<DataSourcePluginActions detail={createPluginDetail()} />, {
systemFeatures: { enable_marketplace: true },
})
expect(
screen.getByRole('button', {
name: 'plugin.detailPanel.switchVersion Data Source Plugin 1.0.0',
}),
).toBeInTheDocument()
})
})
@@ -101,7 +101,7 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => {
return (
<div className="rounded-xl bg-background-section-burn">
<div className="flex items-center gap-3 overflow-hidden px-3 pt-3 pb-2">
<div className="flex flex-wrap items-center gap-3 overflow-hidden px-3 pt-3 pb-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border-[0.5px] border-divider-regular bg-text-primary-on-surface p-1 shadow-xs backdrop-blur-sm">
<img
src={icon}
@@ -113,9 +113,9 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => {
className="h-5 w-5 object-contain"
/>
</div>
<div className="flex min-w-0 grow items-center gap-2">
<div className="truncate system-md-medium text-text-primary">{providerLabel}</div>
<div className="flex shrink-0 items-center gap-1">
<div className="flex min-w-0 grow basis-40 flex-wrap items-center gap-2">
<h2 className="min-w-0 truncate system-md-medium text-text-primary">{providerLabel}</h2>
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-1">
{pluginDetail ? (
<DataSourcePluginActions detail={pluginDetail} onUpdate={onPluginUpdate} />
) : (
@@ -137,7 +137,7 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => {
)}
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<div className="shrink-0">
<Configure
pluginPayload={pluginPayload}
item={item}
@@ -151,7 +151,7 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => {
<div className="ml-3 h-px grow bg-divider-subtle"></div>
</div>
{!!credentials_list.length && (
<div className="space-y-1 p-3 pt-2" onClick={(e) => e.stopPropagation()}>
<div className="space-y-1 p-3 pt-2">
{credentials_list.map((credentialItem) => (
<Item
key={credentialItem.id}
@@ -10,6 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AddApiKeyButton, AddOAuthButton } from '@/app/components/plugins/plugin-auth'
import { useRenderI18nObject } from '@/hooks/use-i18n'
type ConfigureProps = {
item: DataSourceAuth
@@ -19,6 +20,8 @@ type ConfigureProps = {
}
const Configure = ({ item, pluginPayload, onUpdate, disabled }: ConfigureProps) => {
const { t } = useTranslation(['common', 'plugin'])
const renderI18nObject = useRenderI18nObject()
const providerLabel = renderI18nObject(item.label)
const [open, setOpen] = useState(false)
const canApiKey = item.credential_schema?.length
const oAuthData = item.oauth_schema || {}
@@ -47,7 +50,11 @@ const Configure = ({ item, pluginPayload, onUpdate, disabled }: ConfigureProps)
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button className="h-8" variant="secondary-accent">
<Button
aria-label={`${t(($) => $['dataSource.configure'], { ns: 'common' })} ${providerLabel}`}
className="h-8"
variant="secondary-accent"
>
<span className="i-ri-add-line size-4" aria-hidden="true" />
{t(($) => $['dataSource.configure'], { ns: 'common' })}
</Button>
@@ -54,7 +54,7 @@ function DataSourceListSkeleton() {
}
const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSourcePageProps) => {
const { t } = useTranslation(['common'])
const { t } = useTranslation(['common', 'plugin'])
const queryClient = useQueryClient()
const renderI18nObject = useRenderI18nObject()
const [searchText, setSearchText] = useState('')
@@ -107,13 +107,13 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource
className={
stickyToolbar
? layout
? 'flex w-full items-center justify-between gap-3'
: 'sticky top-0 z-10 -mx-6 mb-2 flex items-center justify-between gap-3 bg-components-panel-bg px-6 pb-2'
: 'mb-2 flex items-center justify-between gap-3'
? 'flex w-full flex-wrap items-center justify-between gap-3'
: 'sticky top-0 z-10 -mx-6 mb-2 flex flex-wrap items-center justify-between gap-3 bg-components-panel-bg px-6 pb-2'
: 'mb-2 flex flex-wrap items-center justify-between gap-3'
}
>
<SearchInput
className="w-50"
className="w-full min-w-0 sm:w-50"
placeholder={t(($) => $['operation.search'], { ns: 'common' })}
value={searchText}
onValueChange={setSearchText}
@@ -125,6 +125,16 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource
const body = (
<>
{isDataSourceListLoading && <DataSourceListSkeleton />}
{!isDataSourceListLoading && !!searchText.trim() && (
<div role="status" className="sr-only">
{t(($) => $['category.datasources'], { ns: 'plugin' })}
{': '}
{t(($) => $['marketplace.pluginsResult'], {
ns: 'plugin',
num: filteredDataSources.length,
})}
</div>
)}
{!isDataSourceListLoading && !dataSources.length && (
<div
className="mb-2 rounded-[10px] bg-workflow-process-bg p-4"
@@ -42,7 +42,7 @@ const InstallFromMarketplace = ({
return (
<div className="mb-2">
<Separator className="my-2 mt-4" />
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<button
type="button"
aria-expanded={!collapse}
@@ -55,7 +55,7 @@ const InstallFromMarketplace = ({
/>
{t(($) => $['modelProvider.installDataSource'], { ns: 'modelProvider' })}
</button>
<div className="mb-2 flex items-center pt-2">
<div className="mb-2 flex flex-wrap items-center pt-2">
<span className="pr-1 system-sm-regular text-text-tertiary">
{t(($) => $['modelProvider.discoverMore'], { ns: 'modelProvider' })}
</span>
@@ -81,6 +81,16 @@ const InstallFromMarketplace = ({
)}
</div>
</div>
{!!searchText.trim() && !isAllPluginsLoading && !collapse && (
<div role="status" className="sr-only">
{t(($) => $['modelProvider.installDataSource'], { ns: 'modelProvider' })}
{': '}
{t(($) => $['marketplace.pluginsResult'], {
ns: 'plugin',
num: allPlugins.filter((plugin) => plugin.type !== 'bundle').length,
})}
</div>
)}
{!collapse && isAllPluginsLoading && <LoadingPlaceholder />}
{!isAllPluginsLoading && !collapse && (
<List
@@ -88,7 +98,7 @@ const InstallFromMarketplace = ({
marketplaceCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton={canInstallPlugin}
cardContainerClassName="grid grid-cols-3 gap-2"
cardContainerClassName="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
cardRender={cardRender}
emptyClassName="h-auto"
/>
@@ -2,7 +2,7 @@ import type { DataSourceCredential } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { memo, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Operator from './operator'
@@ -25,12 +25,30 @@ const Item = ({
const { t } = useTranslation(['common', 'plugin'])
const [renaming, setRenaming] = useState(false)
const [renameValue, setRenameValue] = useState(credentialItem.name)
const renameInputRef = useRef<HTMLInputElement>(null)
const operatorTriggerRef = useRef<HTMLButtonElement>(null)
const restoreFocusOnExitRef = useRef(false)
useEffect(() => {
if (renaming) {
renameInputRef.current?.focus()
} else if (restoreFocusOnExitRef.current) {
operatorTriggerRef.current?.focus()
restoreFocusOnExitRef.current = false
}
}, [renaming])
const exitRename = () => {
restoreFocusOnExitRef.current = true
setRenaming(false)
}
return (
<div className="flex h-10 items-center gap-3 overflow-hidden rounded-lg bg-components-panel-on-panel-item-bg py-1 pr-1 pl-3 shadow-xs">
{renaming && (
<div className="flex w-full items-center space-x-1">
<Input
ref={renameInputRef}
aria-label={`${t(($) => $['operation.rename'], { ns: 'common' })} ${credentialItem.name}`}
className="h-6 min-w-0 grow"
value={renameValue}
@@ -47,7 +65,7 @@ const Item = ({
credential_id: credentialItem.id,
name: renameValue,
})
setRenaming(false)
exitRename()
}}
>
{t(($) => $['operation.save'], { ns: 'common' })}
@@ -56,7 +74,7 @@ const Item = ({
size="small"
onClick={(e) => {
e.stopPropagation()
setRenaming(false)
exitRename()
}}
>
{t(($) => $['operation.cancel'], { ns: 'common' })}
@@ -87,6 +105,7 @@ const Item = ({
</div>
<div className="mr-1 ml-2 h-3 w-px bg-divider-regular"></div>
<Operator
triggerRef={operatorTriggerRef}
credentialItem={credentialItem}
onAction={onAction}
onRename={() => setRenaming(true)}
@@ -1,3 +1,4 @@
import type { Ref } from 'react'
import type { DataSourceCredential } from './types'
import {
DropdownMenu,
@@ -15,6 +16,7 @@ type OperatorProps = {
credentialItem: DataSourceCredential
onAction: (action: string, credentialItem: DataSourceCredential) => void
onRename?: () => void
triggerRef?: Ref<HTMLButtonElement>
canUseCredential?: boolean
canManageCredential?: boolean
}
@@ -22,6 +24,7 @@ const Operator = ({
credentialItem,
onAction,
onRename,
triggerRef,
canUseCredential = false,
canManageCredential = false,
}: OperatorProps) => {
@@ -47,8 +50,9 @@ const Operator = ({
<DropdownMenuTrigger
render={
<IconButton
ref={triggerRef}
size="lg"
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
aria-label={`${t(($) => $['operation.more'], { ns: 'common' })} ${credentialItem.name}`}
className="data-popup-open:bg-state-base-hover"
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
@@ -17,6 +17,7 @@ import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/us
import { useReadmePanelStore } from '@/app/components/plugins/readme-panel/store'
import { PluginSource } from '@/app/components/plugins/types'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import useTheme from '@/hooks/use-theme'
import { getMarketplaceUrl } from '@/utils/var'
@@ -40,9 +41,10 @@ const getDetailUrl = (detail: PluginDetail, locale: string, theme: string) => {
}
const DataSourcePluginActions = ({ detail, onUpdate }: Props) => {
const { t } = useTranslation(['plugin'])
const { t } = useTranslation(['common', 'plugin'])
const { theme } = useTheme()
const locale = useLocale()
const renderI18nObject = useRenderI18nObject()
const readmeTriggerId = useId()
const openReadmePanel = useReadmePanelStore((s) => s.openReadmePanel)
const { canDeletePlugin, canUpdatePlugin } = usePluginSettingsAccess()
@@ -65,6 +67,7 @@ const DataSourcePluginActions = ({ detail, onUpdate }: Props) => {
onUpdate,
})
const displayVersion = isFromGitHub ? (detail.meta?.version ?? detail.version) : detail.version
const pluginLabel = renderI18nObject(detail.declaration.label) || detail.name
const handleVersionSelect = (state: {
version: string
@@ -92,7 +95,7 @@ const DataSourcePluginActions = ({ detail, onUpdate }: Props) => {
}
return (
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
<div className="flex shrink-0 items-center gap-1">
{!!displayVersion && (
<PluginVersionPicker
disabled={!canUpdatePlugin || !isFromMarketplace}
@@ -100,6 +103,7 @@ const DataSourcePluginActions = ({ detail, onUpdate }: Props) => {
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={detail.version}
triggerAccessibleName={`${t(($) => $['detailPanel.switchVersion'], { ns: 'plugin' })} ${pluginLabel} ${displayVersion}`}
onSelect={handleVersionSelect}
trigger={() => (
<Badge
@@ -131,6 +135,7 @@ const DataSourcePluginActions = ({ detail, onUpdate }: Props) => {
size="small"
className="h-5 rounded-md px-1.5 py-0 system-xs-medium"
onClick={handleTriggerLatestUpdate}
aria-label={`${t(($) => $['detailPanel.operation.update'], { ns: 'plugin' })} ${pluginLabel}`}
>
{t(($) => $['detailPanel.operation.update'], { ns: 'plugin' })}
</Button>
@@ -142,6 +147,10 @@ const DataSourcePluginActions = ({ detail, onUpdate }: Props) => {
</Tooltip>
)}
<OperationDropdown
triggerAriaLabel={t(($) => $['operation.moreActionsFor'], {
ns: 'common',
name: pluginLabel,
})}
source={detail.source}
onInfo={modalStates.showPluginInfo}
onCheckVersion={handleUpdate}
@@ -1,5 +1,6 @@
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
@@ -360,6 +361,52 @@ describe('IntegrationsPage', () => {
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('closes compact navigation after switching sections in settings', async () => {
const user = userEvent.setup()
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
matches: query === '(width < 48rem)',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
)
const SettingsIntegrations = () => {
const [section, setSection] =
useState<React.ComponentProps<typeof IntegrationsPage>['section']>('provider')
return <IntegrationsPage section={section} onSectionChange={setSection} />
}
renderWithNuqs(<SettingsIntegrations />)
await user.click(
screen.getByRole('button', {
name: 'navigation.mainNav.integrations: navigation.settings.provider',
}),
)
const navigationDrawer = await screen.findByRole('dialog', {
name: 'navigation.mainNav.integrations',
})
await user.click(
within(navigationDrawer).getByRole('button', {
name: 'navigation.settings.dataSource',
}),
)
await waitFor(() => {
expect(
screen.queryByRole('dialog', {
name: 'navigation.mainNav.integrations',
}),
).not.toBeInTheDocument()
})
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
})
it('defaults to the model provider section when no query is provided', () => {
const { container } = renderIntegrationsPage()
+160 -97
View File
@@ -3,8 +3,20 @@
import type { CSSProperties, ReactNode } from 'react'
import type { IntegrationSection } from './routes'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import {
ScrollArea,
ScrollAreaContent,
@@ -12,7 +24,7 @@ import {
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useEffect, useId, useState } from 'react'
import { useEffect, useId, useState, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import UpdateSettingDialog from '@/app/components/header/account-setting/update-setting-dialog'
import {
@@ -64,6 +76,15 @@ const headerDescriptionDocPaths = {
'agent-strategy': '/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin',
} satisfies Partial<Record<IntegrationSection, DocPathWithoutLang>>
const compactNavigationQuery = '(width < 48rem)'
const subscribeToCompactNavigation = (onChange: () => void) => {
const mediaQuery = window.matchMedia(compactNavigationQuery)
mediaQuery.addEventListener('change', onChange)
return () => mediaQuery.removeEventListener('change', onChange)
}
const getCompactNavigationSnapshot = () => window.matchMedia(compactNavigationQuery).matches
const getServerCompactNavigationSnapshot = () => false
type DescriptionWithLearnMoreProps = {
children: ReactNode
href: string
@@ -74,8 +95,8 @@ const DescriptionWithLearnMore = ({ children, href, label }: DescriptionWithLear
const title = typeof children === 'string' ? children : undefined
return (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate" title={title}>
<span className="inline-flex max-w-full min-w-0 items-center gap-0.5">
<span className="min-w-0 break-words whitespace-normal sm:truncate" title={title}>
{children}
</span>
<Link
@@ -125,8 +146,13 @@ export default function IntegrationsPage({
section: routeSection,
syncDocumentTitle = false,
}: IntegrationsPageProps) {
const { t } = useTranslation(['navigation', 'modelProvider'])
const { t } = useTranslation(['navigation', 'modelProvider', 'common'])
const navigationTitleId = useId()
const isCompact = useSyncExternalStore(
subscribeToCompactNavigation,
getCompactNavigationSnapshot,
getServerCompactNavigationSnapshot,
)
const docLink = useDocLink()
const router = useRouter()
const section = useIntegrationSection(routeSection)
@@ -143,6 +169,7 @@ export default function IntegrationsPage({
showPluginCategorySetting,
} = useIntegrationPermissions(section)
const [providerSearchText, setProviderSearchText] = useState('')
const [isNavigationOpen, setIsNavigationOpen] = useState(false)
const showInstallAction = canInstallPlugin
const reserveInstallActionSlot = showInstallAction || isReferenceSettingLoading
const showUtilityActions = canDebugger || showPermissionQuickPanel
@@ -221,6 +248,7 @@ export default function IntegrationsPage({
}
const handleSelectSection = (nextSection: IntegrationSection) => {
if (onSectionChange) {
setIsNavigationOpen(false)
onSectionChange(nextSection)
return
}
@@ -248,108 +276,143 @@ export default function IntegrationsPage({
</>
)
const sidebar = (
<div
className={cn(
'flex shrink-0 flex-col bg-components-panel-bg px-2 py-2 transition-[width]',
isCompact
? 'h-full w-full items-stretch overflow-y-auto'
: 'w-50 items-end border-r border-divider-burn',
)}
data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integration}
>
<div
className={cn('flex min-h-0 flex-1 flex-col gap-0.5 pb-4', isCompact ? 'w-full' : 'w-46')}
>
<div
className={cn(
'flex shrink-0 items-start pr-0 pl-2.5',
reserveInstallActionSlot ? 'h-14 pt-1 pb-7' : 'mb-3 pt-1 pb-0.5',
)}
>
<div className="flex h-6 min-w-0 flex-1 items-center justify-center">
<div
id={navigationTitleId}
className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary"
>
{t(($) => $['settings.integrations'], { ns: 'navigation' })}
</div>
</div>
</div>
{showInstallAction && (
<IntegrationSidebarActions
canManagement={canInstallPlugin}
installContextCategory={getPluginCategoryBySection(section)}
onSwitchToMarketplace={handleSwitchToMarketplace}
/>
)}
{!showInstallAction && reserveInstallActionSlot && (
<div aria-hidden="true" className="h-8 w-full shrink-0" />
)}
<nav
aria-labelledby={navigationTitleId}
className={cn('shrink-0 space-y-px', reserveInstallActionSlot ? 'mt-6' : 'py-4')}
>
<IntegrationSidebarNavItem
item={providerItem}
onSelect={onSectionChange ? handleSelectSection : undefined}
section={section}
/>
<Collapsible open={isToolsExpanded} onOpenChange={handleToolsOpenChange}>
<CollapsibleTrigger
className={cn(
toolsNavItemClassName,
'min-h-8 touch-manipulation justify-between border-none bg-transparent select-none data-panel-open:text-components-menu-item-text',
)}
>
{toolsNavItemContent}
</CollapsibleTrigger>
<CollapsiblePanel className="relative space-y-px before:absolute before:-top-px 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 ? handleSelectSection : undefined}
section={section}
/>
))}
</CollapsiblePanel>
</Collapsible>
<IntegrationSidebarNavItem
item={dataSourceItem}
onSelect={onSectionChange ? handleSelectSection : undefined}
section={section}
/>
{secondaryItems.map((item) => (
<IntegrationSidebarNavItem
key={item.label}
item={item}
onSelect={onSectionChange ? handleSelectSection : undefined}
section={section}
/>
))}
<IntegrationSidebarNavItem
item={customEndpointItem}
onSelect={onSectionChange ? handleSelectSection : undefined}
section={section}
/>
</nav>
</div>
{showUtilityActions && (
<IntegrationSidebarUtilityActions
canDebugger={canDebugger}
permission={permission}
showPermissionQuickPanel={showPermissionQuickPanel}
onPermissionChange={handlePermissionChange}
/>
)}
</div>
)
return (
<div
className="flex h-full min-h-0 w-full flex-1 bg-components-panel-bg"
className="flex h-full min-h-0 w-full flex-1 flex-col bg-components-panel-bg max-md:h-auto max-md:min-h-full md:flex-row"
style={sidebarWidthStyle}
>
{syncDocumentTitle && (
<IntegrationsDocumentTitle title={`${sectionTitle} · ${integrationsTitle}`} />
)}
<div
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-components-panel-bg px-2 py-2 transition-[width]',
'w-50 items-end',
)}
data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integration}
>
<div className="flex min-h-0 w-46 flex-1 flex-col gap-0.5 pb-4">
<div
className={cn(
'flex shrink-0 items-start pr-0 pl-2.5',
reserveInstallActionSlot ? 'h-14 pt-1 pb-7' : 'mb-3 pt-1 pb-0.5',
)}
>
<div className="flex h-6 min-w-0 flex-1 items-center justify-center">
<div
id={navigationTitleId}
className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary"
>
{t(($) => $['settings.integrations'], { ns: 'navigation' })}
</div>
</div>
</div>
{showInstallAction && (
<IntegrationSidebarActions
canManagement={canInstallPlugin}
installContextCategory={getPluginCategoryBySection(section)}
onSwitchToMarketplace={handleSwitchToMarketplace}
/>
)}
{!showInstallAction && reserveInstallActionSlot && (
<div aria-hidden="true" className="h-8 w-full shrink-0" />
)}
<nav
aria-labelledby={navigationTitleId}
className={cn('shrink-0 space-y-px', reserveInstallActionSlot ? 'mt-6' : 'py-4')}
>
<IntegrationSidebarNavItem
item={providerItem}
onSelect={onSectionChange}
section={section}
/>
<Collapsible open={isToolsExpanded} onOpenChange={handleToolsOpenChange}>
<CollapsibleTrigger
className={cn(
toolsNavItemClassName,
'min-h-8 touch-manipulation justify-between border-none bg-transparent select-none data-panel-open:text-components-menu-item-text',
)}
>
{toolsNavItemContent}
</CollapsibleTrigger>
<CollapsiblePanel className="relative space-y-px before:absolute before:-top-px 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}
section={section}
/>
{secondaryItems.map((item) => (
<IntegrationSidebarNavItem
key={item.label}
item={item}
onSelect={onSectionChange}
section={section}
/>
))}
<IntegrationSidebarNavItem
item={customEndpointItem}
onSelect={onSectionChange}
section={section}
/>
</nav>
{isCompact ? (
<div className="shrink-0 border-b border-divider-burn px-2 pb-2">
<Drawer swipeDirection="left" open={isNavigationOpen} onOpenChange={setIsNavigationOpen}>
<DrawerTrigger render={<Button variant="secondary" className="max-w-full" />}>
<span aria-hidden="true" className="i-ri-menu-line size-4 shrink-0" />
<span className="truncate">
{integrationsTitle}: {sectionTitle}
</span>
</DrawerTrigger>
<DrawerPortal>
<DrawerBackdrop className="fixed" />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=left]:w-80 data-[swipe-direction=left]:max-w-full">
<div className="flex shrink-0 items-center justify-between gap-2 px-3 py-2">
<DrawerTitle className="system-md-semibold">{integrationsTitle}</DrawerTitle>
<DrawerCloseButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
/>
</div>
<DrawerContent className="min-h-0 flex-1 p-0 pb-0">{sidebar}</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
</div>
{showUtilityActions && (
<IntegrationSidebarUtilityActions
canDebugger={canDebugger}
permission={permission}
showPermissionQuickPanel={showPermissionQuickPanel}
onPermissionChange={handlePermissionChange}
/>
)}
</div>
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
) : (
sidebar
)}
<section className="flex min-w-0 flex-1 flex-col overflow-hidden max-md:overflow-visible">
{useFillLayout ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden max-md:overflow-visible">
<IntegrationSectionRenderer
key={section}
section={section}
@@ -36,15 +36,22 @@ export function IntegrationPageHeader({
frameClassName,
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex w-full min-w-0 flex-col gap-0.5">
<h1 className="title-2xl-semi-bold text-text-primary">{title}</h1>
{showDescription && (
<div className={cn(descriptionClassName ?? 'system-sm-regular', 'text-text-tertiary')}>
<div
className={cn(
descriptionClassName ?? 'system-sm-regular',
'max-w-full min-w-0 text-text-tertiary',
)}
>
{description}
</div>
)}
</div>
{showToolbar && <div className="flex w-full items-center justify-between">{toolbar}</div>}
{showToolbar && (
<div className="flex w-full min-w-0 items-center justify-between">{toolbar}</div>
)}
</div>
</div>
)
@@ -21,7 +21,7 @@ export function IntegrationSectionLayout({
label,
}: IntegrationSectionLayoutProps) {
return (
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="min-h-0 flex-1 overflow-hidden max-md:min-h-48">
<ScrollAreaViewport
aria-label={label}
className="overscroll-contain"
+6 -2
View File
@@ -44,7 +44,8 @@ const MainNavLayout = ({ children, detailSidebar, initialPlatform }: MainNavLayo
const { t } = useTranslation(['common'])
const pathname = usePathname()
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const useResponsiveNavigation = pathname === '/datasets/create'
const useResponsiveNavigation =
pathname === '/datasets/create' || pathname.startsWith('/integrations/')
const hideMainNavigation = shouldHideMainNavigation(pathname)
const useDetailSidebar = shouldUseDetailSidebar(pathname, {
agentV2Enabled: isAgentV2Enabled(),
@@ -70,7 +71,10 @@ const MainNavLayout = ({ children, detailSidebar, initialPlatform }: MainNavLayo
<main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex min-h-0 min-w-0 grow flex-col overflow-hidden outline-hidden focus:outline-hidden focus-visible:outline-hidden"
className={cn(
'flex min-h-0 min-w-0 grow flex-col overflow-hidden outline-hidden focus:outline-hidden focus-visible:outline-hidden',
pathname.startsWith('/integrations/') && 'max-md:overflow-y-auto',
)}
>
{children}
</main>
@@ -44,10 +44,6 @@ vi.mock('../card/base/download-count', () => ({
),
}))
vi.mock('../card/base/title', () => ({
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
}))
const deploymentState = vi.hoisted(() => ({
deploymentEdition: 'CLOUD' as 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE',
}))
@@ -97,21 +93,26 @@ describe('ProviderCard', () => {
it('renders provider information, tags, and detail link', () => {
renderProviderCard()
expect(screen.getByTestId('title')).toHaveTextContent('Provider One')
expect(screen.getByRole('article', { name: 'Provider One' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: 'Provider One' })).toBeInTheDocument()
expect(screen.getByText('dify')).toBeInTheDocument()
expect(screen.getByTestId('download-count')).toHaveTextContent('123')
expect(screen.getByTestId('description')).toHaveTextContent('Provider description')
expect(screen.getByText('search')).toBeInTheDocument()
expect(screen.getByText('rag')).toBeInTheDocument()
expect(
screen.getByRole('link', { name: /plugin.detailPanel.operation.detail/i }),
screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail Provider One' }),
).toHaveAttribute('href', '/marketplace/dify/provider-one?language=en-US&theme=system')
})
it('opens and closes the install modal', () => {
renderProviderCard()
fireEvent.click(screen.getByRole('button', { name: /plugin.detailPanel.operation.install/i }))
fireEvent.click(
screen.getByRole('button', {
name: 'plugin.detailPanel.operation.install Provider One',
}),
)
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-install-modal'))
@@ -25,6 +25,7 @@ type OperationDropdownProps = Readonly<
detailUrl: string
popupClassName?: string
triggerSize?: 'm' | 'xs'
triggerAriaLabel?: string
destructiveRemove?: boolean
showCheckVersion?: boolean
showRemove?: boolean
@@ -43,6 +44,7 @@ export function OperationDropdown({
alignOffset = 0,
popupClassName,
triggerSize = 'm',
triggerAriaLabel,
destructiveRemove = false,
showCheckVersion = true,
showRemove = true,
@@ -73,7 +75,9 @@ export function OperationDropdown({
<DropdownMenuTrigger
render={
<IconButton
aria-label={t(($) => $['detailPanel.operation.moreActions'], { ns: 'plugin' })}
aria-label={
triggerAriaLabel ?? t(($) => $['detailPanel.operation.moreActions'], { ns: 'plugin' })
}
size={triggerSize === 'xs' ? 'xs' : 'md'}
className="data-popup-open:bg-state-base-hover"
>
+20 -6
View File
@@ -6,7 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { useBoolean } from 'ahooks'
import { useTheme } from 'next-themes'
import * as React from 'react'
import { useMemo } from 'react'
import { useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '#i18n'
import usePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
@@ -18,7 +18,6 @@ import Badge from '../base/badge'
import Icon from './card/base/card-icon'
import Description from './card/base/description'
import DownloadCount from './card/base/download-count'
import Title from './card/base/title'
type Props = Readonly<{
className?: string
@@ -35,6 +34,8 @@ const ProviderCardComponent: FC<Props> = ({ className, payload }) => {
] = useBoolean(false)
const { canInstallPlugin } = usePluginInstallPermission()
const { org, label } = payload
const pluginLabel = getValueFromI18nObject(label)
const titleId = useId()
const locale = useLocale()
const navigation = useMarketplaceDetailNavigation()
@@ -42,7 +43,8 @@ const ProviderCardComponent: FC<Props> = ({ className, payload }) => {
const marketplaceLinkParams = useMemo(() => ({ language: locale, theme }), [locale, theme])
return (
<div
<article
aria-labelledby={titleId}
className={cn(
'group relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg',
className,
@@ -53,7 +55,13 @@ const ProviderCardComponent: FC<Props> = ({ className, payload }) => {
<Icon src={payload.icon} />
<div className="ml-3 w-0 grow">
<div className="flex h-5 items-center">
<Title title={getValueFromI18nObject(label)} />
<h2
id={titleId}
className="truncate system-md-semibold text-text-secondary"
title={pluginLabel}
>
{pluginLabel}
</h2>
{/* <RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" /> */}
</div>
<div className="mb-1 flex h-4 items-center justify-between">
@@ -77,11 +85,17 @@ const ProviderCardComponent: FC<Props> = ({ className, payload }) => {
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center gap-2 rounded-xl bg-linear-to-tr from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent p-4 pt-4 opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
{canInstallPlugin && (
<Button className="grow" variant="primary" onClick={showInstallFromMarketplace}>
<Button
aria-label={`${t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })} ${pluginLabel}`}
className="grow"
variant="primary"
onClick={showInstallFromMarketplace}
>
{t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })}
</Button>
)}
<a
aria-label={`${t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })} ${pluginLabel}`}
href={
navigation.pluginHref(payload) ??
getPluginLinkInMarketplace(payload, marketplaceLinkParams)
@@ -102,7 +116,7 @@ const ProviderCardComponent: FC<Props> = ({ className, payload }) => {
onSuccess={hideInstallFromMarketplace}
/>
)}
</div>
</article>
)
}
@@ -17,6 +17,7 @@ type Props = Readonly<{
pluginID: string
currentVersion: string
trigger: (open: boolean) => React.ReactNode
triggerAccessibleName?: string
sideOffset?: number
alignOffset?: number
onSelect: ({
@@ -37,6 +38,7 @@ const PluginVersionPicker: FC<Props> = ({
pluginID,
currentVersion,
trigger,
triggerAccessibleName,
sideOffset = 4,
alignOffset = 0,
onSelect,
@@ -77,6 +79,7 @@ const PluginVersionPicker: FC<Props> = ({
<button
{...props}
type="button"
aria-label={triggerAccessibleName}
className={cn(
'inline-flex cursor-pointer items-center data-disabled:cursor-default',
props.className,