mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix(web): improve data source integrations accessibility (SNP-872) (#42911)
This commit is contained in:
24 files changed
+453
-164
No files matched your search
@@ -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()
|
||||
|
||||
+1
@@ -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()
|
||||
|
||||
+23
@@ -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', () => {
|
||||
|
||||
+21
-1
@@ -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()
|
||||
|
||||
+27
@@ -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>,
|
||||
|
||||
+25
@@ -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
|
||||
|
||||
+15
-1
@@ -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"
|
||||
|
||||
+13
-3
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user