fix(web): improve app deployment v2 accessibility (#40550)

This commit is contained in:
Wu Tianwei 2026-08-12 11:43:35 +08:00 committed by GitHub
parent 9cc1f68364
commit fbaf23dc8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 324 additions and 205 deletions

View File

@ -5012,22 +5012,6 @@
"count": 4 "count": 4
} }
}, },
"web/app/components/workflow/panel/version-history-panel/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/workflow/panel/version-history-panel/version-history-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/panel/workflow-preview.tsx": { "web/app/components/workflow/panel/workflow-preview.tsx": {
"jsx_a11y/click-events-have-key-events": { "jsx_a11y/click-events-have-key-events": {
"count": 4 "count": 4

View File

@ -41,7 +41,11 @@ vi.mock('../../../base/app-icon', () => ({
background: string background: string
iconType?: string iconType?: string
imageUrl?: string imageUrl?: string
}) => <div data-testid="app-icon" data-size={size} data-icon={icon} data-bg={background} />, }) => (
<span data-size={size} data-icon={icon} data-bg={background}>
{icon}
</span>
),
})) }))
const defaultAppPermissionKeys = [ const defaultAppPermissionKeys = [
@ -91,12 +95,12 @@ describe('AppInfoTrigger', () => {
}) })
render(<AppInfoTrigger {...props} />) render(<AppInfoTrigger {...props} />)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', 'large') expect(screen.getByText('🤖')).toHaveAttribute('data-size', 'large')
expect(screen.getByText('My Chatbot')).toBeInTheDocument() expect(screen.getByText('My Chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument() expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('My Chatbot').closest('button')).toBeNull() expect(screen.getByText('My Chatbot').closest('button')).toBeNull()
await user.click(screen.getByTestId('app-icon')) await user.click(screen.getByText('🤖'))
expect(props.openModal).not.toHaveBeenCalled() expect(props.openModal).not.toHaveBeenCalled()
expect(props.exportCheck).not.toHaveBeenCalled() expect(props.exportCheck).not.toHaveBeenCalled()
@ -105,7 +109,7 @@ describe('AppInfoTrigger', () => {
it('renders only the medium app icon when collapsed', () => { it('renders only the medium app icon when collapsed', () => {
render(<AppInfoTrigger {...createProps({ expand: false })} />) render(<AppInfoTrigger {...createProps({ expand: false })} />)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', 'medium') expect(screen.getByText('🤖')).toHaveAttribute('data-size', 'medium')
expect(screen.queryByText('Test App')).not.toBeInTheDocument() expect(screen.queryByText('Test App')).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument() expect(screen.queryByRole('button')).not.toBeInTheDocument()
}) })

View File

@ -17,6 +17,23 @@ describe('AccessPointUrl', () => {
expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled() expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled()
}) })
it('exposes an available endpoint as an external link', () => {
render(
<AccessPointUrl
{...endpointProps}
enabled
showOpen
openLabel="Open"
openUrl={endpointProps.value}
/>,
)
const openLink = screen.getByRole('link', { name: 'Open' })
expect(openLink).toHaveAttribute('href', endpointProps.value)
expect(openLink).toHaveAttribute('target', '_blank')
expect(openLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('shows an unavailable endpoint without replacing it with a loading skeleton', () => { it('shows an unavailable endpoint without replacing it with a loading skeleton', () => {
render(<AccessPointUrl {...endpointProps} enabled={false} unavailable />) render(<AccessPointUrl {...endpointProps} enabled={false} unavailable />)

View File

@ -80,28 +80,28 @@ vi.mock('../shared/use-access-point-actions', () => ({
vi.mock('../built-in-access-points/web-app-card', () => ({ vi.mock('../built-in-access-points/web-app-card', () => ({
WebAppAccessPointCard: (props: Record<string, unknown>) => { WebAppAccessPointCard: (props: Record<string, unknown>) => {
mocks.webCard(props) mocks.webCard(props)
return <div data-testid="web-app-card" /> return null
}, },
})) }))
vi.mock('../built-in-access-points/service-api-card', () => ({ vi.mock('../built-in-access-points/service-api-card', () => ({
ServiceApiAccessPointCard: (props: Record<string, unknown>) => { ServiceApiAccessPointCard: (props: Record<string, unknown>) => {
mocks.apiCard(props) mocks.apiCard(props)
return <div data-testid="service-api-card" /> return null
}, },
})) }))
vi.mock('../built-in-access-points/mcp-card', () => ({ vi.mock('../built-in-access-points/mcp-card', () => ({
MCPAccessPointCard: (props: Record<string, unknown>) => { MCPAccessPointCard: (props: Record<string, unknown>) => {
mocks.mcpCard(props) mocks.mcpCard(props)
return <div data-testid="mcp-card" /> return null
}, },
})) }))
vi.mock('../built-in-access-points/trigger-card', () => ({ vi.mock('../built-in-access-points/trigger-card', () => ({
TriggerAccessPointCard: (props: Record<string, unknown>) => { TriggerAccessPointCard: (props: Record<string, unknown>) => {
mocks.triggerCard(props) mocks.triggerCard(props)
return <div data-testid="trigger-card" /> return null
}, },
})) }))
@ -125,16 +125,13 @@ describe('BuiltInAccessPoints', () => {
render(<BuiltInAccessPoints appId="app-1" />) render(<BuiltInAccessPoints appId="app-1" />)
expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument() expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument()
expect(screen.getByTestId('web-app-card')).toBeInTheDocument()
expect(screen.getByTestId('service-api-card')).toBeInTheDocument()
expect(screen.getByTestId('mcp-card')).toBeInTheDocument()
expect(screen.getByTestId('trigger-card')).toBeInTheDocument()
expect(mocks.webCard).toHaveBeenCalledWith( expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }), expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }),
) )
expect(mocks.apiCard).toHaveBeenCalledWith( expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }), expect.objectContaining({ availability: 'unavailable', canEdit: false }),
) )
expect(mocks.mcpCard).toHaveBeenCalledTimes(1)
expect(mocks.triggerCard).toHaveBeenCalledWith( expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }), expect.objectContaining({ availability: 'unavailable', canEdit: false }),
) )

View File

@ -16,14 +16,14 @@ vi.mock('react-i18next', async () => {
vi.mock('../deployed-environment-access-points/environment-service-api-card', () => ({ vi.mock('../deployed-environment-access-points/environment-service-api-card', () => ({
EnvironmentServiceApiCard: (props: Record<string, unknown>) => { EnvironmentServiceApiCard: (props: Record<string, unknown>) => {
mocks.serviceApiCard(props) mocks.serviceApiCard(props)
return <div data-testid="environment-service-api-card" /> return null
}, },
})) }))
vi.mock('../deployed-environment-access-points/environment-web-app-card', () => ({ vi.mock('../deployed-environment-access-points/environment-web-app-card', () => ({
EnvironmentWebAppCard: (props: Record<string, unknown>) => { EnvironmentWebAppCard: (props: Record<string, unknown>) => {
mocks.webAppCard(props) mocks.webAppCard(props)
return <div data-testid="environment-web-app-card" /> return null
}, },
})) }))

View File

@ -2,7 +2,7 @@ import type { AppEnvironment } from '@dify/contracts/enterprise-app-deploy/types
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/access-point' import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/access-point'
import { EnvironmentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen' import { EnvironmentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { screen } from '@testing-library/react' import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { consoleQuery } from '@/service/client' import { consoleQuery } from '@/service/client'
@ -15,6 +15,10 @@ import AccessPoint from '..'
let appMode = 'workflow' let appMode = 'workflow'
let appPermissionKeys: string[] = [AppACLPermission.Deploy] let appPermissionKeys: string[] = [AppACLPermission.Deploy]
const accessPointMocks = vi.hoisted(() => ({
builtIn: vi.fn(),
deployed: vi.fn(),
}))
const mockConsoleState = vi.hoisted(() => ({ const mockConsoleState = vi.hoisted(() => ({
userProfile: { id: 'user-1' }, userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
@ -45,46 +49,23 @@ vi.mock('@/context/permission-state', async () => {
}) })
vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({ vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({
BuiltInAccessPoints: ({ BuiltInAccessPoints: (props: { appId: string; highlightedAccessPoint?: AccessPointType }) => {
appId, accessPointMocks.builtIn(props)
highlightedAccessPoint, return null
}: { },
appId: string
highlightedAccessPoint?: AccessPointType
}) => (
<div
data-testid="built-in-access-points"
data-highlighted-access-point={highlightedAccessPoint}
>
{appId}
</div>
),
})) }))
vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({ vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({
DeployedEnvironmentAccessPoints: ({ DeployedEnvironmentAccessPoints: (props: {
appId,
canEdit,
canManage,
environmentId,
highlightedAccessPoint,
}: {
appId: string appId: string
canEdit: boolean canEdit: boolean
canManage: boolean canManage: boolean
environmentId: string environmentId: string
highlightedAccessPoint?: AccessPointType highlightedAccessPoint?: AccessPointType
}) => ( }) => {
<div accessPointMocks.deployed(props)
data-testid="deployed-environment-access-points" return null
data-app-id={appId} },
data-can-edit={String(canEdit)}
data-can-manage={String(canManage)}
data-highlighted-access-point={highlightedAccessPoint}
>
{environmentId}
</div>
),
})) }))
const appEnvironments: AppEnvironment[] = [ const appEnvironments: AppEnvironment[] = [
@ -147,6 +128,7 @@ const renderAccessPoint = ({
describe('AccessPoint', () => { describe('AccessPoint', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks()
appMode = 'workflow' appMode = 'workflow'
appPermissionKeys = [AppACLPermission.Deploy] appPermissionKeys = [AppACLPermission.Deploy]
}) })
@ -155,7 +137,9 @@ describe('AccessPoint', () => {
renderAccessPoint() renderAccessPoint()
expect(screen.getByRole('heading', { name: 'common.appMenus.accessPoint' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'common.appMenus.accessPoint' })).toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toHaveTextContent('app-1') expect(accessPointMocks.builtIn).toHaveBeenCalledWith(
expect.objectContaining({ appId: 'app-1' }),
)
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
'Built-in', 'Built-in',
'Staging', 'Staging',
@ -184,10 +168,11 @@ describe('AccessPoint', () => {
}) })
expect(screen.getByRole('tab', { name: 'Canary' })).toHaveAttribute('aria-selected', 'true') expect(screen.getByRole('tab', { name: 'Canary' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary') expect(accessPointMocks.deployed).toHaveBeenCalledWith(
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute( expect.objectContaining({
'data-highlighted-access-point', environmentId: 'canary',
'serviceApi', highlightedAccessPoint: 'serviceApi',
}),
) )
}) })
@ -197,9 +182,8 @@ describe('AccessPoint', () => {
}) })
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true') expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('built-in-access-points')).toHaveAttribute( expect(accessPointMocks.builtIn).toHaveBeenCalledWith(
'data-highlighted-access-point', expect.objectContaining({ highlightedAccessPoint: 'mcp' }),
'mcp',
) )
}) })
@ -216,9 +200,14 @@ describe('AccessPoint', () => {
queryString: '?environment=staging', queryString: '?environment=staging',
}), }),
) )
expect(screen.getByTestId('deployed-environment-access-points')).not.toHaveAttribute( await waitFor(() => {
'data-highlighted-access-point', expect(accessPointMocks.deployed).toHaveBeenLastCalledWith(
) expect.objectContaining({
environmentId: 'staging',
highlightedAccessPoint: null,
}),
)
})
}) })
it('shows the selected deployed environment with deploy permissions', () => { it('shows the selected deployed environment with deploy permissions', () => {
@ -226,20 +215,15 @@ describe('AccessPoint', () => {
searchParams: '?environment=canary', searchParams: '?environment=canary',
}) })
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary') expect(accessPointMocks.deployed).toHaveBeenCalledWith(
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute( expect.objectContaining({
'data-app-id', appId: 'app-1',
'app-1', canEdit: false,
canManage: true,
environmentId: 'canary',
}),
) )
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute( expect(accessPointMocks.builtIn).not.toHaveBeenCalled()
'data-can-edit',
'false',
)
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-can-manage',
'true',
)
expect(screen.queryByTestId('built-in-access-points')).not.toBeInTheDocument()
}) })
it('falls back to Built-in when the URL targets an unused environment', () => { it('falls back to Built-in when the URL targets an unused environment', () => {
@ -249,11 +233,10 @@ describe('AccessPoint', () => {
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true') expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
expect(screen.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument() expect(screen.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument() expect(accessPointMocks.builtIn).toHaveBeenCalledWith(
expect(screen.getByTestId('built-in-access-points')).not.toHaveAttribute( expect.objectContaining({ highlightedAccessPoint: null }),
'data-highlighted-access-point',
) )
expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument() expect(accessPointMocks.deployed).not.toHaveBeenCalled()
}) })
it('hides environment tabs for app types without multi-environment support', () => { it('hides environment tabs for app types without multi-environment support', () => {
@ -262,7 +245,8 @@ describe('AccessPoint', () => {
renderAccessPoint() renderAccessPoint()
expect(screen.queryByRole('tab')).not.toBeInTheDocument() expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument() expect(accessPointMocks.builtIn).toHaveBeenCalledTimes(1)
expect(accessPointMocks.deployed).not.toHaveBeenCalled()
}) })
it('falls back to built-in access points without app deploy ACL permission', () => { it('falls back to built-in access points without app deploy ACL permission', () => {
@ -273,7 +257,7 @@ describe('AccessPoint', () => {
}) })
expect(screen.queryByRole('tab')).not.toBeInTheDocument() expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument() expect(accessPointMocks.builtIn).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument() expect(accessPointMocks.deployed).not.toHaveBeenCalled()
}) })
}) })

