mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 06:06:20 +08:00
Merge remote-tracking branch 'origin/feat/ui-onboarding-rewrite' into feat/agent-v2
This commit is contained in:
@ -21,7 +21,7 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
|
||||
return <FullScreenLoading />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center gap-4 bg-background-body">
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{t('errorBoundary.message')}
|
||||
</div>
|
||||
|
||||
@ -42,7 +42,7 @@ const AppCard = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative col-span-1 flex h-[142px] cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3">
|
||||
<div className="group relative col-span-1 flex h-35.5 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3">
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2">
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
|
||||
@ -73,23 +73,26 @@ export function ExploreHeaderSkeleton() {
|
||||
function ExploreAppCardSkeleton() {
|
||||
return (
|
||||
<div className="col-span-1 flex h-[142px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3">
|
||||
<SkeletonContainer className="h-full px-4 pt-4">
|
||||
<SkeletonRow>
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2">
|
||||
<div className="relative shrink-0">
|
||||
<SkeletonRectangle className="size-10 shrink-0 animate-pulse rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<SkeletonRectangle className="h-4 w-2/3 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-1/3 animate-pulse" />
|
||||
</div>
|
||||
</SkeletonRow>
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<SkeletonRectangle className="h-3 w-full animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
|
||||
</div>
|
||||
<div className="mt-auto flex gap-1 pb-1">
|
||||
<SkeletonRectangle className="h-5 w-20 animate-pulse rounded-[5px]" />
|
||||
<SkeletonRectangle className="h-5 w-16 animate-pulse rounded-[5px]" />
|
||||
<div className="flex w-0 grow flex-col gap-1 py-px">
|
||||
<SkeletonRectangle className="my-0 h-4 w-3/5 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-16 animate-pulse" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start px-4 py-1">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<SkeletonRectangle className="my-0 h-3 w-full animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-4/5 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex h-[26px] w-full shrink-0 flex-col gap-2 overflow-hidden px-3">
|
||||
<div className="flex w-full shrink-0 items-center gap-1 rounded-lg p-1">
|
||||
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-[5px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -155,11 +155,10 @@ describe('Banner', () => {
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
const loadingWrapper = document.querySelector('[style*="min-height"]')
|
||||
expect(loadingWrapper).toBeInTheDocument()
|
||||
expect(screen.getByRole('status', { name: 'loading' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading indicator with correct minimum height', () => {
|
||||
it('matches the loaded greeting card shell while loading', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
@ -168,39 +167,42 @@ describe('Banner', () => {
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
const loadingWrapper = document.querySelector('[style*="min-height: 168px"]')
|
||||
expect(loadingWrapper).toBeInTheDocument()
|
||||
const loadingWrapper = screen.getByRole('status', { name: 'loading' })
|
||||
expect(loadingWrapper).toHaveClass('rounded-[24px]', 'bg-background-default-dodge')
|
||||
expect(loadingWrapper.querySelector('.px-8.pt-8.pb-8')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
it('returns null when isError is true', () => {
|
||||
it('renders the greeting shell without slider when isError is true', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
})
|
||||
|
||||
const { container } = render(<Banner />)
|
||||
render(<Banner />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty state', () => {
|
||||
it('returns null when banners array is empty', () => {
|
||||
it('renders the greeting shell without slider when banners array is empty', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
const { container } = render(<Banner />)
|
||||
render(<Banner />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns null when all banners are disabled', () => {
|
||||
it('renders the greeting shell without slider when all banners are disabled', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: [
|
||||
createMockBanner('1', 'disabled'),
|
||||
@ -210,21 +212,23 @@ describe('Banner', () => {
|
||||
isError: false,
|
||||
})
|
||||
|
||||
const { container } = render(<Banner />)
|
||||
render(<Banner />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns null when data is undefined', () => {
|
||||
it('renders the greeting shell without slider when data is undefined', () => {
|
||||
mockUseGetBanners.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
const { container } = render(<Banner />)
|
||||
render(<Banner />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -6,24 +6,31 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { Carousel, useCarousel } from '@/app/components/base/carousel'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useGetBanners } from '@/service/use-explore'
|
||||
import Loading from '../../base/loading'
|
||||
import { BannerItem } from './banner-item'
|
||||
|
||||
const AUTOPLAY_DELAY = 5000
|
||||
const MIN_LOADING_HEIGHT = 168
|
||||
const RESIZE_DEBOUNCE_DELAY = 50
|
||||
|
||||
const LoadingState: FC = () => (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-[24px] bg-background-default-dodge shadow-xs"
|
||||
style={{ minHeight: MIN_LOADING_HEIGHT }}
|
||||
>
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
const LoadingState: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('loading', { ns: 'common' })}
|
||||
className="relative flex w-full flex-col items-start overflow-hidden rounded-[24px] bg-background-default-dodge shadow-xs"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-1 px-8 pt-8 pb-8">
|
||||
<SkeletonRectangle className="my-0 h-8 w-[360px] max-w-full animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-4 w-72 max-w-full animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type BannerImpressionTrackerProps = {
|
||||
banners: BannerType[]
|
||||
|
||||
@ -284,7 +284,9 @@ describe('MainNav', () => {
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.getByText('common.environment.testing')).toBeInTheDocument()
|
||||
const environmentTag = screen.getByText('common.environment.testing')
|
||||
expect(environmentTag).toBeInTheDocument()
|
||||
expect(environmentTag.closest('.relative.z-30')).toHaveClass('mt-auto', 'shrink-0')
|
||||
})
|
||||
|
||||
it('does not reserve environment tag space when the environment is not shown', () => {
|
||||
@ -627,10 +629,21 @@ describe('MainNav', () => {
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.search' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.loading')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the installed web apps section when no web apps are available', () => {
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.search' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('separates pinned and unpinned installed web apps', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'installed-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }),
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GOTO_ANYTHING_OPEN_EVENT } from '@/app/components/goto-anything/hooks'
|
||||
|
||||
const MainNavSearchButton = () => {
|
||||
const searchShortcut = ['Mod', 'K']
|
||||
|
||||
export function MainNavSearchButton() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -14,9 +18,11 @@ const MainNavSearchButton = () => {
|
||||
onClick={() => window.dispatchEvent(new Event(GOTO_ANYTHING_OPEN_EVENT))}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
|
||||
<span className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">⌘K</span>
|
||||
<Kbd className="h-[18px] min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{searchShortcut.map(key => (
|
||||
<span key={key}>{formatForDisplay(key)}</span>
|
||||
))}
|
||||
</Kbd>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default MainNavSearchButton
|
||||
|
||||
@ -34,7 +34,16 @@ const appNavItemGap = 2
|
||||
const appNavSeparatorHeight = 17
|
||||
const virtualizationThreshold = 50
|
||||
const webAppSkeletonClassName = 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
|
||||
const webAppSkeletonWidths = ['w-24', 'w-32', 'w-20', 'w-28', 'w-36']
|
||||
const webAppSkeletonWidths = ['w-24', 'w-32', 'w-28']
|
||||
|
||||
function WebAppsHeaderSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" className="flex h-8 items-center justify-between py-1 pr-3.5 pl-4">
|
||||
<div className={cn(webAppSkeletonClassName, 'h-3 w-20')} />
|
||||
<div className={cn(webAppSkeletonClassName, 'size-4 rounded-md')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WebAppsSkeleton() {
|
||||
return (
|
||||
@ -130,6 +139,9 @@ const WebAppsSection = () => {
|
||||
toast.success(t('api.success', { ns: 'common' }))
|
||||
}
|
||||
|
||||
if (!isPending && installedApps.length === 0)
|
||||
return null
|
||||
|
||||
const renderAppNavItem = ({ id, is_pinned, uninstallable, app }: (typeof filteredApps)[number]) => (
|
||||
<AppNavItem
|
||||
key={id}
|
||||
@ -162,33 +174,37 @@ const WebAppsSection = () => {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between py-1 pr-2.5 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 hover:text-text-secondary"
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.search', { ns: 'common' })}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', searchVisible && 'bg-state-base-hover text-text-secondary')}
|
||||
onClick={() => {
|
||||
setAppsExpanded(true)
|
||||
setSearchVisible(value => !value)
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-search-line size-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{appsExpanded && searchVisible && (
|
||||
{isPending
|
||||
? <WebAppsHeaderSkeleton />
|
||||
: (
|
||||
<div className="flex items-center justify-between py-1 pr-2.5 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 hover:text-text-secondary"
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.search', { ns: 'common' })}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', searchVisible && 'bg-state-base-hover text-text-secondary')}
|
||||
onClick={() => {
|
||||
setAppsExpanded(true)
|
||||
setSearchVisible(value => !value)
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-search-line size-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isPending && appsExpanded && searchVisible && (
|
||||
<div className="px-2 pb-2">
|
||||
<SearchInput
|
||||
value={searchText}
|
||||
@ -212,7 +228,7 @@ const WebAppsSection = () => {
|
||||
)}
|
||||
{!isPending && filteredApps.length === 0 && (
|
||||
<div className="px-2 py-1 system-xs-regular">
|
||||
{searchText ? t('mainNav.webApps.noResults', { ns: 'common' }) : t('sidebar.noApps.title', { ns: 'explore' })}
|
||||
{t('mainNav.webApps.noResults', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
{!isPending && webAppRows.length > 0 && !shouldVirtualize && (
|
||||
|
||||
@ -20,7 +20,7 @@ import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import AccountSection from './components/account-section'
|
||||
import HelpMenu from './components/help-menu'
|
||||
import MainNavLink from './components/nav-link'
|
||||
import MainNavSearchButton from './components/search-button'
|
||||
import { MainNavSearchButton } from './components/search-button'
|
||||
import WebAppsSection from './components/web-apps-section'
|
||||
import { WorkspaceCard } from './components/workspace-card'
|
||||
|
||||
@ -174,7 +174,7 @@ const MainNav = ({
|
||||
</>
|
||||
)}
|
||||
{showEnvTag && (
|
||||
<div className="relative z-30 px-3 pb-2">
|
||||
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
|
||||
<EnvNav />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -35,7 +35,5 @@ export const userProfileQueryOptions = () =>
|
||||
},
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
retry: (failureCount, error) => !isLegacyBase401(error) && failureCount < 3,
|
||||
})
|
||||
|
||||
@ -75,7 +75,5 @@ export const serverUserProfileQueryOptions = () =>
|
||||
},
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user