mirror of
https://github.com/langgenius/dify.git
synced 2026-08-15 04:59:46 +08:00
fix(web): improve app deployment v2 accessibility (#40550)
This commit is contained in:
parent
9cc1f68364
commit
fbaf23dc8d
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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 }),
|
||||
)
|
||||
|
||||
@ -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
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user