View File

@ -190,12 +190,12 @@ export function WebAppAccessPointCard({
showQrCode showQrCode
showRegenerate showRegenerate
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })} openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
openUrl={webAppUrl}
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], { regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview', ns: 'appOverview',
})} })}
regenerateDisabled={!canEdit} regenerateDisabled={!canEdit}
regenerating={regenerating} regenerating={regenerating}
onOpen={() => window.open(webAppUrl, '_blank')}
onRegenerate={() => setShowRegenerate(true)} onRegenerate={() => setShowRegenerate(true)}
/> />
{showAccessControl && ( {showAccessControl && (

View File

@ -213,12 +213,12 @@ export function EnvironmentWebAppCard({
showQrCode showQrCode
showRegenerate showRegenerate
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })} openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
openUrl={webAppUrl}
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], { regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview', ns: 'appOverview',
})} })}
regenerateDisabled={!canManage} regenerateDisabled={!canManage}
regenerating={resetAccessTokenMutation.isPending} regenerating={resetAccessTokenMutation.isPending}
onOpen={() => window.open(webAppUrl, '_blank')}
onRegenerate={() => setShowRegenerate(true)} onRegenerate={() => setShowRegenerate(true)}
/> />
{systemFeatures.webapp_auth.enabled && ( {systemFeatures.webapp_auth.enabled && (

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { Button } from '@langgenius/dify-ui/button' import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import CopyFeedback from '@/app/components/base/copy-feedback' import CopyFeedback from '@/app/components/base/copy-feedback'
import ShareQRCode from '@/app/components/base/qrcode' import ShareQRCode from '@/app/components/base/qrcode'
import ActionButton from '../../../base/action-button' import ActionButton from '../../../base/action-button'
@ -17,7 +17,7 @@ type AccessPointUrlProps = {
showOpen?: boolean showOpen?: boolean
showQrCode?: boolean showQrCode?: boolean
showRegenerate?: boolean showRegenerate?: boolean
onOpen?: () => void openUrl?: string
onRegenerate?: () => void onRegenerate?: () => void
openLabel?: string openLabel?: string
regenerateLabel?: string regenerateLabel?: string
@ -30,9 +30,9 @@ export function AccessPointUrl({
label, label,
loading = false, loading = false,
copyDisabled = false, copyDisabled = false,
onOpen,
onRegenerate, onRegenerate,
openLabel, openLabel,
openUrl,
regenerateDisabled = false, regenerateDisabled = false,
regenerateLabel, regenerateLabel,
regenerating = false, regenerating = false,
@ -105,16 +105,26 @@ export function AccessPointUrl({
{showOpen && ( {showOpen && (
<> <>
<span className="mx-1 h-3.5 w-px bg-divider-regular" /> <span className="mx-1 h-3.5 w-px bg-divider-regular" />
<Button {enabled && openUrl ? (
variant="secondary" <a
size="small" href={openUrl}
className="h-6 gap-1 px-1.5" target="_blank"
disabled={!enabled} rel="noopener noreferrer"
onClick={onOpen} className={buttonVariants({
> variant: 'secondary',
<span aria-hidden className="i-ri-external-link-line size-3.5" /> size: 'small',
{openLabel} className: 'h-6 gap-1 px-1.5',
</Button> })}
>
<span aria-hidden className="i-ri-external-link-line size-3.5" />
{openLabel}
</a>
) : (
<Button variant="secondary" size="small" className="h-6 gap-1 px-1.5" disabled>
<span aria-hidden className="i-ri-external-link-line size-3.5" />
{openLabel}
</Button>
)}
</> </>
)} )}
</div> </div>

View File

@ -4,8 +4,13 @@ import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import SpecificGroupsOrMembers from '../specific-groups-or-members' import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockAddMemberOrGroupDialog = vi.hoisted(() => vi.fn())
vi.mock('../add-member-or-group-pop', () => ({ vi.mock('../add-member-or-group-pop', () => ({
default: () => <div data-testid="add-member-or-group-dialog" />, default: (props: Record<string, unknown>) => {
mockAddMemberOrGroupDialog(props)
return null
},
})) }))
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup =>
@ -34,6 +39,10 @@ describe('SpecificGroupsOrMembers', () => {
members: [baseMember], members: [baseMember],
} }
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the collapsed row when not in specific mode', () => { it('should render the collapsed row when not in specific mode', () => {
render( render(
<SpecificGroupsOrMembers <SpecificGroupsOrMembers
@ -45,7 +54,7 @@ describe('SpecificGroupsOrMembers', () => {
) )
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() expect(mockAddMemberOrGroupDialog).not.toHaveBeenCalled()
}) })
it('should show loading while whitelist subjects are pending', () => { it('should show loading while whitelist subjects are pending', () => {
@ -59,7 +68,7 @@ describe('SpecificGroupsOrMembers', () => {
) )
expect(container.querySelector('.spin-animation')).toBeInTheDocument() expect(container.querySelector('.spin-animation')).toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() expect(mockAddMemberOrGroupDialog).not.toHaveBeenCalled()
}) })
it('should expose the failed load and allow retry without rendering an empty selection', async () => { it('should expose the failed load and allow retry without rendering an empty selection', async () => {
@ -78,7 +87,7 @@ describe('SpecificGroupsOrMembers', () => {
expect(screen.getByRole('alert')).toHaveTextContent('common.dynamicSelect.error') expect(screen.getByRole('alert')).toHaveTextContent('common.dynamicSelect.error')
expect(screen.queryByText('app.accessControlDialog.noGroupsOrMembers')).not.toBeInTheDocument() expect(screen.queryByText('app.accessControlDialog.noGroupsOrMembers')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() expect(mockAddMemberOrGroupDialog).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
expect(onRetrySubjects).toHaveBeenCalledTimes(1) expect(onRetrySubjects).toHaveBeenCalledTimes(1)

View File

@ -36,10 +36,12 @@ vi.mock('@/app/components/app/app-publisher', () => ({
mockAppPublisherProps.current = props mockAppPublisherProps.current = props
return ( return (
<div> <div>
<button onClick={() => props.onPublish?.({ id: 'model-1' })}> <button type="button" onClick={() => props.onPublish?.({ id: 'model-1' })}>
publish-through-wrapper publish-through-wrapper
</button> </button>
<button onClick={() => props.onRestore?.()}>restore-through-wrapper</button> <button type="button" onClick={() => props.onRestore?.()}>
restore-through-wrapper
</button>
</div> </div>
) )
}, },

View File

@ -168,9 +168,11 @@ vi.mock('@/app/components/base/amplitude', () => ({
vi.mock('@/app/components/tools/workflow-tool', () => ({ vi.mock('@/app/components/tools/workflow-tool', () => ({
WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => ( WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => (
<div data-testid="workflow-tool-drawer"> <div role="dialog" aria-label="Workflow tool drawer">
workflow tool drawer workflow tool drawer
<button onClick={onHide}>close-workflow-tool-drawer</button> <button type="button" onClick={onHide}>
close-workflow-tool-drawer
</button>
</div> </div>
), ),
})) }))
@ -182,15 +184,18 @@ vi.mock('../built-in-publisher/summary-section', () => ({
<div> <div>
{props.environmentTabs} {props.environmentTabs}
<button <button
type="button"
disabled={props.publishDisabled || props.published} disabled={props.publishDisabled || props.published}
onClick={() => void props.handlePublish()} onClick={() => void props.handlePublish()}
> >
publisher-summary-publish publisher-summary-publish
</button> </button>
<button disabled={props.published} onClick={() => void props.handleRestore()}> <button type="button" disabled={props.published} onClick={() => void props.handleRestore()}>
publisher-summary-restore publisher-summary-restore
</button> </button>
<button onClick={props.onEditVersion}>publisher-summary-edit-version</button> <button type="button" onClick={props.onEditVersion}>
publisher-summary-edit-version
</button>
</div> </div>
) )
}, },
@ -200,20 +205,26 @@ vi.mock('../built-in-publisher/actions-section', () => ({
PublisherActionsSection: (props: Record<string, any>) => { PublisherActionsSection: (props: Record<string, any>) => {
sectionProps.actions = props sectionProps.actions = props
return ( return (
<div data-testid="publisher-actions"> <div>
{props.showRunConfig && props.handleOpenRunConfig && ( {props.showRunConfig && props.handleOpenRunConfig && (
<button onClick={() => props.handleOpenRunConfig(props.appURL)}> <button type="button" onClick={() => props.handleOpenRunConfig(props.appURL)}>
publisher-run-config publisher-run-config
</button> </button>
)} )}
{props.showMarketplaceAction && ( {props.showMarketplaceAction && (
<button disabled={props.marketplaceActionDisabled} onClick={props.onPublishToMarketplace}> <button
type="button"
disabled={props.marketplaceActionDisabled}
onClick={props.onPublishToMarketplace}
>
{props.publishingToMarketplace {props.publishingToMarketplace
? 'workflow.common.publishingToMarketplace' ? 'workflow.common.publishingToMarketplace'
: 'workflow.common.publishToMarketplace'} : 'workflow.common.publishToMarketplace'}
</button> </button>
)} )}
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button> <button type="button" onClick={props.onConfigureWorkflowTool}>
publisher-workflow-tool
</button>
</div> </div>
) )
}, },
@ -406,7 +417,7 @@ describe('AppPublisher', () => {
) )
await user.click(screen.getByText('publisher-summary-edit-version')) await user.click(screen.getByText('publisher-summary-edit-version'))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() expect(screen.queryByText('publisher-summary-edit-version')).not.toBeInTheDocument()
const [titleInput, notesInput] = screen.getAllByRole('textbox') const [titleInput, notesInput] = screen.getAllByRole('textbox')
await user.clear(titleInput!) await user.clear(titleInput!)
await user.type(titleInput!, 'Release 6') await user.type(titleInput!, 'Release 6')
@ -803,8 +814,8 @@ describe('AppPublisher', () => {
) )
fireEvent.click(screen.getByText('publisher-workflow-tool')) fireEvent.click(screen.getByText('publisher-workflow-tool'))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() expect(screen.queryByText('publisher-workflow-tool')).not.toBeInTheDocument()
expect(screen.getByTestId('workflow-tool-drawer')).toBeInTheDocument() expect(screen.getByRole('dialog', { name: 'Workflow tool drawer' })).toBeInTheDocument()
}) })
it('should not open workflow tool drawer without tool.manage', () => { it('should not open workflow tool drawer without tool.manage', () => {
@ -819,7 +830,7 @@ describe('AppPublisher', () => {
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-workflow-tool')) fireEvent.click(screen.getByText('publisher-workflow-tool'))
expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument() expect(screen.queryByRole('dialog', { name: 'Workflow tool drawer' })).not.toBeInTheDocument()
expect(sectionProps.actions?.workflowToolAvailable).toBe(false) expect(sectionProps.actions?.workflowToolAvailable).toBe(false)
}) })

View File

@ -12,9 +12,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
})) }))
vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => ( default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
<span data-testid="model-icon">{modelName}</span>
),
})) }))
describe('PublishWithMultipleModel', () => { describe('PublishWithMultipleModel', () => {

View File

@ -80,11 +80,7 @@ describe('SuggestedAction', () => {
const handleClick = vi.fn() const handleClick = vi.fn()
render( render(
<SuggestedAction <SuggestedAction description="Use as a tool in other apps" onClick={handleClick}>
description="Use as a tool in other apps"
endIcon={<span data-testid="configure-icon" />}
onClick={handleClick}
>
Workflow as Tool Workflow as Tool
</SuggestedAction>, </SuggestedAction>,
) )
@ -92,7 +88,6 @@ describe('SuggestedAction', () => {
fireEvent.click(screen.getByRole('button', { name: 'Workflow as Tool' })) fireEvent.click(screen.getByRole('button', { name: 'Workflow as Tool' }))
expect(handleClick).toHaveBeenCalledTimes(1) expect(handleClick).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('configure-icon')).toBeInTheDocument()
}) })
it('should keep the main link separate from a trailing action button', () => { it('should keep the main link separate from a trailing action button', () => {

View File

@ -6,6 +6,8 @@ import { useInfiniteScroll } from '../use-infinite-scroll'
let intersectionCallback: IntersectionObserverCallback | undefined let intersectionCallback: IntersectionObserverCallback | undefined
let intersectionOptions: IntersectionObserverInit | undefined let intersectionOptions: IntersectionObserverInit | undefined
let scrollRoot: HTMLDivElement | null = null
let scrollSentinel: HTMLDivElement | null = null
const observe = vi.fn() const observe = vi.fn()
const disconnect = vi.fn() const disconnect = vi.fn()
const originalIntersectionObserver = globalThis.IntersectionObserver const originalIntersectionObserver = globalThis.IntersectionObserver
@ -37,8 +39,18 @@ function TestInfiniteScroll({ query }: { query: InfiniteScrollQuery }) {
return createElement( return createElement(
'div', 'div',
{ ref: rootRef, 'data-testid': 'scroll-root' }, {
createElement('div', { ref: sentinelRef, 'data-testid': 'scroll-sentinel' }), ref: (node: HTMLDivElement | null) => {
scrollRoot = node
rootRef(node)
},
},
createElement('div', {
ref: (node: HTMLDivElement | null) => {
scrollSentinel = node
sentinelRef(node)
},
}),
) )
} }
@ -69,6 +81,8 @@ describe('deploy useInfiniteScroll', () => {
vi.clearAllMocks() vi.clearAllMocks()
intersectionCallback = undefined intersectionCallback = undefined
intersectionOptions = undefined intersectionOptions = undefined
scrollRoot = null
scrollSentinel = null
globalThis.IntersectionObserver = globalThis.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver MockIntersectionObserver as unknown as typeof IntersectionObserver
}) })
@ -78,13 +92,13 @@ describe('deploy useInfiniteScroll', () => {
}) })
it('should observe the sentinel within the version list', () => { it('should observe the sentinel within the version list', () => {
const view = render(createElement(TestInfiniteScroll, { query: createQuery() })) render(createElement(TestInfiniteScroll, { query: createQuery() }))
const root = view.getByTestId('scroll-root')
const sentinel = view.getByTestId('scroll-sentinel')
expect(observe).toHaveBeenCalledWith(sentinel) expect(scrollRoot).not.toBeNull()
expect(scrollSentinel).not.toBeNull()
expect(observe).toHaveBeenCalledWith(scrollSentinel)
expect(intersectionOptions).toMatchObject({ expect(intersectionOptions).toMatchObject({
root, root: scrollRoot,
rootMargin: '0px 0px 300px 0px', rootMargin: '0px 0px 300px 0px',
threshold: 0, threshold: 0,
}) })

View File

@ -424,9 +424,9 @@ vi.mock('../app-card', () => ({
})) }))
vi.mock('../app-card/action-bar', () => ({ vi.mock('../app-card/action-bar', () => ({
AppCardActionBar: ({ app }: { app: { id: string } }) => { AppCardActionBar: ({ app }: { app: { id: string; name: string } }) => {
return React.createElement('button', { return React.createElement('button', {
'data-testid': `app-card-action-bar-${app.id}`, 'aria-label': `Actions for ${app.name}`,
type: 'button', type: 'button',
}) })
}, },
@ -693,7 +693,7 @@ describe('List', () => {
const starredCard = screen.getByRole('link', { name: /Starred App/ }) const starredCard = screen.getByRole('link', { name: /Starred App/ })
const allAppsLabel = screen.getByText('All Apps') const allAppsLabel = screen.getByText('All Apps')
const firstAppCard = screen.getByTestId('app-card-app-1') const firstAppCard = screen.getByTestId('app-card-app-1')
const actionBar = screen.getByTestId('app-card-action-bar-starred-app-1') const actionBar = screen.getByRole('button', { name: 'Actions for Starred App' })
expect(starredCard).toBeInTheDocument() expect(starredCard).toBeInTheDocument()
expect(actionBar).toBeInTheDocument() expect(actionBar).toBeInTheDocument()
@ -740,7 +740,9 @@ describe('List', () => {
const firstWorkspaceCard = screen.getByTestId('app-card-app-1') const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1') const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
const starredCard = screen.getByRole('link', { name: /Starred App/ }) const starredCard = screen.getByRole('link', { name: /Starred App/ })
const starredActionBar = screen.getByTestId('app-card-action-bar-starred-app-1') const starredActionBar = screen.getByRole('button', {
name: 'Actions for Starred App',
})
expect(firstWorkspaceCard).toHaveAttribute( expect(firstWorkspaceCard).toHaveAttribute(
'data-step-by-step-tour-target', 'data-step-by-step-tour-target',

View File

@ -17,9 +17,7 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
}) => }) =>
isShow ? ( isShow ? (
<div data-testid="secret-key-modal"> <div data-testid="secret-key-modal">
<span data-testid="modal-app-id"> <span>{`Modal for ${scope.type === 'dataset' ? 'no-app' : scope.appId}`}</span>
{`Modal for ${scope.type === 'dataset' ? 'no-app' : scope.appId}`}
</span>
<span data-testid="modal-can-manage">{String(canManage)}</span> <span data-testid="modal-can-manage">{String(canManage)}</span>
<button onClick={onClose} data-testid="close-modal"> <button onClick={onClose} data-testid="close-modal">
Close Close

View File

@ -214,7 +214,8 @@ vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
return ( return (
<div <div
data-testid="workflow-app-main" role="region"
aria-label="Workflow canvas"
data-nodes={JSON.stringify(nodes)} data-nodes={JSON.stringify(nodes)}
data-edges={JSON.stringify(edges)} data-edges={JSON.stringify(edges)}
data-viewport={JSON.stringify(viewport)} data-viewport={JSON.stringify(viewport)}
@ -274,7 +275,7 @@ describe('WorkflowApp', () => {
render(<WorkflowApp />) render(<WorkflowApp />)
expect(screen.getByTestId('loading')).toBeInTheDocument() expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(screen.queryByTestId('workflow-app-main')).not.toBeInTheDocument() expect(screen.queryByRole('region', { name: 'Workflow canvas' })).not.toBeInTheDocument()
}) })
it('should render the workflow app shell and sync trigger statuses when data is ready', () => { it('should render the workflow app shell and sync trigger statuses when data is ready', () => {
@ -297,7 +298,7 @@ describe('WorkflowApp', () => {
'data-edges', 'data-edges',
JSON.stringify([{ id: 'edge-1' }]), JSON.stringify([{ id: 'edge-1' }]),
) )
expect(screen.getByTestId('workflow-app-main')).toHaveAttribute( expect(screen.getByRole('region', { name: 'Workflow canvas' })).toHaveAttribute(
'data-viewport', 'data-viewport',
JSON.stringify({ x: 1, y: 2, zoom: 3 }), JSON.stringify({ x: 1, y: 2, zoom: 3 }),
) )
@ -311,7 +312,7 @@ describe('WorkflowApp', () => {
it('should not sync trigger statuses when trigger data is unavailable', () => { it('should not sync trigger statuses when trigger data is unavailable', () => {
render(<WorkflowApp />) render(<WorkflowApp />)
expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument() expect(screen.getByRole('region', { name: 'Workflow canvas' })).toBeInTheDocument()
expect(mockSetTriggerStatuses).not.toHaveBeenCalled() expect(mockSetTriggerStatuses).not.toHaveBeenCalled()
}) })
@ -370,7 +371,7 @@ describe('WorkflowApp', () => {
render(<WorkflowApp />) render(<WorkflowApp />)
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument() expect(screen.getByRole('region', { name: 'Workflow canvas' })).toBeInTheDocument()
}) })
expect(mockGetWorkflowRunAndTraceUrl).not.toHaveBeenCalled() expect(mockGetWorkflowRunAndTraceUrl).not.toHaveBeenCalled()

View File

@ -94,7 +94,9 @@ const WorkflowRemountHarness = () => {
return ( return (
<> <>
<button onClick={() => setUseReplacementHistory(true)}>Remount workflow</button> <button type="button" onClick={() => setUseReplacementHistory(true)}>
Remount workflow
</button>
<WorkflowWithDefaultContext <WorkflowWithDefaultContext
key={useReplacementHistory ? 'replacement' : 'initial'} key={useReplacementHistory ? 'replacement' : 'initial'}
nodes={replacementNodes} nodes={replacementNodes}

View File

@ -1,6 +1,7 @@
import type { Shape } from '../../../store' import type { Shape } from '../../../store'
import type { VersionHistory } from '@/types/workflow' import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { renderWithConsoleQuery as render } from '@/test/console/query-data'
@ -19,6 +20,7 @@ const mockWorkflowStoreSetState = vi.fn()
const mockEmitRestoreIntent = vi.fn() const mockEmitRestoreIntent = vi.fn()
const mockEmitRestoreComplete = vi.fn() const mockEmitRestoreComplete = vi.fn()
const mockEmitWorkflowUpdate = vi.fn() const mockEmitWorkflowUpdate = vi.fn()
const mockFetchNextPage = vi.fn()
const mockToast = vi.hoisted(() => ({ const mockToast = vi.hoisted(() => ({
error: vi.fn(), error: vi.fn(),
success: vi.fn(), success: vi.fn(),
@ -26,6 +28,8 @@ const mockToast = vi.hoisted(() => ({
let mockPlanType = Plan.professional let mockPlanType = Plan.professional
let mockEnableBilling = true let mockEnableBilling = true
let mockPublishedEnvironments: VersionHistory['environments'] let mockPublishedEnvironments: VersionHistory['environments']
let mockHasNextPage = false
let mockIsFetching = false
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({ const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-id', id: 'version-id',
@ -109,9 +113,9 @@ vi.mock('@/service/use-workflow', () => ({
}, },
], ],
}, },
fetchNextPage: vi.fn(), fetchNextPage: mockFetchNextPage,
hasNextPage: false, hasNextPage: mockHasNextPage,
isFetching: false, isFetching: mockIsFetching,
}), }),
})) }))
@ -184,7 +188,11 @@ vi.mock('../restore-confirm-modal', () => ({
if (!isOpen) return null if (!isOpen) return null
return <button onClick={() => onRestore(versionInfo)}>confirm restore</button> return (
<button type="button" onClick={() => onRestore(versionInfo)}>
confirm restore
</button>
)
} }
return <MockRestoreConfirmModal /> return <MockRestoreConfirmModal />
@ -200,6 +208,7 @@ vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
onPublish: (params: { id?: string; title: string; releaseNotes: string }) => Promise<void> onPublish: (params: { id?: string; title: string; releaseNotes: string }) => Promise<void>
}) => ( }) => (
<button <button
type="button"
onClick={() => onClick={() =>
onPublish({ onPublish({
id: versionInfo.id, id: versionInfo.id,
@ -228,15 +237,19 @@ vi.mock('../version-history-item', () => ({
return ( return (
<div> <div>
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button> <button type="button" onClick={() => onClick(item)}>
{item.marked_name || item.version}
</button>
{item.version !== WorkflowVersion.Draft && ( {item.version !== WorkflowVersion.Draft && (
<> <>
<button <button
type="button"
onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)} onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)}
> >
{`restore-${item.id}`} {`restore-${item.id}`}
</button> </button>
<button <button
type="button"
onClick={() => onClick={() =>
handleClickActionMenuItem(VersionHistoryContextMenuOptions.exportDSL) handleClickActionMenuItem(VersionHistoryContextMenuOptions.exportDSL)
} }
@ -244,11 +257,13 @@ vi.mock('../version-history-item', () => ({
{`export-${item.id}`} {`export-${item.id}`}
</button> </button>
<button <button
type="button"
onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.edit)} onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.edit)}
> >
{`edit-${item.id}`} {`edit-${item.id}`}
</button> </button>
<button <button
type="button"
onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.delete)} onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.delete)}
> >
{`delete-${item.id}`} {`delete-${item.id}`}
@ -272,6 +287,35 @@ describe('VersionHistoryPanel', () => {
mockPlanType = Plan.professional mockPlanType = Plan.professional
mockEnableBilling = true mockEnableBilling = true
mockPublishedEnvironments = undefined mockPublishedEnvironments = undefined
mockHasNextPage = false
mockIsFetching = false
})
it('should expose close and pagination actions as accessible buttons', async () => {
const user = userEvent.setup()
mockHasNextPage = true
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.common.loadMore' }))
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(mockHandleLoadBackupDraft).toHaveBeenCalled()
})
vi.clearAllMocks()
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isRestoring: false })
expect(mockSetShowWorkflowVersionHistoryPanel).toHaveBeenCalledWith(false)
}) })
describe('Version Click Behavior', () => { describe('Version Click Behavior', () => {

View File

@ -142,13 +142,9 @@ describe('VersionHistoryItem', () => {
/>, />,
) )
const title = screen.getByText('Release 1') fireEvent.mouseEnter(screen.getByRole('button', { name: 'Release 1' }))
const itemContainer = title.closest('.group')
if (!itemContainer) throw new Error('Expected version history item container')
fireEvent.mouseEnter(itemContainer) const triggerButton = await screen.findByRole('button', { name: 'common.operation.more' })
const triggerButton = await screen.findByRole('button')
await user.click(triggerButton) await user.click(triggerButton)
expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument() expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument()
@ -188,13 +184,9 @@ describe('VersionHistoryItem', () => {
/>, />,
) )
const title = screen.getByText('Release 1') fireEvent.mouseEnter(screen.getByRole('button', { name: 'Release 1' }))
const itemContainer = title.closest('.group')
if (!itemContainer) throw new Error('Expected version history item container')
fireEvent.mouseEnter(itemContainer) const triggerButton = await screen.findByRole('button', { name: 'common.operation.more' })
const triggerButton = await screen.findByRole('button')
await user.click(triggerButton) await user.click(triggerButton)
expect(screen.queryByText('app.export')).not.toBeInTheDocument() expect(screen.queryByText('app.export')).not.toBeInTheDocument()
@ -218,9 +210,34 @@ describe('VersionHistoryItem', () => {
/>, />,
) )
await user.click(screen.getByText('Release 1')) const versionButton = screen.getByRole('button', { name: 'Release 1' })
expect(versionButton).toHaveAttribute('aria-current', 'true')
await user.click(versionButton)
expect(onClick).not.toHaveBeenCalled() expect(onClick).not.toHaveBeenCalled()
}) })
it('should expose the version and action menu in keyboard order', async () => {
const user = userEvent.setup()
render(
<VersionHistoryItem
item={createVersionHistory()}
currentVersion={null}
latestVersionId="version-1"
onClick={vi.fn()}
handleClickActionMenuItem={vi.fn()}
canImportExportDSL
isLast
/>,
)
await user.tab()
expect(screen.getByRole('button', { name: 'Release 1' })).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'common.operation.more' })).toHaveFocus()
})
}) })
}) })

View File

@ -2,7 +2,6 @@
import type { VersionHistory } from '@/types/workflow' import type { VersionHistory } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowDownDoubleLine, RiCloseLine, RiLoader2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import copy from 'copy-to-clipboard' import copy from 'copy-to-clipboard'
import * as React from 'react' import * as React from 'react'
@ -372,12 +371,14 @@ export const VersionHistoryPanel = ({
handleSwitch={handleSwitch} handleSwitch={handleSwitch}
/> />
<Divider type="vertical" className="mx-1 h-3.5" /> <Divider type="vertical" className="mx-1 h-3.5" />
<div <button
className="flex size-6 cursor-pointer items-center justify-center p-0.5" type="button"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="flex size-6 cursor-pointer items-center justify-center rounded p-0.5 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={handleClose} onClick={handleClose}
> >
<RiCloseLine className="size-4 text-text-tertiary" /> <span aria-hidden className="i-ri-close-line size-4 text-text-tertiary" />
</div> </button>
</div> </div>
<div className="flex h-0 flex-1 flex-col"> <div className="flex h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto px-3 py-2"> <div className="flex-1 overflow-y-auto px-3 py-2">
@ -412,18 +413,30 @@ export const VersionHistoryPanel = ({
</div> </div>
{hasNextPage && ( {hasNextPage && (
<div className="p-2"> <div className="p-2">
<div className="flex cursor-pointer items-center gap-x-1" onClick={handleNextPage}> <button
<div className="item-center flex justify-center p-0.5"> type="button"
aria-busy={isFetching || undefined}
className="flex w-full cursor-pointer items-center gap-x-1 rounded outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-wait"
disabled={isFetching}
onClick={handleNextPage}
>
<span className="flex items-center justify-center p-0.5">
{isFetching ? ( {isFetching ? (
<RiLoader2Line className="size-3.5 animate-spin text-text-accent" /> <span
aria-hidden
className="i-ri-loader-2-line size-3.5 animate-spin text-text-accent motion-reduce:animate-none"
/>
) : ( ) : (
<RiArrowDownDoubleLine className="size-3.5 text-text-accent" /> <span
aria-hidden
className="i-ri-arrow-down-double-line size-3.5 text-text-accent"
/>
)} )}
</div> </span>
<div className="py-px system-xs-medium-uppercase text-text-accent"> <span className="py-px system-xs-medium-uppercase text-text-accent">
{t(($) => $['common.loadMore'], { ns: 'workflow' })} {t(($) => $['common.loadMore'], { ns: 'workflow' })}
</div> </span>
</div> </button>
</div> </div>
)} )}
</div> </div>

View File

@ -47,7 +47,6 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
hideActionMenu, hideActionMenu,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isHovering, setIsHovering] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const formatTime = (time: number) => dayjs.unix(time).format('YYYY-MM-DD HH:mm') const formatTime = (time: number) => dayjs.unix(time).format('YYYY-MM-DD HH:mm')
@ -56,10 +55,15 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
const isDraft = formattedVersion === WorkflowVersion.Draft const isDraft = formattedVersion === WorkflowVersion.Draft
const isLatest = formattedVersion === WorkflowVersion.Latest const isLatest = formattedVersion === WorkflowVersion.Latest
const deployedEnvironments = item.environments || [] const deployedEnvironments = item.environments || []
const titleId = React.useId()
const didSelectDraftRef = React.useRef(false)
useEffect(() => { useEffect(() => {
if (isDraft) onClick(item) if (!isDraft || didSelectDraftRef.current) return
}, [])
didSelectDraftRef.current = true
onClick(item)
}, [isDraft, item, onClick])
const handleClickItem = () => { const handleClickItem = () => {
if (isSelected) return if (isSelected) return
@ -70,16 +74,9 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
<div <div
className={cn( className={cn(
'group relative flex gap-x-1 rounded-lg p-2', 'group relative flex gap-x-1 rounded-lg p-2',
isSelected isSelected ? 'bg-state-accent-active' : 'hover:bg-state-base-hover',
? 'cursor-not-allowed bg-state-accent-active'
: 'cursor-pointer hover:bg-state-base-hover',
)} )}
onClick={handleClickItem} onMouseLeave={() => setOpen(false)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => {
setIsHovering(false)
setOpen(false)
}}
onContextMenu={(e) => { onContextMenu={(e) => {
if (hideActionMenu) return if (hideActionMenu) return
@ -87,20 +84,35 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
setOpen(true) setOpen(true)
}} }}
> >
<button
type="button"
aria-labelledby={titleId}
aria-current={isSelected ? 'true' : undefined}
className={cn(
'absolute inset-0 rounded-lg outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
isSelected ? 'cursor-default' : 'cursor-pointer',
)}
onClick={handleClickItem}
/>
{!isLast && ( {!isLast && (
<div className="absolute top-6 left-4 h-[calc(100%-0.75rem)] w-0.5 bg-divider-subtle" />
)}
<div className="flex h-5 w-4.5 shrink-0 items-center justify-center">
<div <div
aria-hidden
className="pointer-events-none absolute top-6 left-4 h-[calc(100%-0.75rem)] w-0.5 bg-divider-subtle"
/>
)}
<div className="pointer-events-none relative z-[1] flex h-5 w-4.5 shrink-0 items-center justify-center">
<div
aria-hidden
className={cn( className={cn(
'size-2 rounded-lg border-2', 'size-2 rounded-lg border-2',
isSelected ? 'border-text-accent' : 'border-text-quaternary', isSelected ? 'border-text-accent' : 'border-text-quaternary',
)} )}
/> />
</div> </div>
<div className="flex grow flex-col gap-y-0.5 overflow-hidden"> <div className="pointer-events-none relative z-[1] flex grow flex-col gap-y-0.5 overflow-hidden">
<div className="mr-6 flex h-5 items-center gap-x-1"> <div className="mr-6 flex h-5 items-center gap-x-1">
<div <div
id={titleId}
className={cn( className={cn(
'truncate py-px system-sm-semibold', 'truncate py-px system-sm-semibold',
isSelected ? 'text-text-accent' : 'text-text-secondary', isSelected ? 'text-text-accent' : 'text-text-secondary',
@ -144,8 +156,13 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
)} )}
</div> </div>
{/* Action Menu */} {/* Action Menu */}
{!hideActionMenu && !isDraft && isHovering && ( {!hideActionMenu && !isDraft && (
<div className="absolute top-1 right-1"> <div
className={cn(
'invisible absolute top-1 right-1 z-10 group-focus-within:visible group-hover:visible',
open && 'visible',
)}
>
<ActionMenu <ActionMenu
workflowId={item.id} workflowId={item.id}
isShowDelete={!isLatest} isShowDelete={!isLatest}