Compare commits

...

6 Commits

Author SHA1 Message Date
yyh
d8d3f408f9 example 2026-03-19 14:14:26 +08:00
yyh
e58d7344f1 fix stories 2026-03-19 14:05:51 +08:00
yyh
818026d095 fix tests 2026-03-19 13:59:04 +08:00
yyh
728a29b84f migrate toast 2026-03-19 13:43:29 +08:00
yyh
660f317bc7 refactor(web): loosen scroll area placement 2026-03-19 13:36:45 +08:00
yyh
c463576afd refactor(web): unify explore sidebar scroll container 2026-03-19 13:26:29 +08:00
7 changed files with 192 additions and 82 deletions

View File

@@ -7,17 +7,21 @@
*/
import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import SideBar from '@/app/components/explore/sidebar'
import { MediaType } from '@/hooks/use-breakpoints'
import { AppModeEnum } from '@/types/app'
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
let mockMediaType: string = MediaType.pc
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockInstalledApps: InstalledApp[] = []
let mockIsUninstallPending = false
vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegments: () => mockSegments,
@@ -42,12 +46,22 @@ vi.mock('@/service/use-explore', () => ({
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
isPending: mockIsUninstallPending,
}),
useUpdateAppPinStatus: () => ({
mutateAsync: mockUpdatePinStatus,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
close: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
},
}))
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
id: overrides.id ?? 'app-1',
uninstallable: overrides.uninstallable ?? false,
@@ -74,7 +88,7 @@ describe('Sidebar Lifecycle Flow', () => {
vi.clearAllMocks()
mockMediaType = MediaType.pc
mockInstalledApps = []
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
mockIsUninstallPending = false
})
describe('Pin / Unpin / Delete Flow', () => {
@@ -91,7 +105,7 @@ describe('Sidebar Lifecycle Flow', () => {
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true })
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
@@ -110,7 +124,7 @@ describe('Sidebar Lifecycle Flow', () => {
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false })
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
@@ -136,9 +150,9 @@ describe('Sidebar Lifecycle Flow', () => {
// Step 4: Uninstall API called and success toast shown
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith('app-1')
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.api.remove',
title: 'common.api.remove',
}))
})
})

View File

