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
}
},
"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": {
"jsx_a11y/click-events-have-key-events": {
"count": 4

View File

@ -41,7 +41,11 @@ vi.mock('../../../base/app-icon', () => ({
background: string
iconType?: 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 = [
@ -91,12 +95,12 @@ describe('AppInfoTrigger', () => {
})
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('app.types.advanced')).toBeInTheDocument()
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.exportCheck).not.toHaveBeenCalled()
@ -105,7 +109,7 @@ describe('AppInfoTrigger', () => {
it('renders only the medium app icon when collapsed', () => {
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.queryByRole('button')).not.toBeInTheDocument()
})

View File

@ -17,6 +17,23 @@ describe('AccessPointUrl', () => {
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', () => {
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', () => ({
WebAppAccessPointCard: (props: Record<string, unknown>) => {
mocks.webCard(props)
return <div data-testid="web-app-card" />
return null
},
}))
vi.mock('../built-in-access-points/service-api-card', () => ({
ServiceApiAccessPointCard: (props: Record<string, unknown>) => {
mocks.apiCard(props)
return <div data-testid="service-api-card" />
return null
},
}))
vi.mock('../built-in-access-points/mcp-card', () => ({
MCPAccessPointCard: (props: Record<string, unknown>) => {
mocks.mcpCard(props)
return <div data-testid="mcp-card" />
return null
},
}))
vi.mock('../built-in-access-points/trigger-card', () => ({
TriggerAccessPointCard: (props: Record<string, unknown>) => {
mocks.triggerCard(props)
return <div data-testid="trigger-card" />
return null
},
}))
@ -125,16 +125,13 @@ describe('BuiltInAccessPoints', () => {
render(<BuiltInAccessPoints appId="app-1" />)
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.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
)
expect(mocks.mcpCard).toHaveBeenCalledTimes(1)
expect(mocks.triggerCard).toHaveBeenCalledWith(
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', () => ({
EnvironmentServiceApiCard: (props: Record<string, unknown>) => {
mocks.serviceApiCard(props)
return <div data-testid="environment-service-api-card" />
return null
},
}))
vi.mock('../deployed-environment-access-points/environment-web-app-card', () => ({
EnvironmentWebAppCard: (props: Record<string, unknown>) => {
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 { AccessPoint as AccessPointType } from '@/app/components/app/deploy/access-point'
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 { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { consoleQuery } from '@/service/client'
@ -15,6 +15,10 @@ import AccessPoint from '..'
let appMode = 'workflow'
let appPermissionKeys: string[] = [AppACLPermission.Deploy]
const accessPointMocks = vi.hoisted(() => ({
builtIn: vi.fn(),
deployed: vi.fn(),
}))
const mockConsoleState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
@ -45,46 +49,23 @@ vi.mock('@/context/permission-state', async () => {
})
vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({
BuiltInAccessPoints: ({
appId,
highlightedAccessPoint,
}: {
appId: string
highlightedAccessPoint?: AccessPointType
}) => (
<div
data-testid="built-in-access-points"
data-highlighted-access-point={highlightedAccessPoint}
>
{appId}
</div>
),
BuiltInAccessPoints: (props: { appId: string; highlightedAccessPoint?: AccessPointType }) => {
accessPointMocks.builtIn(props)
return null
},
}))
vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({
DeployedEnvironmentAccessPoints: ({
appId,
canEdit,
canManage,
environmentId,
highlightedAccessPoint,
}: {
DeployedEnvironmentAccessPoints: (props: {
appId: string
canEdit: boolean
canManage: boolean
environmentId: string
highlightedAccessPoint?: AccessPointType
}) => (
<div
data-testid="deployed-environment-access-points"
data-app-id={appId}
data-can-edit={String(canEdit)}
data-can-manage={String(canManage)}
data-highlighted-access-point={highlightedAccessPoint}
>
{environmentId}
</div>
),
}) => {
accessPointMocks.deployed(props)
return null
},
}))
const appEnvironments: AppEnvironment[] = [
@ -147,6 +128,7 @@ const renderAccessPoint = ({
describe('AccessPoint', () => {
beforeEach(() => {
vi.clearAllMocks()
appMode = 'workflow'
appPermissionKeys = [AppACLPermission.Deploy]
})
@ -155,7 +137,9 @@ describe('AccessPoint', () => {
renderAccessPoint()
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([
'Built-in',
'Staging',
@ -184,10 +168,11 @@ describe('AccessPoint', () => {
})
expect(screen.getByRole('tab', { name: 'Canary' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-highlighted-access-point',
'serviceApi',
expect(accessPointMocks.deployed).toHaveBeenCalledWith(
expect.objectContaining({
environmentId: 'canary',
highlightedAccessPoint: 'serviceApi',
}),
)
})
@ -197,9 +182,8 @@ describe('AccessPoint', () => {
})
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('built-in-access-points')).toHaveAttribute(
'data-highlighted-access-point',
'mcp',
expect(accessPointMocks.builtIn).toHaveBeenCalledWith(
expect.objectContaining({ highlightedAccessPoint: 'mcp' }),
)
})
@ -216,9 +200,14 @@ describe('AccessPoint', () => {
queryString: '?environment=staging',
}),
)
expect(screen.getByTestId('deployed-environment-access-points')).not.toHaveAttribute(
'data-highlighted-access-point',
)
await waitFor(() => {
expect(accessPointMocks.deployed).toHaveBeenLastCalledWith(
expect.objectContaining({
environmentId: 'staging',
highlightedAccessPoint: null,
}),
)
})
})
it('shows the selected deployed environment with deploy permissions', () => {
@ -226,20 +215,15 @@ describe('AccessPoint', () => {
searchParams: '?environment=canary',
})
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-app-id',
'app-1',
expect(accessPointMocks.deployed).toHaveBeenCalledWith(
expect.objectContaining({
appId: 'app-1',
canEdit: false,
canManage: true,
environmentId: 'canary',
}),
)
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'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()
expect(accessPointMocks.builtIn).not.toHaveBeenCalled()
})
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.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).not.toHaveAttribute(
'data-highlighted-access-point',
expect(accessPointMocks.builtIn).toHaveBeenCalledWith(
expect.objectContaining({ highlightedAccessPoint: null }),
)
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', () => {
@ -262,7 +245,8 @@ describe('AccessPoint', () => {
renderAccessPoint()
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', () => {
@ -273,7 +257,7 @@ describe('AccessPoint', () => {
})
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument()
expect(accessPointMocks.builtIn).toHaveBeenCalledTimes(1)
expect(accessPointMocks.deployed).not.toHaveBeenCalled()
})
})

View File

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

View File

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

View File

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

View File

@ -4,8 +4,13 @@ import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockAddMemberOrGroupDialog = vi.hoisted(() => vi.fn())
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 =>
@ -34,6 +39,10 @@ describe('SpecificGroupsOrMembers', () => {
members: [baseMember],
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the collapsed row when not in specific mode', () => {
render(
<SpecificGroupsOrMembers
@ -45,7 +54,7 @@ describe('SpecificGroupsOrMembers', () => {
)
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', () => {
@ -59,7 +68,7 @@ describe('SpecificGroupsOrMembers', () => {
)
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 () => {
@ -78,7 +87,7 @@ describe('SpecificGroupsOrMembers', () => {
expect(screen.getByRole('alert')).toHaveTextContent('common.dynamicSelect.error')
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' }))
expect(onRetrySubjects).toHaveBeenCalledTimes(1)

View File

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

View File

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

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', () => ({
default: ({ modelName }: { modelName: string }) => (
<span data-testid="model-icon">{modelName}</span>
),
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
}))
describe('PublishWithMultipleModel', () => {

View File

@ -80,11 +80,7 @@ describe('SuggestedAction', () => {
const handleClick = vi.fn()
render(
<SuggestedAction
description="Use as a tool in other apps"
endIcon={<span data-testid="configure-icon" />}
onClick={handleClick}
>
<SuggestedAction description="Use as a tool in other apps" onClick={handleClick}>
Workflow as Tool
</SuggestedAction>,
)
@ -92,7 +88,6 @@ describe('SuggestedAction', () => {
fireEvent.click(screen.getByRole('button', { name: 'Workflow as Tool' }))
expect(handleClick).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('configure-icon')).toBeInTheDocument()
})
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 intersectionOptions: IntersectionObserverInit | undefined
let scrollRoot: HTMLDivElement | null = null
let scrollSentinel: HTMLDivElement | null = null
const observe = vi.fn()
const disconnect = vi.fn()
const originalIntersectionObserver = globalThis.IntersectionObserver
@ -37,8 +39,18 @@ function TestInfiniteScroll({ query }: { query: InfiniteScrollQuery }) {
return createElement(
'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()
intersectionCallback = undefined
intersectionOptions = undefined
scrollRoot = null
scrollSentinel = null
globalThis.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver
})
@ -78,13 +92,13 @@ describe('deploy useInfiniteScroll', () => {
})
it('should observe the sentinel within the version list', () => {
const view = render(createElement(TestInfiniteScroll, { query: createQuery() }))
const root = view.getByTestId('scroll-root')
const sentinel = view.getByTestId('scroll-sentinel')
render(createElement(TestInfiniteScroll, { query: createQuery() }))
expect(observe).toHaveBeenCalledWith(sentinel)
expect(scrollRoot).not.toBeNull()
expect(scrollSentinel).not.toBeNull()
expect(observe).toHaveBeenCalledWith(scrollSentinel)
expect(intersectionOptions).toMatchObject({
root,
root: scrollRoot,
rootMargin: '0px 0px 300px 0px',
threshold: 0,
})

View File

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

View File

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

View File

@ -214,7 +214,8 @@ vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
return (
<div
data-testid="workflow-app-main"
role="region"
aria-label="Workflow canvas"
data-nodes={JSON.stringify(nodes)}
data-edges={JSON.stringify(edges)}
data-viewport={JSON.stringify(viewport)}
@ -274,7 +275,7 @@ describe('WorkflowApp', () => {
render(<WorkflowApp />)
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', () => {
@ -297,7 +298,7 @@ describe('WorkflowApp', () => {
'data-edges',
JSON.stringify([{ id: 'edge-1' }]),
)
expect(screen.getByTestId('workflow-app-main')).toHaveAttribute(
expect(screen.getByRole('region', { name: 'Workflow canvas' })).toHaveAttribute(
'data-viewport',
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', () => {
render(<WorkflowApp />)
expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'Workflow canvas' })).toBeInTheDocument()
expect(mockSetTriggerStatuses).not.toHaveBeenCalled()
})
@ -370,7 +371,7 @@ describe('WorkflowApp', () => {
render(<WorkflowApp />)
await waitFor(() => {
expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'Workflow canvas' })).toBeInTheDocument()
})
expect(mockGetWorkflowRunAndTraceUrl).not.toHaveBeenCalled()

View File

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

View File

@ -1,6 +1,7 @@
import type { Shape } from '../../../store'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect, useRef } from 'react'
import { Plan } from '@/app/components/billing/type'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
@ -19,6 +20,7 @@ const mockWorkflowStoreSetState = vi.fn()
const mockEmitRestoreIntent = vi.fn()
const mockEmitRestoreComplete = vi.fn()
const mockEmitWorkflowUpdate = vi.fn()
const mockFetchNextPage = vi.fn()
const mockToast = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
@ -26,6 +28,8 @@ const mockToast = vi.hoisted(() => ({
let mockPlanType = Plan.professional
let mockEnableBilling = true
let mockPublishedEnvironments: VersionHistory['environments']
let mockHasNextPage = false
let mockIsFetching = false
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-id',
@ -109,9 +113,9 @@ vi.mock('@/service/use-workflow', () => ({
},
],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
isFetching: mockIsFetching,
}),
}))
@ -184,7 +188,11 @@ vi.mock('../restore-confirm-modal', () => ({
if (!isOpen) return null
return <button onClick={() => onRestore(versionInfo)}>confirm restore</button>
return (
<button type="button" onClick={() => onRestore(versionInfo)}>
confirm restore
</button>
)
}
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>
}) => (
<button
type="button"
onClick={() =>
onPublish({
id: versionInfo.id,
@ -228,15 +237,19 @@ vi.mock('../version-history-item', () => ({
return (
<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 && (
<>
<button
type="button"
onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)}
>
{`restore-${item.id}`}
</button>
<button
type="button"
onClick={() =>
handleClickActionMenuItem(VersionHistoryContextMenuOptions.exportDSL)
}
@ -244,11 +257,13 @@ vi.mock('../version-history-item', () => ({
{`export-${item.id}`}
</button>
<button
type="button"
onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.edit)}
>
{`edit-${item.id}`}
</button>
<button
type="button"
onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.delete)}
>
{`delete-${item.id}`}
@ -272,6 +287,35 @@ describe('VersionHistoryPanel', () => {
mockPlanType = Plan.professional
mockEnableBilling = true
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', () => {

View File

@ -142,13 +142,9 @@ describe('VersionHistoryItem', () => {
/>,
)
const title = screen.getByText('Release 1')
const itemContainer = title.closest('.group')
if (!itemContainer) throw new Error('Expected version history item container')
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Release 1' }))
fireEvent.mouseEnter(itemContainer)
const triggerButton = await screen.findByRole('button')
const triggerButton = await screen.findByRole('button', { name: 'common.operation.more' })
await user.click(triggerButton)
expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument()
@ -188,13 +184,9 @@ describe('VersionHistoryItem', () => {
/>,
)
const title = screen.getByText('Release 1')
const itemContainer = title.closest('.group')
if (!itemContainer) throw new Error('Expected version history item container')
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Release 1' }))
fireEvent.mouseEnter(itemContainer)
const triggerButton = await screen.findByRole('button')
const triggerButton = await screen.findByRole('button', { name: 'common.operation.more' })
await user.click(triggerButton)
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()
})
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 { toast } from '@langgenius/dify-ui/toast'
import { RiArrowDownDoubleLine, RiCloseLine, RiLoader2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import copy from 'copy-to-clipboard'
import * as React from 'react'
@ -372,12 +371,14 @@ export const VersionHistoryPanel = ({
handleSwitch={handleSwitch}
/>
<Divider type="vertical" className="mx-1 h-3.5" />
<div
className="flex size-6 cursor-pointer items-center justify-center p-0.5"
<button
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}
>
<RiCloseLine className="size-4 text-text-tertiary" />
</div>
<span aria-hidden className="i-ri-close-line size-4 text-text-tertiary" />
</button>
</div>
<div className="flex h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto px-3 py-2">
@ -412,18 +413,30 @@ export const VersionHistoryPanel = ({
</div>
{hasNextPage && (
<div className="p-2">
<div className="flex cursor-pointer items-center gap-x-1" onClick={handleNextPage}>
<div className="item-center flex justify-center p-0.5">
<button
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 ? (
<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>
<div className="py-px system-xs-medium-uppercase text-text-accent">
</span>
<span className="py-px system-xs-medium-uppercase text-text-accent">
{t(($) => $['common.loadMore'], { ns: 'workflow' })}
</div>
</div>
</span>
</button>
</div>
)}
</div>

View File

@ -47,7 +47,6 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
hideActionMenu,
}) => {
const { t } = useTranslation()
const [isHovering, setIsHovering] = useState(false)
const [open, setOpen] = useState(false)
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 isLatest = formattedVersion === WorkflowVersion.Latest
const deployedEnvironments = item.environments || []
const titleId = React.useId()
const didSelectDraftRef = React.useRef(false)
useEffect(() => {
if (isDraft) onClick(item)
}, [])
if (!isDraft || didSelectDraftRef.current) return
didSelectDraftRef.current = true
onClick(item)
}, [isDraft, item, onClick])
const handleClickItem = () => {
if (isSelected) return
@ -70,16 +74,9 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
<div
className={cn(
'group relative flex gap-x-1 rounded-lg p-2',
isSelected
? 'cursor-not-allowed bg-state-accent-active'
: 'cursor-pointer hover:bg-state-base-hover',
isSelected ? 'bg-state-accent-active' : 'hover:bg-state-base-hover',
)}
onClick={handleClickItem}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => {
setIsHovering(false)
setOpen(false)
}}
onMouseLeave={() => setOpen(false)}
onContextMenu={(e) => {
if (hideActionMenu) return
@ -87,20 +84,35 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
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 && (
<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
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(
'size-2 rounded-lg border-2',
isSelected ? 'border-text-accent' : 'border-text-quaternary',
)}
/>
</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
id={titleId}
className={cn(
'truncate py-px system-sm-semibold',
isSelected ? 'text-text-accent' : 'text-text-secondary',
@ -144,8 +156,13 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
)}
</div>
{/* Action Menu */}
{!hideActionMenu && !isDraft && isHovering && (
<div className="absolute top-1 right-1">
{!hideActionMenu && !isDraft && (
<div
className={cn(
'invisible absolute top-1 right-1 z-10 group-focus-within:visible group-hover:visible',
open && 'visible',
)}
>
<ActionMenu
workflowId={item.id}
isShowDelete={!isLatest}