diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx index 77f493ab18..f3d3128ccb 100644 --- a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -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 => ({ 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', })) }) }) diff --git a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx index 170a4771d4..2781a5844f 100644 --- a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx @@ -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', () => { diff --git a/web/app/components/base/ui/scroll-area/index.stories.tsx b/web/app/components/base/ui/scroll-area/index.stories.tsx index 17be6a352d..8eb655a151 100644 --- a/web/app/components/base/ui/scroll-area/index.stories.tsx +++ b/web/app/components/base/ui/scroll-area/index.stories.tsx @@ -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]' diff --git a/web/app/components/base/ui/scroll-area/index.tsx b/web/app/components/base/ui/scroll-area/index.tsx index 73197b7ee5..8e5d872576 100644 --- a/web/app/components/base/ui/scroll-area/index.tsx +++ b/web/app/components/base/ui/scroll-area/index.tsx @@ -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( diff --git a/web/app/components/explore/sidebar/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx index 26c065a10c..e29a12a17f 100644 --- a/web/app/components/explore/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx @@ -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 => ({ 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', () => { diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index 4b328bb46d..032430909d 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -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) => ( + + handleUpdatePinStatus(id, !is_pinned)} + uninstallable={uninstallable} + onDelete={(id) => { + setCurrId(id) + setShowConfirm(true) + }} + /> + {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && } + + )) + return ( -
+
{ )} {installedApps.length > 0 && ( -
- {!isMobile && !isFold &&

{t('sidebar.webApps', { ns: 'explore' })}

} -
- {installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => ( - - handleUpdatePinStatus(id, !is_pinned)} - uninstallable={uninstallable} - onDelete={(id) => { - setCurrId(id) - setShowConfirm(true) - }} - /> - {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && } - - ))} -
-
- )} - - {!isMobile && ( -
- {isFold - ? +
+ {!isMobile && !isFold &&

{t('sidebar.webApps', { ns: 'explore' })}

} + {shouldUseExpandedScrollArea + ? ( +
+ + + + {installedAppItems} + + + + + + +
+ ) : ( - +
+ {installedAppItems} +
)}
)} - {showConfirm && ( - setShowConfirm(false)} - /> + {!isMobile && ( +
+
+ {isFold + ? + : ( + + )} +
+
)} + + + +
+ + {t('sidebar.delete.title', { ns: 'explore' })} + + + {t('sidebar.delete.content', { ns: 'explore' })} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t('operation.confirm', { ns: 'common' })} + + +
+
) } diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 9270b7f88a..fdf2b43e92 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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