@@ -88,7 +88,6 @@ describe('scroll-area wrapper', () => {
'hover:opacity-100',
'data-[orientation=vertical]:absolute',
'data-[orientation=vertical]:inset-y-0',
'data-[orientation=vertical]:right-0',
'data-[orientation=vertical]:w-3',
'data-[orientation=vertical]:justify-center',
)
@@ -129,7 +128,6 @@ describe('scroll-area wrapper', () => {
'hover:opacity-100',
'data-[orientation=horizontal]:absolute',
'data-[orientation=horizontal]:inset-x-0',
'data-[orientation=horizontal]:bottom-0',
'data-[orientation=horizontal]:h-3',
'data-[orientation=horizontal]:items-center',
)
@@ -166,6 +164,24 @@ describe('scroll-area wrapper', () => {
)
})
})
it('should let callers control scrollbar inset spacing via margin-based className overrides', async () => {
renderScrollArea({
verticalScrollbarClassName: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:[margin-inline-end:-0.75rem]',
horizontalScrollbarClassName: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-2',
})
await waitFor(() => {
expect(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveClass(
'data-[orientation=vertical]:my-2',
'data-[orientation=vertical]:[margin-inline-end:-0.75rem]',
)
expect(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveClass(
'data-[orientation=horizontal]:mx-2',
'data-[orientation=horizontal]:mb-2',
)
})
})
})
describe('Corner', () => {

View File

@@ -18,7 +18,7 @@ const meta = {
layout: 'padded',
docs: {
description: {
component: 'Compound scroll container built on Base UI ScrollArea. These stories focus on panel-style compositions that already exist throughout Dify: dense sidebars, sticky list headers, multi-pane workbenches, horizontal rails, and overlay surfaces.',
component: 'Compound scroll container built on Base UI ScrollArea. These stories focus on panel-style compositions that already exist throughout Dify: dense sidebars, sticky list headers, multi-pane workbenches, horizontal rails, and overlay surfaces. Scrollbar placement should be adjusted by consumer spacing classes such as margin-based overrides instead of right/bottom positioning utilities.',
},
},
},
@@ -35,12 +35,12 @@ const titleClassName = 'text-text-primary system-sm-semibold'
const bodyClassName = 'text-text-secondary system-sm-regular'
const insetScrollAreaClassName = 'h-full p-1'
const insetViewportClassName = 'rounded-[20px] bg-components-panel-bg'
const insetScrollbarClassName = 'data-[orientation=vertical]:top-1 data-[orientation=vertical]:bottom-1 data-[orientation=vertical]:right-1 data-[orientation=horizontal]:bottom-1 data-[orientation=horizontal]:left-1 data-[orientation=horizontal]:right-1'
const insetScrollbarClassName = 'data-[orientation=vertical]:my-1 data-[orientation=vertical]:[margin-inline-end:0.25rem] data-[orientation=horizontal]:mx-1 data-[orientation=horizontal]:mb-1'
const storyButtonClassName = 'flex w-full items-center justify-between gap-3 rounded-xl border border-divider-subtle bg-components-panel-bg-alt px-3 py-2.5 text-left text-text-secondary transition-colors hover:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none'
const sidebarScrollAreaClassName = 'h-full pr-2'
const sidebarViewportClassName = 'overscroll-contain pr-2'
const sidebarContentClassName = 'space-y-0.5 pr-2'
const sidebarScrollbarClassName = 'data-[orientation=vertical]:right-0.5'
const sidebarScrollAreaClassName = 'h-full'
const sidebarViewportClassName = 'overscroll-contain'
const sidebarContentClassName = 'space-y-0.5'
const sidebarScrollbarClassName = 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:[margin-inline-end:-0.75rem]'
const appNavButtonClassName = 'group flex h-8 w-full items-center justify-between gap-3 rounded-lg px-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none'
const appNavMetaClassName = 'shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 text-text-quaternary system-2xs-medium-uppercase tracking-[0.08em]'

View File

@@ -15,8 +15,8 @@ export const scrollAreaScrollbarClassName = cn(
'pointer-events-none data-[hovering]:pointer-events-auto data-[hovering]:opacity-100',
'data-[scrolling]:pointer-events-auto data-[scrolling]:opacity-100',
'hover:pointer-events-auto hover:opacity-100',
'data-[orientation=vertical]:absolute data-[orientation=vertical]:inset-y-0 data-[orientation=vertical]:right-0 data-[orientation=vertical]:w-3 data-[orientation=vertical]:justify-center',
'data-[orientation=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:bottom-0 data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:items-center',
'data-[orientation=vertical]:absolute data-[orientation=vertical]:inset-y-0 data-[orientation=vertical]:w-3 data-[orientation=vertical]:justify-center',
'data-[orientation=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:items-center',
)
export const scrollAreaThumbClassName = cn(

View File

@@ -1,15 +1,19 @@
import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import { MediaType } from '@/hooks/use-breakpoints'
import { AppModeEnum } from '@/types/app'
import SideBar from '../index'
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockIsPending = false
let mockIsUninstallPending = false
let mockInstalledApps: InstalledApp[] = []
let mockMediaType: string = MediaType.pc
@@ -36,12 +40,22 @@ vi.mock('@/service/use-explore', () => ({
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
isPending: mockIsUninstallPending,
}),
useUpdateAppPinStatus: () => ({
mutateAsync: mockUpdatePinStatus,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
close: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
},
}))
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
id: overrides.id ?? 'app-123',
uninstallable: overrides.uninstallable ?? false,
@@ -67,9 +81,9 @@ describe('SideBar', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPending = false
mockIsUninstallPending = false
mockInstalledApps = []
mockMediaType = MediaType.pc
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('Rendering', () => {
@@ -84,6 +98,7 @@ describe('SideBar', () => {
renderSideBar()
expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toBeInTheDocument()
expect(screen.getByText('My App')).toBeInTheDocument()
})
@@ -135,9 +150,9 @@ describe('SideBar', () => {
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith('app-123')
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.api.remove',
title: 'common.api.remove',
}))
})
})
@@ -152,9 +167,9 @@ describe('SideBar', () => {
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true })
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.api.success',
title: 'common.api.success',
}))
})
})
@@ -187,6 +202,18 @@ describe('SideBar', () => {
expect(mockUninstall).not.toHaveBeenCalled()
})
})
it('should disable dialog actions while uninstall is pending', async () => {
mockInstalledApps = [createInstalledApp()]
mockIsUninstallPending = true
renderSideBar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
expect(screen.getByText('common.operation.cancel')).toBeDisabled()
expect(screen.getByText('common.operation.confirm')).toBeDisabled()
})
})
describe('Edge Cases', () => {

View File

@@ -3,17 +3,40 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@/app/components/base/ui/scroll-area'
import { toast } from '@/app/components/base/ui/toast'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
import { useSelectedLayoutSegments } from '@/next/navigation'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import Item from './app-nav-item'
import NoApps from './no-apps'
const expandedSidebarScrollAreaClassNames = {
root: 'h-full',
viewport: 'overscroll-contain',
content: 'space-y-0.5',
scrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:[margin-inline-end:-0.75rem]',
thumb: 'rounded-full',
} as const
const SideBar = () => {
const { t } = useTranslation()
const segments = useSelectedLayoutSegments()
@@ -21,7 +44,7 @@ const SideBar = () => {
const isDiscoverySelected = lastSegment === 'apps'
const { data, isPending } = useGetInstalledApps()
const installedApps = data?.installed_apps ?? []
const { mutateAsync: uninstallApp } = useUninstallApp()
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
const media = useBreakpoints()
@@ -36,23 +59,48 @@ const SideBar = () => {
const id = currId
await uninstallApp(id)
setShowConfirm(false)
Toast.notify({
toast.add({
type: 'success',
message: t('api.remove', { ns: 'common' }),
title: t('api.remove', { ns: 'common' }),
})
}
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
await updatePinStatus({ appId: id, isPinned })
Toast.notify({
toast.add({
type: 'success',
message: t('api.success', { ns: 'common' }),
title: t('api.success', { ns: 'common' }),
})
}
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
const shouldUseExpandedScrollArea = !isMobile && !isFold
const webAppsLabelId = React.useId()
const installedAppItems = installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => (
<React.Fragment key={id}>
<Item
isMobile={isMobile || isFold}
name={name}
icon_type={icon_type}
icon={icon}
icon_background={icon_background}
icon_url={icon_url}
id={id}
isSelected={lastSegment?.toLowerCase() === id}
isPinned={is_pinned}
togglePin={() => handleUpdatePinStatus(id, !is_pinned)}
uninstallable={uninstallable}
onDelete={(id) => {
setCurrId(id)
setShowConfirm(true)
}}
/>
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />}
</React.Fragment>
))
return (
<div className={cn('relative w-fit shrink-0 cursor-pointer px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}>
<div className={cn('flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}>
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
<Link
href="/explore/apps"
@@ -73,59 +121,69 @@ const SideBar = () => {
)}
{installedApps.length > 0 && (
<div className="mt-5">
{!isMobile && !isFold && <p className="mb-1.5 break-all pl-2 uppercase text-text-tertiary system-xs-medium-uppercase mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
<div
className="space-y-0.5 overflow-y-auto overflow-x-hidden"
style={{
height: 'calc(100vh - 250px)',
}}
>
{installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => (
<React.Fragment key={id}>
<Item
isMobile={isMobile || isFold}
name={name}
icon_type={icon_type}
icon={icon}
icon_background={icon_background}
icon_url={icon_url}
id={id}
isSelected={lastSegment?.toLowerCase() === id}
isPinned={is_pinned}
togglePin={() => handleUpdatePinStatus(id, !is_pinned)}
uninstallable={uninstallable}
onDelete={(id) => {
setCurrId(id)
setShowConfirm(true)
}}
/>
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />}
</React.Fragment>
))}
</div>
</div>
)}
{!isMobile && (
<div className="absolute bottom-3 left-3 flex size-8 cursor-pointer items-center justify-center text-text-tertiary" onClick={toggleIsFold}>
{isFold
? <span className="i-ri-expand-right-line" />
<div className="mt-5 flex min-h-0 flex-1 flex-col">
{!isMobile && !isFold && <p id={webAppsLabelId} className="mb-1.5 break-all pl-2 uppercase text-text-tertiary system-xs-medium-uppercase mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
{shouldUseExpandedScrollArea
? (
<div className="min-h-0 flex-1">
<ScrollArea className={expandedSidebarScrollAreaClassNames.root}>
<ScrollAreaViewport
aria-labelledby={webAppsLabelId}
className={expandedSidebarScrollAreaClassNames.viewport}
role="region"
>
<ScrollAreaContent className={expandedSidebarScrollAreaClassNames.content}>
{installedAppItems}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={expandedSidebarScrollAreaClassNames.scrollbar}>
<ScrollAreaThumb className={expandedSidebarScrollAreaClassNames.thumb} />
</ScrollAreaScrollbar>
</ScrollArea>
</div>
)
: (
<span className="i-ri-layout-left-2-line" />
<div
className="h-full min-h-0 flex-1 space-y-0.5 overflow-y-auto overflow-x-hidden"
>
{installedAppItems}
</div>
)}
</div>
)}
{showConfirm && (
<Confirm
title={t('sidebar.delete.title', { ns: 'explore' })}
content={t('sidebar.delete.content', { ns: 'explore' })}
isShow={showConfirm}
onConfirm={handleDelete}
onCancel={() => setShowConfirm(false)}
/>
{!isMobile && (
<div className="mt-auto flex pb-3 pt-3">
<div className="flex size-8 cursor-pointer items-center justify-center text-text-tertiary" onClick={toggleIsFold}>
{isFold
? <span className="i-ri-expand-right-line" />
: (
<span className="i-ri-layout-left-2-line" />
)}
</div>
</div>
)}
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch pb-4 pl-6 pr-6 pt-6">
<AlertDialogTitle className="w-full text-text-primary title-2xl-semi-bold">
{t('sidebar.delete.title', { ns: 'explore' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
{t('sidebar.delete.content', { ns: 'explore' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={isUninstalling}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isUninstalling} disabled={isUninstalling} onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -4336,11 +4336,6 @@
"count": 2
}
},
"app/components/explore/sidebar/index.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"app/components/explore/sidebar/no-apps/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3