mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 07:20:34 +08:00
fix(web): preserve web apps navigation focus and disclosure semantics (#42113)
This commit is contained in:
@@ -31,7 +31,7 @@ When('I open the app from the app list', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.goto('/apps')
|
||||
await waitForAppsConsole(page)
|
||||
const appLink = page.getByRole('link', { name: appName, exact: true })
|
||||
const appLink = page.getByRole('main').getByRole('link', { name: appName, exact: true })
|
||||
await expect(appLink).toBeVisible()
|
||||
await appLink.click()
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ vi.mock('@/next/link', () => ({
|
||||
}))
|
||||
|
||||
const baseProps = {
|
||||
ariaLabel: 'My App',
|
||||
app: {
|
||||
id: 'app-123',
|
||||
app_owner_tenant_id: 'tenant-1',
|
||||
@@ -70,21 +69,10 @@ describe('AppNavItem', () => {
|
||||
const link = screen.getByRole('link', { name: 'My App' })
|
||||
|
||||
expect(link).toHaveAttribute('href', '/installed/app-123')
|
||||
expect(link).toHaveAttribute('aria-label', 'My App')
|
||||
expect(link).not.toHaveAttribute('aria-current')
|
||||
expect(link).toHaveAttribute('data-prefetch', 'false')
|
||||
})
|
||||
|
||||
it('should use a contextual accessible name when ariaLabel is provided', () => {
|
||||
render(<AppNavItem {...baseProps} ariaLabel="Open My App web app" />)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Open My App web app' })
|
||||
|
||||
expect(link).toHaveAttribute('href', '/installed/app-123')
|
||||
expect(link).toHaveAttribute('aria-label', 'Open My App web app')
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should enable prefetch after pointer intent', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
@@ -7,7 +7,6 @@ import ItemOperation from '@/app/components/explore/item-operation'
|
||||
import Link from '@/next/link'
|
||||
|
||||
type IAppNavItemProps = {
|
||||
ariaLabel: string
|
||||
app: InstalledAppResponse
|
||||
isSelected: boolean
|
||||
onTogglePin: (id: string, isPinned: boolean) => void
|
||||
@@ -15,7 +14,6 @@ type IAppNavItemProps = {
|
||||
}
|
||||
|
||||
export default function AppNavItem({
|
||||
ariaLabel,
|
||||
app: installedApp,
|
||||
isSelected,
|
||||
onTogglePin,
|
||||
@@ -41,11 +39,10 @@ export default function AppNavItem({
|
||||
onMouseEnter={() => setIsPrefetchEnabled(true)}
|
||||
onFocus={() => setIsPrefetchEnabled(true)}
|
||||
aria-current={isSelected ? 'page' : undefined}
|
||||
aria-label={ariaLabel}
|
||||
title={name}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 outline-hidden"
|
||||
>
|
||||
<AppIcon
|
||||
decorative
|
||||
size="tiny"
|
||||
className="size-5 rounded-md text-sm"
|
||||
iconType={icon_type}
|
||||
|
||||
@@ -384,14 +384,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const { default: WebAppsSection } = await import('../components/web-apps-section')
|
||||
|
||||
return {
|
||||
default: () => WebAppsSection,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
@@ -1410,7 +1402,7 @@ describe('MainNav', () => {
|
||||
|
||||
renderMainNav()
|
||||
|
||||
const scrollViewport = await screen.findByRole('region', {
|
||||
const scrollViewport = await screen.findByRole('navigation', {
|
||||
name: 'explore.sidebar.webApps',
|
||||
})
|
||||
scrollViewport.scrollTop = 240
|
||||
@@ -1424,7 +1416,15 @@ describe('MainNav', () => {
|
||||
await user.click(searchButton)
|
||||
expect(searchButton).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder')
|
||||
const searchInput = screen.getByRole('searchbox', {
|
||||
name: 'common.mainNav.webApps.searchPlaceholder',
|
||||
})
|
||||
const webAppsButton = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
|
||||
const listPanel = document.getElementById(webAppsButton.getAttribute('aria-controls')!)
|
||||
const searchPanel = document.getElementById(searchButton.getAttribute('aria-controls')!)
|
||||
expect(listPanel).toContainElement(scrollViewport)
|
||||
expect(searchPanel).toContainElement(searchInput)
|
||||
expect(listPanel).not.toContainElement(searchInput)
|
||||
await user.type(searchInput, 'beta')
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1433,11 +1433,11 @@ describe('MainNav', () => {
|
||||
expect(screen.getByText('Beta Tool')).toBeInTheDocument()
|
||||
})
|
||||
expect(searchInput).toHaveFocus()
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' }),
|
||||
).toHaveAttribute('href', '/installed/installed-2')
|
||||
expect(screen.getByRole('link', { name: 'Beta Tool' })).toHaveAttribute(
|
||||
'href',
|
||||
'/installed/installed-2',
|
||||
)
|
||||
|
||||
const webAppsButton = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
|
||||
await user.click(webAppsButton)
|
||||
expect(searchButton).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(
|
||||
@@ -1449,6 +1449,10 @@ describe('MainNav', () => {
|
||||
expect(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder')).toHaveValue(
|
||||
'beta',
|
||||
)
|
||||
expect(webAppsButton).toHaveFocus()
|
||||
await user.click(searchButton)
|
||||
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
|
||||
expect(searchButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('announces no installed web app results only after the search settles', async () => {
|
||||
@@ -1477,7 +1481,7 @@ describe('MainNav', () => {
|
||||
|
||||
renderMainNav()
|
||||
|
||||
const webAppsRegion = await screen.findByRole('region', {
|
||||
const webAppsRegion = await screen.findByRole('navigation', {
|
||||
name: 'explore.sidebar.webApps',
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.search' }))
|
||||
@@ -1508,7 +1512,7 @@ describe('MainNav', () => {
|
||||
renderMainNav()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('region', { name: 'explore.sidebar.webApps' }),
|
||||
screen.queryByRole('navigation', { name: 'explore.sidebar.webApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
|
||||
@@ -1528,7 +1532,7 @@ describe('MainNav', () => {
|
||||
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('region', { name: 'explore.sidebar.webApps' }),
|
||||
screen.queryByRole('navigation', { name: 'explore.sidebar.webApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
expect(
|
||||
@@ -1555,6 +1559,13 @@ describe('MainNav', () => {
|
||||
expect(await screen.findByText('Pinned App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unpinned App')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('divider')).toBeInTheDocument()
|
||||
const rows = within(
|
||||
screen.getByRole('navigation', { name: 'explore.sidebar.webApps' }),
|
||||
).getAllByRole('listitem')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveAttribute('aria-posinset', '1')
|
||||
expect(rows[1]).toHaveAttribute('aria-posinset', '2')
|
||||
for (const row of rows) expect(row).toHaveAttribute('aria-setsize', '2')
|
||||
})
|
||||
|
||||
it('keeps long installed web app names truncated in the main nav item', async () => {
|
||||
@@ -1623,7 +1634,7 @@ describe('MainNav', () => {
|
||||
)
|
||||
renderMainNav()
|
||||
const firstAppLink = await screen.findByRole('link', {
|
||||
name: 'common.mainNav.webApps.openApp:{"name":"Alpha App"}',
|
||||
name: 'Alpha App',
|
||||
})
|
||||
|
||||
await triggerIntersection()
|
||||
@@ -1634,7 +1645,7 @@ describe('MainNav', () => {
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
|
||||
const webAppsRegion = screen.getByRole('region', { name: 'explore.sidebar.webApps' })
|
||||
const webAppsRegion = screen.getByRole('navigation', { name: 'explore.sidebar.webApps' })
|
||||
retryButton.focus()
|
||||
expect(retryButton).toHaveFocus()
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type {
|
||||
InstalledAppListResponse,
|
||||
InstalledAppResponse,
|
||||
} from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Suspense } from 'react'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import WebAppsSection from '../web-apps-section'
|
||||
|
||||
const service = vi.hoisted(() => ({
|
||||
list: vi.fn<() => Promise<InstalledAppListResponse>>(),
|
||||
remove: vi.fn<() => Promise<void>>(),
|
||||
pin: vi.fn<() => Promise<void>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({ usePathname: () => '/' }))
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
return { workspacePermissionKeysAtom: atom(['app_library.access']) }
|
||||
})
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleQuery: {
|
||||
installedApps: {
|
||||
get: {
|
||||
infiniteOptions: (options: {
|
||||
input: (cursor?: string) => { query: { name?: string } }
|
||||
}) => ({
|
||||
...options,
|
||||
queryKey: ['installed-apps', options.input().query.name ?? ''],
|
||||
queryFn: () => service.list(),
|
||||
}),
|
||||
},
|
||||
byInstalledAppId: {
|
||||
delete: { mutationOptions: () => ({ mutationFn: service.remove }) },
|
||||
patch: { mutationOptions: () => ({ mutationFn: service.pin }) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function createApp(index: number): InstalledAppResponse {
|
||||
return {
|
||||
id: `installed-${index}`,
|
||||
app_owner_tenant_id: 'tenant-1',
|
||||
editable: true,
|
||||
last_used_at: null,
|
||||
uninstallable: false,
|
||||
is_pinned: index === 0,
|
||||
app: {
|
||||
id: `app-${index}`,
|
||||
mode: 'chat',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: null,
|
||||
name: `App ${index}`,
|
||||
description: '',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSection(count = 40) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
service.list.mockResolvedValue({
|
||||
installed_apps: Array.from({ length: count }, (_, index) => createApp(index)),
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
})
|
||||
const screen = await render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex h-64 w-60 flex-col bg-background-body text-text-primary">
|
||||
<Suspense fallback={null}>
|
||||
<WebAppsSection />
|
||||
</Suspense>
|
||||
</div>
|
||||
<button type="button">After apps</button>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole('link', {
|
||||
name: 'App 0',
|
||||
exact: true,
|
||||
}),
|
||||
)
|
||||
.toBeVisible()
|
||||
return { screen, queryClient }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
service.remove.mockResolvedValue(undefined)
|
||||
service.pin.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('keeps a focused app mounted when the list scrolls away', async () => {
|
||||
const { screen } = await renderSection()
|
||||
const firstApp = screen.getByRole('link', {
|
||||
name: 'App 0',
|
||||
exact: true,
|
||||
})
|
||||
firstApp.element().focus()
|
||||
const viewport = screen.getByRole('navigation', { name: 'explore.sidebar.webApps' })
|
||||
viewport.element().scrollTo({ top: 1000 })
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole('link', {
|
||||
name: 'App 35',
|
||||
exact: true,
|
||||
}),
|
||||
)
|
||||
.toBeVisible()
|
||||
await expect.element(firstApp).toHaveFocus()
|
||||
await userEvent.keyboard('{Tab}{Tab}')
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole('link', {
|
||||
name: 'App 1',
|
||||
exact: true,
|
||||
}),
|
||||
)
|
||||
.toHaveFocus()
|
||||
await userEvent.keyboard('{Shift>}{Tab}{Tab}{/Shift}')
|
||||
await expect.element(firstApp).toHaveFocus()
|
||||
})
|
||||
|
||||
it('tabs through the virtualized apps and can leave the list', async () => {
|
||||
const { screen } = await renderSection(20)
|
||||
const firstApp = screen.getByRole('link', {
|
||||
name: 'App 0',
|
||||
exact: true,
|
||||
})
|
||||
firstApp.element().focus()
|
||||
for (let index = 0; index < 20; index++) {
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole('link', {
|
||||
name: `App ${index}`,
|
||||
exact: true,
|
||||
}),
|
||||
)
|
||||
.toHaveFocus()
|
||||
await userEvent.keyboard('{Tab}{Tab}')
|
||||
}
|
||||
await expect.element(screen.getByRole('button', { name: 'After apps' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not flash a scrollbar when rapidly toggling a list that fits', async () => {
|
||||
const { screen } = await renderSection(2)
|
||||
const toggle = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
|
||||
const frames: { viewportHeight: number; contentHeight: number; hasOverflow: boolean }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const viewport = screen.getByRole('navigation', { name: 'explore.sidebar.webApps' }).query()
|
||||
if (viewport) {
|
||||
frames.push({
|
||||
viewportHeight: viewport.clientHeight,
|
||||
contentHeight: viewport.scrollHeight,
|
||||
hasOverflow: viewport.hasAttribute('data-has-overflow-y'),
|
||||
})
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
try {
|
||||
for (let index = 0; index < 12; index++) await toggle.click()
|
||||
await expect.element(screen.getByRole('link', { name: 'App 0', exact: true })).toBeVisible()
|
||||
} finally {
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
expect(frames.length).toBeGreaterThan(0)
|
||||
for (const snapshot of frames) {
|
||||
expect(snapshot.viewportHeight).toBeGreaterThanOrEqual(snapshot.contentHeight)
|
||||
expect(snapshot.hasOverflow).toBe(false)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import type {
|
||||
InstalledAppListResponse,
|
||||
InstalledAppResponse,
|
||||
} from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Suspense } from 'react'
|
||||
import { renderToReadableStream } from 'react-dom/server.node'
|
||||
import WebAppsSection from '../web-apps-section'
|
||||
|
||||
vi.mock('@tanstack/react-virtual')
|
||||
|
||||
const service = vi.hoisted(() => ({
|
||||
list: vi.fn<() => Promise<InstalledAppListResponse>>(),
|
||||
remove: vi.fn<() => Promise<void>>(),
|
||||
pin: vi.fn<() => Promise<void>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({ usePathname: () => '/' }))
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
return { workspacePermissionKeysAtom: atom(['app_library.access']) }
|
||||
})
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleQuery: {
|
||||
installedApps: {
|
||||
get: {
|
||||
infiniteOptions: (options: {
|
||||
input: (cursor?: string) => { query: { name?: string } }
|
||||
}) => ({
|
||||
...options,
|
||||
queryKey: ['installed-apps', options.input().query.name ?? ''],
|
||||
queryFn: () => service.list(),
|
||||
}),
|
||||
},
|
||||
byInstalledAppId: {
|
||||
delete: { mutationOptions: () => ({ mutationFn: service.remove }) },
|
||||
patch: { mutationOptions: () => ({ mutationFn: service.pin }) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function createApp(index: number): InstalledAppResponse {
|
||||
return {
|
||||
id: `installed-${index}`,
|
||||
app_owner_tenant_id: 'tenant-1',
|
||||
editable: true,
|
||||
last_used_at: null,
|
||||
uninstallable: false,
|
||||
is_pinned: index === 0,
|
||||
app: {
|
||||
id: `app-${index}`,
|
||||
mode: 'chat',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: null,
|
||||
name: `App ${index}`,
|
||||
description: '',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSection(count = 2) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
service.list.mockResolvedValue({
|
||||
installed_apps: Array.from({ length: count }, (_, index) => createApp(index)),
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
})
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WebAppsSection />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
await screen.findByRole('link', { name: 'App 0' })
|
||||
return { queryClient, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
service.remove.mockResolvedValue(undefined)
|
||||
service.pin.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('leaves only the web apps fallback on the server without running its client providers', async () => {
|
||||
const onError = vi.fn()
|
||||
const onBrowserBailout = vi.fn()
|
||||
const stream = await renderToReadableStream(
|
||||
<>
|
||||
<nav aria-label="Primary">Studio</nav>
|
||||
<Suspense fallback={<span>Web apps fallback</span>}>
|
||||
<WebAppsSection />
|
||||
</Suspense>
|
||||
</>,
|
||||
{ onError, onBrowserBailout },
|
||||
)
|
||||
const html = await new Response(stream).text()
|
||||
expect(html).toContain('<nav aria-label="Primary">Studio</nav>')
|
||||
expect(html).toContain('<span>Web apps fallback</span>')
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(onBrowserBailout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores the menu trigger on cancel and blocks Escape during deletion', async () => {
|
||||
const { user } = await renderSection()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: 'common.operation.moreActionsFor:{"name":"App 0"}',
|
||||
})
|
||||
await user.click(trigger)
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
expect(trigger).toHaveFocus()
|
||||
|
||||
let finish!: () => void
|
||||
service.remove.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
await user.click(trigger)
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
|
||||
try {
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
} finally {
|
||||
await act(async () => {
|
||||
finish()
|
||||
})
|
||||
}
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('keeps the search input focused when a refresh removes all apps', async () => {
|
||||
const { queryClient, user } = await renderSection(1)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.search' }))
|
||||
const search = screen.getByRole('searchbox')
|
||||
expect(search).toHaveFocus()
|
||||
service.list.mockResolvedValue({ installed_apps: [], has_more: false, next_cursor: null })
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['installed-apps'] })
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('status')).toHaveTextContent('common.mainNav.webApps.noResults'),
|
||||
)
|
||||
expect(search).toHaveFocus()
|
||||
})
|
||||
|
||||
it('returns focus to the section control after deleting the final app', async () => {
|
||||
const { queryClient, user } = await renderSection(1)
|
||||
service.remove.mockImplementation(async () => {
|
||||
service.list.mockResolvedValue({ installed_apps: [], has_more: false, next_cursor: null })
|
||||
await queryClient.invalidateQueries({ queryKey: ['installed-apps'] })
|
||||
})
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'common.operation.moreActionsFor:{"name":"App 0"}' }),
|
||||
)
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: 'explore.sidebar.webApps' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps focus when clearing empty search results after the unfiltered cache expires', async () => {
|
||||
const { queryClient, user } = await renderSection()
|
||||
const toggle = screen.getByRole('button', { name: 'common.operation.search' })
|
||||
await user.click(toggle)
|
||||
service.list.mockResolvedValue({ installed_apps: [], has_more: false, next_cursor: null })
|
||||
await user.type(screen.getByRole('searchbox'), 'missing app')
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('status')).toHaveTextContent('common.mainNav.webApps.noResults'),
|
||||
)
|
||||
queryClient.removeQueries({ queryKey: ['installed-apps', ''] })
|
||||
let finish!: (value: InstalledAppListResponse) => void
|
||||
service.list.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
await user.click(toggle)
|
||||
try {
|
||||
expect(toggle).toHaveFocus()
|
||||
} finally {
|
||||
await act(async () => {
|
||||
finish({ installed_apps: [createApp(0)], has_more: false, next_cursor: null })
|
||||
})
|
||||
}
|
||||
expect(await screen.findByRole('link', { name: 'App 0' })).toBeInTheDocument()
|
||||
})
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import {
|
||||
@@ -27,9 +26,10 @@ import {
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { use, useCallback, useId, useMemo, useRef, useState } from 'react'
|
||||
import { browser } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
|
||||
@@ -59,6 +59,7 @@ type WebAppListRow =
|
||||
key: string
|
||||
kind: 'app'
|
||||
app: InstalledAppResponse
|
||||
position: number
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
@@ -69,6 +70,10 @@ const WebAppsSectionContent = () => {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sectionToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const searchFocusRequestedRef = useRef(false)
|
||||
const sectionLabelId = useId()
|
||||
const [lastFocusedAppId, setLastFocusedAppId] = useState<string | null>(null)
|
||||
const [appsExpanded, setAppsExpanded] = useState(true)
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
@@ -103,7 +108,7 @@ const WebAppsSectionContent = () => {
|
||||
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
|
||||
|
||||
return installedApps.flatMap((app, index) => {
|
||||
const rows: WebAppListRow[] = [{ key: app.id, kind: 'app', app }]
|
||||
const rows: WebAppListRow[] = [{ key: app.id, kind: 'app', app, position: index + 1 }]
|
||||
|
||||
if (index === pinnedAppsCount - 1 && index !== installedApps.length - 1)
|
||||
rows.push({ key: `${app.id}-separator`, kind: 'separator' })
|
||||
@@ -115,6 +120,24 @@ const WebAppsSectionContent = () => {
|
||||
(index: number) => webAppRows[index]?.key ?? index,
|
||||
[webAppRows],
|
||||
)
|
||||
const focusedRowIndex = webAppRows.findIndex((row) => row.key === lastFocusedAppId)
|
||||
const getVisibleRows = useCallback(
|
||||
(range: Parameters<typeof defaultRangeExtractor>[0]) => {
|
||||
const visibleRows = defaultRangeExtractor(range)
|
||||
if (focusedRowIndex < 0) return visibleRows
|
||||
|
||||
// Retain the focused row and its neighbors so scrolling cannot remove the Tab target.
|
||||
const previousAppIndex =
|
||||
focusedRowIndex - (webAppRows[focusedRowIndex - 1]?.kind === 'separator' ? 2 : 1)
|
||||
const nextAppIndex =
|
||||
focusedRowIndex + (webAppRows[focusedRowIndex + 1]?.kind === 'separator' ? 2 : 1)
|
||||
const focusRows = [previousAppIndex, focusedRowIndex, nextAppIndex].filter(
|
||||
(index) => index >= 0 && index < range.count,
|
||||
)
|
||||
return [...new Set([...visibleRows, ...focusRows])].sort((a, b) => a - b)
|
||||
},
|
||||
[focusedRowIndex, webAppRows],
|
||||
)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: webAppRows.length,
|
||||
@@ -122,6 +145,7 @@ const WebAppsSectionContent = () => {
|
||||
webAppRows[index]?.kind === 'separator' ? appNavSeparatorHeight : appNavItemHeight,
|
||||
gap: appNavItemGap,
|
||||
getItemKey: getWebAppRowKey,
|
||||
rangeExtractor: getVisibleRows,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 6,
|
||||
paddingEnd: installedAppsQuery.hasNextPage ? 0 : 8,
|
||||
@@ -141,10 +165,16 @@ const WebAppsSectionContent = () => {
|
||||
}
|
||||
|
||||
const handleSearchVisibleChange = (visible: boolean) => {
|
||||
searchFocusRequestedRef.current = visible
|
||||
setAppsExpanded(true)
|
||||
if (!visible) handleSearchTextChange('')
|
||||
setSearchVisible(visible)
|
||||
}
|
||||
const focusSearchOnAttach = useCallback((input: HTMLInputElement | null) => {
|
||||
if (!input || !searchFocusRequestedRef.current) return
|
||||
searchFocusRequestedRef.current = false
|
||||
input.focus()
|
||||
}, [])
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!uninstallDialogAppId) return
|
||||
@@ -176,90 +206,69 @@ const WebAppsSectionContent = () => {
|
||||
|
||||
if (installedAppsQuery.isPending) return null
|
||||
|
||||
if (!installedAppsQuery.isError && installedApps.length === 0 && !normalizedSearchText)
|
||||
if (
|
||||
!installedAppsQuery.isError &&
|
||||
!installedAppsQuery.isPlaceholderData &&
|
||||
installedApps.length === 0 &&
|
||||
!normalizedSearchText &&
|
||||
!searchVisible &&
|
||||
uninstallDialogAppId === null &&
|
||||
!uninstallAppMutation.isSuccess
|
||||
)
|
||||
return null
|
||||
|
||||
const renderAppNavItem = (installedApp: (typeof installedApps)[number]) => (
|
||||
<AppNavItem
|
||||
app={installedApp}
|
||||
ariaLabel={t(($) => $['mainNav.webApps.openApp'], {
|
||||
ns: 'common',
|
||||
name: installedApp.app.name,
|
||||
})}
|
||||
isSelected={isInstalledAppPath(pathname, installedApp.id)}
|
||||
onTogglePin={handleUpdatePinStatus}
|
||||
onDelete={setUninstallDialogAppId}
|
||||
/>
|
||||
)
|
||||
const renderRow = (row: WebAppListRow) => {
|
||||
if (row.kind === 'separator') {
|
||||
return (
|
||||
<div className="flex h-3 items-center px-1">
|
||||
<Divider className="m-0 h-px bg-divider-subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return renderAppNavItem(row.app)
|
||||
}
|
||||
return (
|
||||
<Collapsible
|
||||
open={appsExpanded && searchVisible}
|
||||
onOpenChange={handleSearchVisibleChange}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
open={appsExpanded}
|
||||
onOpenChange={setAppsExpanded}
|
||||
className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_auto_minmax(0,1fr)]"
|
||||
>
|
||||
<div className="flex items-center justify-between py-1 pr-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={appsExpanded}
|
||||
className="flex min-w-0 items-center rounded-md px-2 py-1 text-left system-xs-medium-uppercase text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => setAppsExpanded((value) => !value)}
|
||||
>
|
||||
<span>{t(($) => $['sidebar.webApps'], { ns: 'explore' })}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'i-ri-arrow-down-s-fill h-4 w-4 shrink-0 transition-transform',
|
||||
!appsExpanded && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<CollapsibleTrigger
|
||||
className="size-6 min-h-0 w-6 justify-center gap-0 rounded-md p-0.5 hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-secondary data-panel-open:bg-state-base-hover data-panel-open:text-text-secondary"
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
|
||||
className="rounded-md"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-search-line size-3.5" />
|
||||
</span>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsiblePanel className="shrink-0">
|
||||
<div className="px-2 pb-2">
|
||||
<SearchInput
|
||||
value={searchText}
|
||||
onValueChange={handleSearchTextChange}
|
||||
placeholder={t(($) => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })}
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus -- The field is mounted after an explicit search action.
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
{appsExpanded && (
|
||||
<CollapsibleTrigger
|
||||
ref={sectionToggleRef}
|
||||
className="col-start-1 row-start-1 my-1 ml-2 min-h-6 w-fit min-w-0 justify-start gap-0 rounded-md px-2 py-1 text-left text-text-tertiary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-tertiary"
|
||||
>
|
||||
<span id={sectionLabelId} className="system-xs-medium-uppercase">
|
||||
{t(($) => $['sidebar.webApps'], { ns: 'explore' })}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-arrow-down-s-fill h-4 w-4 shrink-0 -rotate-90 transition-transform group-data-panel-open:rotate-0 motion-reduce:transition-none"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<Collapsible
|
||||
open={appsExpanded && searchVisible}
|
||||
onOpenChange={handleSearchVisibleChange}
|
||||
className="contents"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className="col-start-2 row-start-1 my-1 mr-2 size-6 min-h-0 w-6 justify-center gap-0 self-center rounded-md p-0.5 hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-secondary data-panel-open:bg-state-base-hover data-panel-open:text-text-secondary"
|
||||
render={
|
||||
<IconButton aria-label={t(($) => $['operation.search'], { ns: 'common' })}>
|
||||
<span aria-hidden className="i-ri-search-line size-3.5" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<CollapsiblePanel className="col-span-2 row-start-2">
|
||||
<div className="px-2 pb-2">
|
||||
<SearchInput
|
||||
ref={focusSearchOnAttach}
|
||||
value={searchText}
|
||||
onValueChange={handleSearchTextChange}
|
||||
placeholder={t(($) => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })}
|
||||
aria-label={t(($) => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })}
|
||||
/>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</Collapsible>
|
||||
<CollapsiblePanel className="col-span-2 row-start-3 flex h-full min-h-0 flex-col transition-none data-ending-style:h-full data-starting-style:h-full">
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollAreaViewport
|
||||
ref={scrollRef}
|
||||
aria-busy={installedAppsQuery.isFetching}
|
||||
aria-label={t(($) => $['sidebar.webApps'], { ns: 'explore' })}
|
||||
aria-labelledby={sectionLabelId}
|
||||
style={{ overflowX: 'hidden' }}
|
||||
className="overscroll-contain"
|
||||
role="region"
|
||||
className="overscroll-contain focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
|
||||
role="navigation"
|
||||
>
|
||||
<ScrollAreaContent style={{ minWidth: 0 }} className="w-full max-w-full px-2">
|
||||
<div className="sr-only" role="status">
|
||||
@@ -286,7 +295,7 @@ const WebAppsSectionContent = () => {
|
||||
<div className="px-2 py-1 system-xs-regular">{noResultsMessage}</div>
|
||||
)}
|
||||
{webAppRows.length > 0 && (
|
||||
<div
|
||||
<ul
|
||||
className="relative w-full"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
@@ -294,19 +303,45 @@ const WebAppsSectionContent = () => {
|
||||
const row = webAppRows[virtualRow.index]!
|
||||
|
||||
return (
|
||||
<div
|
||||
<li
|
||||
key={virtualRow.key}
|
||||
aria-hidden={row.kind === 'separator' ? true : undefined}
|
||||
aria-posinset={row.kind === 'app' ? row.position : undefined}
|
||||
aria-setsize={
|
||||
row.kind === 'app'
|
||||
? installedAppsQuery.hasNextPage
|
||||
? -1
|
||||
: installedApps.length
|
||||
: undefined
|
||||
}
|
||||
onFocusCapture={
|
||||
row.kind === 'app' ? () => setLastFocusedAppId(row.app.id) : undefined
|
||||
}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderRow(row)}
|
||||
</div>
|
||||
{row.kind === 'separator' ? (
|
||||
<div className="flex h-3 items-center px-1">
|
||||
<Divider className="m-0 h-px bg-divider-subtle" />
|
||||
</div>
|
||||
) : (
|
||||
<AppNavItem
|
||||
app={row.app}
|
||||
isSelected={isInstalledAppPath(pathname, row.app.id)}
|
||||
onTogglePin={handleUpdatePinStatus}
|
||||
onDelete={(id) => {
|
||||
uninstallAppMutation.reset()
|
||||
setUninstallDialogAppId(id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
{installedAppsQuery.hasNextPage && (
|
||||
<div className="relative">
|
||||
@@ -347,14 +382,20 @@ const WebAppsSectionContent = () => {
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
<AlertDialog
|
||||
open={uninstallDialogAppId !== null}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange={(open, details) => {
|
||||
if (uninstallAppMutation.isPending) {
|
||||
details.cancel()
|
||||
return
|
||||
}
|
||||
if (!open) setUninstallDialogAppId(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent
|
||||
finalFocus={() => (uninstallAppMutation.isSuccess ? sectionToggleRef.current : true)}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
|
||||
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['sidebar.delete.title'], { ns: 'explore' })}
|
||||
@@ -381,6 +422,8 @@ const WebAppsSectionContent = () => {
|
||||
}
|
||||
|
||||
const WebAppsSection = () => {
|
||||
use(browser('The installed apps navigation renders in the browser.'))
|
||||
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canAccessAppLibrary = hasPermission(workspacePermissionKeys, 'app_library.access')
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { MainNavItem, MainNavProps } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { lazy, Suspense, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
|
||||
@@ -15,7 +15,6 @@ import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { useCanViewSkills } from '@/features/skills/permissions'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
@@ -26,7 +25,7 @@ import { MainNavSearchButton } from './components/search-button'
|
||||
import { WorkspaceCard } from './components/workspace-card'
|
||||
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
|
||||
|
||||
const WebAppsSection = dynamic(() => import('./components/web-apps-section'), { ssr: false })
|
||||
const WebAppsSection = lazy(() => import('./components/web-apps-section'))
|
||||
|
||||
export function MainNav({ className, initialPlatform }: MainNavProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -131,7 +130,11 @@ export function MainNav({ className, initialPlatform }: MainNavProps) {
|
||||
</MainNavLink>
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
{!isCurrentWorkspaceDatasetOperator && (
|
||||
<Suspense fallback={null}>
|
||||
<WebAppsSection />
|
||||
</Suspense>
|
||||
)}
|
||||
{showEnvTag && (
|
||||
<div className="mt-auto shrink-0 px-3 pb-2">
|
||||
<EnvNav />
|
||||
|
||||
Reference in New Issue
Block a user