mirror of
https://github.com/langgenius/dify.git
synced 2026-09-21 13:20:52 +08:00
fix(web): improve home accessibility (#39938)
This commit is contained in:
@@ -803,8 +803,7 @@ describe('AppList', () => {
|
||||
|
||||
renderAppList()
|
||||
|
||||
const card = screen.getByRole('button', { name: 'Preview Only App' })
|
||||
expect(card).toHaveClass('opacity-60')
|
||||
const card = screen.getByRole('button', { name: /Preview Only App.*app\.types\.chatbot/ })
|
||||
expect(card).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.queryByRole('link', { name: /Preview Only App/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Readonly Author')).toBeInTheDocument()
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ExploreAppListHeader({
|
||||
href="https://marketplace.dify.ai/templates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary hover:text-text-secondary"
|
||||
className="-m-1 flex min-h-6 shrink-0 touch-manipulation items-center gap-1 rounded-md p-1 system-xs-medium text-text-tertiary outline-hidden transition-colors hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
|
||||
>
|
||||
{t(($) => $['apps.viewMore'], { ns: 'explore' })}
|
||||
<span className="i-ri-arrow-right-line size-3 shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AnchorHTMLAttributes, ReactNode } from 'react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import ContinueWorkItem from '../item'
|
||||
@@ -86,6 +87,7 @@ describe('ContinueWorkItem', () => {
|
||||
const link = screen.getByRole('link', { name: /Continue App/ })
|
||||
|
||||
expect(link).toHaveAttribute('href', '/app/app-1/configuration')
|
||||
expect(link).toHaveAccessibleDescription(/Alice.*5 minutes ago/)
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}'),
|
||||
@@ -93,6 +95,29 @@ describe('ContinueWorkItem', () => {
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['chat', 'app.types.chatbot'],
|
||||
['advanced-chat', 'app.types.advanced'],
|
||||
['agent-chat', 'app.types.agent'],
|
||||
['workflow', 'app.types.workflow'],
|
||||
['completion', 'app.types.completion'],
|
||||
] as const)('should include the %s app mode in the accessible name', (mode, label) => {
|
||||
renderItem(createApp({ mode }))
|
||||
|
||||
expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAccessibleName(
|
||||
new RegExp(label.replaceAll('.', '\\.'), 'i'),
|
||||
)
|
||||
})
|
||||
|
||||
it('should omit the author separator when the author is unavailable', () => {
|
||||
renderItem(createApp({ author_name: null }))
|
||||
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should link to access config when RBAC is enabled and only access config permission is available', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }))
|
||||
|
||||
@@ -113,39 +138,34 @@ describe('ContinueWorkItem', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should render preview-only apps as disabled buttons and warn on click', () => {
|
||||
it('should render preview-only apps as disabled buttons and warn on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
|
||||
|
||||
const card = screen.getByRole('button', { name: 'Continue App' })
|
||||
const card = screen.getByRole('button', { name: /Continue App.*app\.types\.chatbot/i })
|
||||
|
||||
expect(card).toHaveAttribute('type', 'button')
|
||||
expect(card).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(card).toHaveClass('cursor-not-allowed')
|
||||
expect(card).toHaveClass('opacity-60')
|
||||
expect(card).toHaveAccessibleDescription(/Alice.*5 minutes ago/)
|
||||
expect(screen.queryByRole('link', { name: /Continue App/ })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(card)
|
||||
await user.click(card)
|
||||
|
||||
expect(toastMocks.warning).toHaveBeenCalledWith('app.noAccessResourcePermission')
|
||||
})
|
||||
|
||||
it('should warn when activating a preview-only app with Enter or Space', () => {
|
||||
it('should warn when activating a preview-only app with Enter or Space', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
|
||||
|
||||
const card = screen.getByRole('button', { name: 'Continue App' })
|
||||
const card = screen.getByRole('button', { name: /Continue App.*app\.types\.chatbot/i })
|
||||
|
||||
fireEvent.keyDown(card, { key: 'Enter' })
|
||||
fireEvent.keyDown(card, { key: ' ' })
|
||||
card.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(toastMocks.warning).toHaveBeenCalledTimes(2)
|
||||
expect(toastMocks.warning).toHaveBeenNthCalledWith(1, 'app.noAccessResourcePermission')
|
||||
expect(toastMocks.warning).toHaveBeenNthCalledWith(2, 'app.noAccessResourcePermission')
|
||||
})
|
||||
|
||||
it('should ignore other keys on preview-only app cards', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Continue App' }), { key: 'Escape' })
|
||||
|
||||
expect(toastMocks.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ const ContinueWork = ({ apps, className }: ContinueWorkProps) => {
|
||||
</h2>
|
||||
<Link
|
||||
href="/apps"
|
||||
className="ml-4 flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary"
|
||||
className="-my-1 -mr-1 ml-3 flex min-h-6 shrink-0 touch-manipulation items-center gap-1 rounded-md p-1 system-xs-medium text-text-tertiary outline-hidden transition-colors hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
|
||||
>
|
||||
{t(($) => $['continueWork.exploreStudio'], { ns: 'explore' })}
|
||||
<span className="i-ri-arrow-right-line size-3 shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -17,6 +17,14 @@ import Link from '@/next/link'
|
||||
import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
import { hasOnlyAppPreviewPermission } from '@/utils/permission'
|
||||
|
||||
const appModeLabelKeys = {
|
||||
'advanced-chat': 'types.advanced',
|
||||
'agent-chat': 'types.agent',
|
||||
chat: 'types.chatbot',
|
||||
completion: 'types.completion',
|
||||
workflow: 'types.workflow',
|
||||
} as const satisfies Record<RecentAppResponse['mode'], string>
|
||||
|
||||
type ContinueWorkItemProps = {
|
||||
app: RecentAppResponse
|
||||
}
|
||||
@@ -27,8 +35,12 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
const currentUserId = useAtomValue(userProfileIdAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const appNameId = React.useId()
|
||||
const appModeId = React.useId()
|
||||
const appMetadataId = React.useId()
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const updatedAt = app.updated_at * 1000
|
||||
const appModeLabel = t(($) => $[appModeLabelKeys[app.mode]], { ns: 'app' })
|
||||
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
|
||||
const href = getRedirectionPath(app, {
|
||||
currentUserId,
|
||||
@@ -37,7 +49,7 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
isRbacEnabled,
|
||||
})
|
||||
const cardClassName = cn(
|
||||
'flex min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-4 pb-4 shadow-xs shadow-shadow-shadow-3',
|
||||
'relative flex min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-4 pb-4 text-left shadow-xs shadow-shadow-shadow-3',
|
||||
isPreviewOnly && 'cursor-not-allowed opacity-60',
|
||||
)
|
||||
|
||||
@@ -45,13 +57,6 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' }))
|
||||
}
|
||||
|
||||
const handlePreviewOnlyCardKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
|
||||
event.preventDefault()
|
||||
showPreviewOnlyAccessWarning()
|
||||
}
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="relative shrink-0">
|
||||
@@ -69,10 +74,24 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 py-px">
|
||||
<h3 className="truncate system-md-semibold text-text-secondary">{app.name}</h3>
|
||||
<div className="flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary">
|
||||
<span className="shrink-0">{app.author_name}</span>
|
||||
<span className="shrink-0">·</span>
|
||||
<h3 id={appNameId} className="truncate system-md-semibold text-text-secondary">
|
||||
{app.name}
|
||||
</h3>
|
||||
<span id={appModeId} className="sr-only">
|
||||
{appModeLabel}
|
||||
</span>
|
||||
<div
|
||||
id={appMetadataId}
|
||||
className="flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{app.author_name && (
|
||||
<>
|
||||
<span className="min-w-0 truncate">{app.author_name}</span>
|
||||
<span className="shrink-0" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="min-w-0 truncate">
|
||||
{t(($) => $['continueWork.editedAt'], {
|
||||
ns: 'explore',
|
||||
@@ -86,22 +105,30 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
|
||||
if (isPreviewOnly) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={app.name}
|
||||
aria-disabled="true"
|
||||
className={cardClassName}
|
||||
onClick={showPreviewOnlyAccessWarning}
|
||||
onKeyDown={handlePreviewOnlyCardKeyDown}
|
||||
>
|
||||
<div className={cardClassName}>
|
||||
<button
|
||||
type="button"
|
||||
aria-labelledby={`${appNameId} ${appModeId}`}
|
||||
aria-describedby={appMetadataId}
|
||||
aria-disabled="true"
|
||||
className="absolute inset-0 z-10 cursor-not-allowed touch-manipulation appearance-none rounded-xl border-0 bg-transparent p-0 outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
|
||||
onClick={showPreviewOnlyAccessWarning}
|
||||
/>
|
||||
{cardContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={cardClassName}>
|
||||
<Link
|
||||
href={href}
|
||||
aria-labelledby={`${appNameId} ${appModeId}`}
|
||||
aria-describedby={appMetadataId}
|
||||
className={cn(
|
||||
cardClassName,
|
||||
'touch-manipulation outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
|
||||
)}
|
||||
>
|
||||
{cardContent}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -57,14 +58,15 @@ describe('LearnDifyItem', () => {
|
||||
expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should create app when card is clicked outside cloud edition', () => {
|
||||
it('should create app when card is clicked outside cloud edition', async () => {
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
const app = createApp()
|
||||
const onCreate = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<LearnDifyItem canCreate item={app} onCreate={onCreate} onTry={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Dify App' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Learn Dify App' }))
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(app)
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
@@ -78,13 +80,14 @@ describe('LearnDifyItem', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Learn Dify App' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open detail when card is clicked in cloud edition', () => {
|
||||
it('should open detail when card is clicked in cloud edition', async () => {
|
||||
const onTry = vi.fn()
|
||||
const app = createApp()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<LearnDifyItem canCreate={false} item={app} onTry={onTry} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Dify App' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Learn Dify App' }))
|
||||
|
||||
expect(onTry).toHaveBeenCalledWith({ appId: app.app_id, app })
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('preview_template', {
|
||||
@@ -96,13 +99,19 @@ describe('LearnDifyItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should run the card action when Enter is pressed', () => {
|
||||
it('should run the card action when Enter is pressed', async () => {
|
||||
const onTry = vi.fn()
|
||||
const app = createApp()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<LearnDifyItem canCreate={false} item={app} onTry={onTry} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Learn Dify App' }), { key: 'Enter' })
|
||||
const card = screen.getByRole('button', { name: 'Learn Dify App' })
|
||||
expect(card).toHaveAttribute('type', 'button')
|
||||
expect(card).toHaveAccessibleDescription('Learn Dify description')
|
||||
|
||||
card.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onTry).toHaveBeenCalledWith({ appId: app.app_id, app })
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ const LearnDifyContent = ({
|
||||
{onHide && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
|
||||
className="flex size-8 shrink-0 touch-manipulation items-center justify-center rounded-lg text-text-tertiary outline-hidden transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
|
||||
aria-label={t(($) => $['learnDify.hide'], { ns: 'explore' })}
|
||||
onClick={handleHide}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -22,6 +21,8 @@ const LearnDifyItem = ({ canCreate, item, onCreate, onTry }: LearnDifyItemProps)
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const appNameId = React.useId()
|
||||
const appDescriptionId = React.useId()
|
||||
const appBasicInfo = item.app
|
||||
const canViewApp = deploymentEdition === 'CLOUD'
|
||||
const canShowCreate = canCreate && !!onCreate
|
||||
@@ -45,25 +46,23 @@ const LearnDifyItem = ({ canCreate, item, onCreate, onTry }: LearnDifyItemProps)
|
||||
|
||||
if (canShowCreate) onCreate?.(item)
|
||||
}
|
||||
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
|
||||
event.preventDefault()
|
||||
handleCardClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'relative flex min-w-0 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs',
|
||||
'relative flex min-w-0 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 text-left shadow-xs',
|
||||
isClickable && 'cursor-pointer',
|
||||
)}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
aria-label={isClickable ? appBasicInfo.name : undefined}
|
||||
onClick={isClickable ? handleCardClick : undefined}
|
||||
onKeyDown={isClickable ? handleCardKeyDown : undefined}
|
||||
>
|
||||
{isClickable && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-10 cursor-pointer touch-manipulation appearance-none rounded-xl border-0 bg-transparent p-0 outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
|
||||
aria-labelledby={appNameId}
|
||||
aria-describedby={item.description ? appDescriptionId : undefined}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-2 pb-1">
|
||||
<AppIcon
|
||||
size="large"
|
||||
@@ -72,11 +71,14 @@ const LearnDifyItem = ({ canCreate, item, onCreate, onTry }: LearnDifyItemProps)
|
||||
background={appBasicInfo.icon_background}
|
||||
imageUrl={appBasicInfo.icon_url}
|
||||
/>
|
||||
<h3 className="w-full truncate system-md-semibold text-text-secondary">
|
||||
<h3 id={appNameId} className="w-full truncate system-md-semibold text-text-secondary">
|
||||
{appBasicInfo.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">
|
||||
<p
|
||||
id={appDescriptionId}
|
||||
className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
Reference in New Issue
Block a user