diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 11b33239789..156a7a82439 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1040,11 +1040,6 @@ "count": 2 } }, - "web/app/components/base/inline-delete-confirm/index.stories.tsx": { - "no-console": { - "count": 2 - } - }, "web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx": { "jsx-a11y/anchor-is-valid": { "count": 1 @@ -1487,14 +1482,6 @@ "count": 1 } }, - "web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 3 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 3 - } - }, "web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts": { "no-restricted-imports": { "count": 1 diff --git a/packages/dify-ui/src/context-menu/index.stories.tsx b/packages/dify-ui/src/context-menu/index.stories.tsx index f05adefb7d8..7c9b4f08bdc 100644 --- a/packages/dify-ui/src/context-menu/index.stories.tsx +++ b/packages/dify-ui/src/context-menu/index.stories.tsx @@ -6,8 +6,8 @@ import { ContextMenuCheckboxItemIndicator, ContextMenuContent, ContextMenuGroup, + ContextMenuGroupLabel, ContextMenuItem, - ContextMenuLabel, ContextMenuLinkItem, ContextMenuRadioGroup, ContextMenuRadioItem, @@ -88,13 +88,13 @@ export const WithGroupLabel: Story = { - Actions + Actions Rename Duplicate - Danger Zone + Danger Zone Delete diff --git a/packages/dify-ui/src/context-menu/index.tsx b/packages/dify-ui/src/context-menu/index.tsx index aa1200e6c68..6912391d562 100644 --- a/packages/dify-ui/src/context-menu/index.tsx +++ b/packages/dify-ui/src/context-menu/index.tsx @@ -253,9 +253,9 @@ function ContextMenuSubContent({ ) } -type ContextMenuLabelProps = BaseContextMenu.GroupLabel.Props +type ContextMenuGroupLabelProps = BaseContextMenu.GroupLabel.Props -function ContextMenuLabel({ className, ...props }: ContextMenuLabelProps) { +function ContextMenuGroupLabel({ className, ...props }: ContextMenuGroupLabelProps) { return ( cn(floatingGroupLabelClassName, resolveClassName(className, state))} @@ -281,8 +281,8 @@ export { ContextMenuCheckboxItemIndicator, ContextMenuContent, ContextMenuGroup, + ContextMenuGroupLabel, ContextMenuItem, - ContextMenuLabel, ContextMenuLinkItem, ContextMenuRadioGroup, ContextMenuRadioItem, @@ -299,9 +299,9 @@ export type { ContextMenuCheckboxItemIndicatorProps, ContextMenuCheckboxItemProps, ContextMenuContentProps, + ContextMenuGroupLabelProps, ContextMenuGroupProps, ContextMenuItemProps, - ContextMenuLabelProps, ContextMenuLinkItemProps, ContextMenuProps, ContextMenuRadioGroupProps, diff --git a/packages/dify-ui/src/dropdown-menu/__tests__/types.tsx b/packages/dify-ui/src/dropdown-menu/__tests__/types.tsx new file mode 100644 index 00000000000..d7eb1b5dbbe --- /dev/null +++ b/packages/dify-ui/src/dropdown-menu/__tests__/types.tsx @@ -0,0 +1,34 @@ +import type { DropdownMenuHandle } from '@langgenius/dify-ui/dropdown-menu' +import { + createDropdownMenuHandle, + DropdownMenu, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { expectTypeOf } from 'vite-plus/test' + +type Payload = { documentId: string } + +export function dropdownMenuTypeContracts() { + const handle = createDropdownMenuHandle() + expectTypeOf(handle).toEqualTypeOf>() + const trigger = ( + + Actions + + ) + const menu = ( + + {({ payload }) => { + expectTypeOf(payload).toEqualTypeOf() + return {payload?.documentId} + }} + + ) + const invalid = ( + // @ts-expect-error Detached triggers preserve the handle payload contract. + + Actions + + ) + return { trigger, menu, invalid } +} diff --git a/packages/dify-ui/src/dropdown-menu/index.stories.tsx b/packages/dify-ui/src/dropdown-menu/index.stories.tsx index 8319aaf2bd5..1b26ed18300 100644 --- a/packages/dify-ui/src/dropdown-menu/index.stories.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.stories.tsx @@ -1,13 +1,15 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' +import { expect, userEvent, within } from 'storybook/test' import { + createDropdownMenuHandle, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuCheckboxItemIndicator, DropdownMenuContent, DropdownMenuGroup, + DropdownMenuGroupLabel, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuLinkItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, @@ -88,13 +90,13 @@ export const WithGroupLabel: Story = { - Actions + Actions Edit Duplicate - Export + Export Export as PDF Export as CSV @@ -276,7 +278,7 @@ function ComplexDemo() { - Edit + Edit Rename @@ -313,7 +315,7 @@ function ComplexDemo() { - Sort by + Sort by value={sortOrder} onValueChange={setSortOrder}> value="newest"> Newest first @@ -348,3 +350,51 @@ function ComplexDemo() { export const Complex: Story = { render: () => , } + +function DetachedTriggerDemo() { + const handle = React.useMemo(() => createDropdownMenuHandle<{ name: string }>(), []) + const [selected, setSelected] = React.useState('') + return ( + + + Report actions + + + {({ payload }) => ( + + + {payload?.name} + setSelected(payload?.name ?? '')}> + Archive + + + + )} + + {selected} + + ) +} + +export const DetachedTrigger: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(canvasElement.ownerDocument.body) + const trigger = canvas.getByRole('button', { name: 'Report actions' }) + trigger.focus() + await userEvent.keyboard('{ArrowDown}') + const archive = await body.findByRole('menuitem', { name: 'Archive' }) + await expect(archive).toHaveFocus() + await userEvent.keyboard('{Enter}') + await expect(canvas.getByRole('status', { name: 'Archived document' })).toHaveTextContent( + 'Report', + ) + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + await expect(trigger).toHaveFocus() + }, +} diff --git a/packages/dify-ui/src/dropdown-menu/index.tsx b/packages/dify-ui/src/dropdown-menu/index.tsx index 09d4b1e7443..06853ad486a 100644 --- a/packages/dify-ui/src/dropdown-menu/index.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.tsx @@ -23,6 +23,9 @@ const DropdownMenuPortal = Menu.Portal const DropdownMenuTrigger = Menu.Trigger const DropdownMenuSub = Menu.SubmenuRoot const DropdownMenuGroup = Menu.Group +const createDropdownMenuHandle = Menu.createHandle + +type DropdownMenuHandle = Menu.Handle type DropdownMenuProps = Menu.Root.Props type DropdownMenuTriggerProps = Menu.Trigger.Props @@ -104,9 +107,9 @@ function DropdownMenuCheckboxItemIndicator({ type DropdownMenuCheckboxItemIndicatorProps = Omit -type DropdownMenuLabelProps = Menu.GroupLabel.Props +type DropdownMenuGroupLabelProps = Menu.GroupLabel.Props -function DropdownMenuLabel({ className, ...props }: DropdownMenuLabelProps) { +function DropdownMenuGroupLabel({ className, ...props }: DropdownMenuGroupLabelProps) { return ( cn(floatingGroupLabelClassName, resolveClassName(className, state))} @@ -293,13 +296,14 @@ function DropdownMenuSeparator({ className, ...props }: DropdownMenuSeparatorPro } export { + createDropdownMenuHandle, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuCheckboxItemIndicator, DropdownMenuContent, DropdownMenuGroup, + DropdownMenuGroupLabel, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuLinkItem, DropdownMenuPopup, DropdownMenuPortal, @@ -318,9 +322,10 @@ export type { DropdownMenuCheckboxItemIndicatorProps, DropdownMenuCheckboxItemProps, DropdownMenuContentProps, + DropdownMenuGroupLabelProps, DropdownMenuGroupProps, + DropdownMenuHandle, DropdownMenuItemProps, - DropdownMenuLabelProps, DropdownMenuLinkItemProps, DropdownMenuPopupProps, DropdownMenuPortalProps, diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index ca3f6462d70..e02eece79f7 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import type { DataSet } from '@/models/datasets' -import { createEvent, fireEvent, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' @@ -8,8 +8,6 @@ import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import DatasetInfo from '..' import Dropdown from '../dropdown' -import Menu from '../menu' -import MenuItem from '../menu-item' let mockDataset: DataSet const mockPush = vi.fn() @@ -19,7 +17,6 @@ const mockInvalidDatasetDetail = vi.fn() const mockExportPipeline = vi.fn() const mockCheckIsUsedInApp = vi.fn() const mockDeleteDataset = vi.fn() -const TestEditIcon = () => let mockIsRbacEnabled = true const mockConsoleState = vi.hoisted(() => ({ current: { @@ -222,191 +219,6 @@ describe('DatasetInfo', () => { }) }) -describe('MenuItem', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Event handling for menu item interactions. - describe('Interactions', () => { - it('should call handler when clicked', async () => { - const user = userEvent.setup() - const handleClick = vi.fn() - // Arrange - render() - - // Act - await user.click(screen.getByRole('button', { name: 'Edit' })) - - // Assert - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it.each([ - ['Enter', '{Enter}'], - ['Space', ' '], - ])('should be reachable and activate with %s', async (_, key) => { - const user = userEvent.setup() - const handleClick = vi.fn() - render() - - await user.tab() - expect(screen.getByRole('button', { name: 'Edit' })).toHaveFocus() - - await user.keyboard(key) - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it('should stop propagation before invoking the handler', () => { - const handleClick = vi.fn() - render() - - const menuItem = screen.getByRole('button', { name: 'Edit' }) - const event = createEvent.click(menuItem) - const stopPropagation = vi.spyOn(event, 'stopPropagation') - - fireEvent(menuItem, event) - - expect(handleClick).toHaveBeenCalledTimes(1) - expect(stopPropagation).toHaveBeenCalledTimes(1) - }) - - it('should prevent the default action when no click handler is provided', () => { - render() - - const menuItem = screen.getByRole('button', { name: 'Edit' }) - const event = createEvent.click(menuItem) - fireEvent(menuItem, event) - - expect(event.defaultPrevented).toBe(true) - }) - }) -}) - -describe('Menu', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDataset = createDataset() - }) - - // Rendering of menu options based on runtime mode and delete visibility. - describe('Rendering', () => { - it('should show edit, export, and delete options when rag pipeline and deletable', () => { - // Arrange - mockDataset = createDataset({ runtime_mode: 'rag_pipeline' }) - render( - , - ) - - // Assert - expect(screen.getByText('common.operation.edit')).toBeInTheDocument() - expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument() - expect(screen.getByText('common.operation.delete')).toBeInTheDocument() - }) - - it('should show resource access option when enabled', () => { - render( - , - ) - - expect(screen.getByText('navigation.settings.resourceAccess')).toBeInTheDocument() - }) - - it('should hide export and delete options when not rag pipeline and not deletable', () => { - // Arrange - mockDataset = createDataset({ runtime_mode: 'general' }) - render( - , - ) - - // Assert - expect(screen.getByText('common.operation.edit')).toBeInTheDocument() - expect( - screen.queryByText('datasetPipeline.operations.exportPipeline'), - ).not.toBeInTheDocument() - expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() - }) - }) - - describe('Interactions', () => { - it('should invoke the rename callback when edit is clicked', async () => { - const user = userEvent.setup() - const openRenameModal = vi.fn() - - render( - , - ) - - await user.click(screen.getByText('common.operation.edit')) - - expect(openRenameModal).toHaveBeenCalledTimes(1) - }) - - it('should invoke export and delete callbacks from their menu items', async () => { - const user = userEvent.setup() - const handleExportPipeline = vi.fn() - const detectIsUsedByApp = vi.fn() - - render( - , - ) - - await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) - await user.click(screen.getByText('common.operation.delete')) - - expect(handleExportPipeline).toHaveBeenCalledTimes(1) - expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) - }) - - it('should invoke access config callback from its menu item', async () => { - const user = userEvent.setup() - const openAccessConfig = vi.fn() - - render( - , - ) - - await user.click(screen.getByText('navigation.settings.resourceAccess')) - - expect(openAccessConfig).toHaveBeenCalledTimes(1) - }) - }) -}) - describe('Dropdown', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 80b8f342411..945ba9c3221 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -11,9 +11,9 @@ import { import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, - DropdownMenuPopup, - DropdownMenuPortal, - DropdownMenuPositioner, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' @@ -35,7 +35,6 @@ import { useExportPipelineDSL } from '@/service/use-pipeline' import { downloadBlob } from '@/utils/download' import { getDatasetACLCapabilities } from '@/utils/permission' import RenameDatasetModal from '../../datasets/rename-modal' -import Menu from './menu' type DropDownProps = { expand: boolean @@ -63,7 +62,7 @@ const getErrorMessage = async (error: unknown) => { } const DropDown = ({ expand, triggerClassName }: DropDownProps) => { - const { t } = useTranslation(['app', 'common', 'dataset']) + const { t } = useTranslation(['app', 'common', 'dataset', 'datasetPipeline', 'navigation']) const { push, replace } = useRouter() const [open, setOpen] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false) @@ -185,22 +184,39 @@ const DropDown = ({ expand, triggerClassName }: DropDownProps) => { } /> - - - - - - - + + {datasetACLCapabilities.canEdit && ( + + + {t(($) => $['operation.edit'], { ns: 'common' })} + + )} + {datasetACLCapabilities.canImportExportDSL && dataset.runtime_mode === 'rag_pipeline' && ( + void handleExportPipeline()}> + + {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} + + )} + {datasetACLCapabilities.canAccessConfig && ( + + + {t(($) => $['settings.resourceAccess'], { ns: 'navigation' })} + + )} + {datasetACLCapabilities.canDelete && ( + <> + + + + {t(($) => $['operation.delete'], { ns: 'common' })} + + + )} + {showRenameModal && ( void -} - -const MenuItem = ({ Icon, name, handleClick }: MenuItemProps) => { - return ( - - ) -} - -export default React.memo(MenuItem) diff --git a/web/app/components/app-sidebar/dataset-info/menu.tsx b/web/app/components/app-sidebar/dataset-info/menu.tsx deleted file mode 100644 index a8e6fe83941..00000000000 --- a/web/app/components/app-sidebar/dataset-info/menu.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Separator } from '@langgenius/dify-ui/separator' -import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine, RiLock2Line } from '@remixicon/react' -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import MenuItem from './menu-item' - -type MenuProps = { - showEdit?: boolean - showDelete: boolean - showExportPipeline?: boolean - showAccessConfig?: boolean - openRenameModal: () => void - handleExportPipeline: () => void - detectIsUsedByApp: () => void - openAccessConfig?: () => void -} - -const Menu = ({ - showEdit = true, - showDelete, - showExportPipeline = true, - showAccessConfig = false, - openRenameModal, - handleExportPipeline, - detectIsUsedByApp, - openAccessConfig, -}: MenuProps) => { - const { t } = useTranslation(['common', 'datasetPipeline', 'navigation']) - const runtimeMode = useDatasetDetailContextWithSelector((state) => state.dataset?.runtime_mode) - - return ( -
-
- {showEdit && ( - $['operation.edit'], { ns: 'common' })} - handleClick={openRenameModal} - /> - )} - {showExportPipeline && runtimeMode === 'rag_pipeline' && ( - $['operations.exportPipeline'], { ns: 'datasetPipeline' })} - handleClick={handleExportPipeline} - /> - )} - {showAccessConfig && ( - $['settings.resourceAccess'], { ns: 'navigation' })} - handleClick={openAccessConfig} - /> - )} -
- {showDelete && ( - <> - -
- $['operation.delete'], { ns: 'common' })} - handleClick={detectIsUsedByApp} - /> -
- - )} -
- ) -} - -export default React.memo(Menu) diff --git a/web/app/components/app/app-publisher/environment-tabs/environment-menu-item.tsx b/web/app/components/app/app-publisher/environment-tabs/environment-menu-item.tsx deleted file mode 100644 index d15c9c582fe..00000000000 --- a/web/app/components/app/app-publisher/environment-tabs/environment-menu-item.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { PublisherEnvironment } from './types' -import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' - -export function EnvironmentMenuItem({ - environment, - onClick, -}: { - environment: PublisherEnvironment - onClick: () => void -}) { - return ( - - - - {environment.name} - - - ) -} diff --git a/web/app/components/app/app-publisher/environment-tabs/index.tsx b/web/app/components/app/app-publisher/environment-tabs/index.tsx index d24de41f2ac..3bb90463529 100644 --- a/web/app/components/app/app-publisher/environment-tabs/index.tsx +++ b/web/app/components/app/app-publisher/environment-tabs/index.tsx @@ -1,12 +1,13 @@ 'use client' -import type { PublisherEnvironmentTabsProps } from './types' +import type { PublisherEnvironment, PublisherEnvironmentTabsProps } from './types' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, - DropdownMenuLabel, + DropdownMenuGroupLabel, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' @@ -14,7 +15,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too import { useTranslation } from 'react-i18next' import { BUILT_IN_ENVIRONMENT_ID } from '../state' import { EnvironmentButton } from './environment-button' -import { EnvironmentMenuItem } from './environment-menu-item' import { ENVIRONMENT_TAB_HORIZONTAL_PADDING, ENVIRONMENT_TAB_MAX_WIDTH, @@ -25,6 +25,23 @@ import { import { EnvironmentTabsMeasurementProbe } from './measurement-probe' import { useEnvironmentTabMeasurements } from './use-measurements' +function EnvironmentMenuItem({ + environment, + onClick, +}: { + environment: PublisherEnvironment + onClick: () => void +}) { + return ( + + + + {environment.name} + + + ) +} + export function PublisherEnvironmentTabs({ environments, joinedEnvironmentIds, @@ -150,11 +167,7 @@ export function PublisherEnvironmentTabs({ } /> - + {overflowEnvironments.length > 0 && ( {overflowEnvironments.map((environment) => ( @@ -167,13 +180,13 @@ export function PublisherEnvironmentTabs({ )} {overflowEnvironments.length > 0 && undeployedEnvironments.length > 0 && ( - + )} {undeployedEnvironments.length > 0 && ( - + {t(($) => $['card.notDeployed'], { ns: 'deployments' })} - + {undeployedEnvironments.map((environment) => ( void } -const VersionSelector: React.FC = ({ versionLen, value, onChange }) => { +function VersionSelector({ versionLen, value, onChange }: VersionSelectorProps) { const { t } = useTranslation(['appGeneration']) const moreThanOneVersion = versionLen > 1 const versions = Array.from({ length: versionLen }, (_, index) => ({ @@ -41,29 +43,27 @@ const VersionSelector: React.FC = ({ versionLen, value, on placement="bottom-start" sideOffset={4} alignOffset={-12} - className="w-52 rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1" + className="w-52 p-1" > -
- {t(($) => $['generate.versions'], { ns: 'appGeneration' })} -
{ onChange(nextValue) }} > + + {t(($) => $['generate.versions'], { ns: 'appGeneration' })} + {versions.map((option) => ( -
{option.label}
- {value === option.value && ( - - )} +
{option.label}
+
))}
diff --git a/web/app/components/app/create-app-dropdown.tsx b/web/app/components/app/create-app-dropdown.tsx index f2ed39f4a30..40ebc6d640e 100644 --- a/web/app/components/app/create-app-dropdown.tsx +++ b/web/app/components/app/create-app-dropdown.tsx @@ -1,11 +1,12 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { useTranslation } from 'react-i18next' @@ -63,10 +64,10 @@ export function CreateAppDropdown({ })} > {(onCreateBlank || onCreateTemplate) && ( -
+ {onCreateBlank && ( )} -
+
)} {onImportDSL && (onCreateBlank || onCreateTemplate) && ( -
+ )} {onImportDSL && ( -
- + + @@ -118,7 +113,7 @@ export function CreateAppDropdown({ -
+ )} diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx index 789658bcad9..83c7d07f793 100644 --- a/web/app/components/app/deploy/__tests__/index.spec.tsx +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -1210,7 +1210,7 @@ describe('AppDeploy', () => { const menu = await screen.findByRole('menu') expect(within(menu).getByRole('alert')).toHaveTextContent('deployments.common.loadFailed') - await user.click(within(menu).getByRole('button', { name: 'common.operation.retry' })) + await user.click(within(menu).getByRole('menuitem', { name: 'common.operation.retry' })) expect(await screen.findByText('8 of 12 environments in use')).toBeInTheDocument() expect(requestCount).toBe(2) diff --git a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx index 437eb2b4a14..f8836788893 100644 --- a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx +++ b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx @@ -6,8 +6,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, + DropdownMenuGroupLabel, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { useAtomValue } from 'jotai' @@ -55,12 +55,12 @@ export function EnvironmentDeployMenu({ aria-busy={isLoading || isRetrying} placement="bottom-end" sideOffset={4} - className="w-42 rounded-xl p-1" + className="w-42 p-1" > - + {t(($) => $['card.notDeployed'])} - + {isLoading ? ( ) : isError ? ( @@ -68,17 +68,21 @@ export function EnvironmentDeployMenu({

{t(($) => $['common.loadFailed'])}

- + {isRetrying && ( + + )} +
) : undeployedEnvironments.length === 0 ? (

@@ -90,7 +94,7 @@ export function EnvironmentDeployMenu({ undeployedEnvironments.map((environment) => ( onSelectEnvironment(environment)} > ({ - useTheme: () => ({ theme: 'system', setTheme }), -})) - -describe('ThemeSwitcher', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it.each([ - ['System theme', 'system'], - ['Light theme', 'light'], - ['Dark theme', 'dark'], - ])('selects %s', async (name, theme) => { - const user = userEvent.setup() - render() - - await user.click(screen.getByRole('button', { name })) - - expect(setTheme).toHaveBeenCalledWith(theme) - }) -}) diff --git a/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx b/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx deleted file mode 100644 index 691529c6912..00000000000 --- a/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { cleanup, fireEvent, render } from '@testing-library/react' -import * as React from 'react' -import { createReactI18nextMock } from '@/test/i18n-mock' -import InlineDeleteConfirm from '../index' - -// Mock react-i18next with custom translations for test assertions -vi.mock('react-i18next', () => - createReactI18nextMock({ - 'operation.deleteConfirmTitle': 'Delete?', - 'operation.yes': 'Yes', - 'operation.no': 'No', - 'operation.confirmAction': 'Please confirm your action.', - }), -) - -afterEach(cleanup) - -describe('InlineDeleteConfirm', () => { - describe('Rendering', () => { - it('should render with default text', () => { - const onConfirm = vi.fn() - const onCancel = vi.fn() - const { getByText } = render( - , - ) - - expect(getByText('Delete?')).toBeInTheDocument() - expect(getByText('No')).toBeInTheDocument() - expect(getByText('Yes')).toBeInTheDocument() - }) - - it('should render with custom text', () => { - const onConfirm = vi.fn() - const onCancel = vi.fn() - const { getByText } = render( - , - ) - - expect(getByText('Remove?')).toBeInTheDocument() - expect(getByText('Cancel')).toBeInTheDocument() - expect(getByText('Confirm')).toBeInTheDocument() - }) - - it('should expose the prompt title and description on a semantic group', () => { - const onConfirm = vi.fn() - const onCancel = vi.fn() - const { getByRole } = render( - , - ) - - const prompt = getByRole('group', { name: 'Delete?' }) - expect(prompt).toHaveAccessibleDescription('Please confirm your action.') - }) - }) - - describe('Button interactions', () => { - it('should call onCancel when cancel button is clicked', () => { - const onConfirm = vi.fn() - const onCancel = vi.fn() - const { getByText } = render( - , - ) - - fireEvent.click(getByText('No')) - expect(onCancel).toHaveBeenCalledTimes(1) - expect(onConfirm).not.toHaveBeenCalled() - }) - - it('should call onConfirm when confirm button is clicked', () => { - const onConfirm = vi.fn() - const onCancel = vi.fn() - const { getByText } = render( - , - ) - - fireEvent.click(getByText('Yes')) - expect(onConfirm).toHaveBeenCalledTimes(1) - expect(onCancel).not.toHaveBeenCalled() - }) - }) -}) diff --git a/web/app/components/base/inline-delete-confirm/index.stories.tsx b/web/app/components/base/inline-delete-confirm/index.stories.tsx deleted file mode 100644 index 66d8cbde70e..00000000000 --- a/web/app/components/base/inline-delete-confirm/index.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useState } from 'react' -import { fn } from 'storybook/test' -import InlineDeleteConfirm from '.' - -const meta = { - title: 'Base/Feedback/InlineDeleteConfirm', - component: InlineDeleteConfirm, - parameters: { - layout: 'centered', - docs: { - description: { - component: - 'Compact confirmation prompt that appears inline, commonly used near delete buttons or destructive controls.', - }, - }, - }, - argTypes: { - variant: { - control: 'select', - options: ['delete', 'warning', 'info'], - }, - }, - args: { - title: 'Delete this item?', - confirmText: 'Delete', - cancelText: 'Cancel', - onConfirm: fn(), - onCancel: fn(), - }, - tags: ['autodocs'], -} satisfies Meta - -export default meta -type Story = StoryObj - -const InlineDeleteConfirmDemo = (args: Story['args']) => { - const [visible, setVisible] = useState(true) - - return ( -

- - {visible && ( - { - console.log('✅ Confirm clicked') - setVisible(false) - }} - onCancel={() => { - console.log('❎ Cancel clicked') - setVisible(false) - }} - /> - )} -
- ) -} - -export const Playground: Story = { - render: (args) => , -} - -export const WarningVariant: Story = { - render: (args) => , - args: { - variant: 'warning', - title: 'Archive conversation?', - confirmText: 'Archive', - cancelText: 'Keep', - }, -} - -export const InfoVariant: Story = { - render: (args) => , - args: { - variant: 'info', - title: 'Remove collaborator?', - confirmText: 'Remove', - cancelText: 'Keep', - }, -} diff --git a/web/app/components/base/inline-delete-confirm/index.tsx b/web/app/components/base/inline-delete-confirm/index.tsx deleted file mode 100644 index efde27c8f80..00000000000 --- a/web/app/components/base/inline-delete-confirm/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'use client' -import type { FC } from 'react' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { useId } from 'react' -import { useTranslation } from 'react-i18next' - -type InlineDeleteConfirmProps = { - title?: string - confirmText?: string - cancelText?: string - onConfirm: () => void - onCancel: () => void - className?: string - variant?: 'delete' | 'warning' | 'info' -} - -const InlineDeleteConfirm: FC = ({ - title, - confirmText, - cancelText, - onConfirm, - onCancel, - className, - variant = 'delete', -}) => { - const { t } = useTranslation(['common']) - const titleId = useId() - const descriptionId = useId() - - const titleText = - title || t(($) => $['operation.deleteConfirmTitle'], { ns: 'common', defaultValue: 'Delete?' }) - const confirmTxt = - confirmText || t(($) => $['operation.yes'], { ns: 'common', defaultValue: 'Yes' }) - const cancelTxt = cancelText || t(($) => $['operation.no'], { ns: 'common', defaultValue: 'No' }) - - return ( -
-
- {titleText} -
- -
- - -
- - - {t(($) => $['operation.confirmAction'], { - ns: 'common', - defaultValue: 'Please confirm your action.', - })} - -
- ) -} - -InlineDeleteConfirm.displayName = 'InlineDeleteConfirm' - -export default InlineDeleteConfirm diff --git a/web/app/components/base/sort/index.tsx b/web/app/components/base/sort/index.tsx index 31a2284a253..404f1a9fbce 100644 --- a/web/app/components/base/sort/index.tsx +++ b/web/app/components/base/sort/index.tsx @@ -4,6 +4,7 @@ import { DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { useMemo } from 'react' @@ -29,7 +30,7 @@ function Sort({ order, value, items, onSelect }: Props) { }, [items, value]) return ( -
+
@@ -54,7 +55,7 @@ function Sort({ order, value, items, onSelect }: Props) {
{item.name}
- {value === item.value && ( - - )} + ))}
@@ -89,7 +85,7 @@ function Sort({ order, value, items, onSelect }: Props) { -
- -
- -
- ) -} diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx index 11bd8068214..18899094264 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx @@ -3,14 +3,13 @@ import { cn } from '@langgenius/dify-ui/cn' import { DialogTrigger } from '@langgenius/dify-ui/dialog' import { DropdownMenu, - DropdownMenuPopup, - DropdownMenuPortal, - DropdownMenuPositioner, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Operations from './operations' type ActionsProps = { onApplyTemplate: () => void @@ -28,16 +27,9 @@ const Actions = ({ handleDelete, }: ActionsProps) => { const { t } = useTranslation(['common', 'datasetPipeline']) - const [isMoreOperationsOpen, setIsMoreOperationsOpen] = React.useState(false) return ( -
+
diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx deleted file mode 100644 index f2c8c0ebf37..00000000000 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Separator } from '@langgenius/dify-ui/separator' -import * as React from 'react' -import { useTranslation } from 'react-i18next' - -type OperationsProps = { - openEditModal: () => void - onDelete: () => void - onExport: () => void - onClose?: () => void -} - -const Operations = ({ openEditModal, onDelete, onExport, onClose }: OperationsProps) => { - const { t } = useTranslation(['common', 'datasetPipeline']) - - const onClickEdit = (e: React.MouseEvent) => { - e.stopPropagation() - e.preventDefault() - onClose?.() - openEditModal() - } - - const onClickExport = (e: React.MouseEvent) => { - e.stopPropagation() - e.preventDefault() - onClose?.() - onExport() - } - - const onClickDelete = (e: React.MouseEvent) => { - e.stopPropagation() - e.preventDefault() - onClose?.() - onDelete() - } - - return ( -
-
-
- - {t(($) => $['operations.editInfo'], { ns: 'datasetPipeline' })} - -
-
- - {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} - -
-
- -
-
- - {t(($) => $['operation.delete'], { ns: 'common' })} - -
-
-
- ) -} - -export default React.memo(Operations) diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index 94c09896ed3..3dbcc1c9f51 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -14,6 +14,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' @@ -296,8 +297,8 @@ const Operations = ({ }, [canDownload, closeOperationsMenu, handleDownload], ) - const menuActionClassName = cn(s.actionItem, 'border-none bg-transparent') - const menuDeleteActionClassName = cn(menuActionClassName, s.deleteActionItem, 'group') + const menuActionClassName = 'h-9 w-[calc(100%-8px)] gap-2 px-3 py-2 text-left' + const menuDeleteActionClassName = cn(menuActionClassName, 'group') const handleSettingsClick = useCallback( (evt: React.MouseEvent) => { evt.preventDefault() @@ -311,16 +312,12 @@ const Operations = ({ [canViewSettings, closeOperationsMenu, datasetId, detail.id, router], ) const settingsMenuItem = ( - + ) const enabledLabel = `${t(($) => $['list.status.enabled'], { ns: 'datasetDocuments' })}: ${name}` const renderListSwitch = () => { @@ -417,141 +414,119 @@ const Operations = ({ -
- {canShowPrimarySection && ( - <> - {canShowRenameAction && ( - - )} - {canShowSummaryAction && ( - - )} - {canShowSettingsAction && settingsMenuItem} - {canShowDownloadAction && ( - - )} - {canShowSyncAction && ( - - )} - {(canShowStatusSection || canShowDeleteAction) && ( - - )} - - )} - {canShowPauseAction && ( - - )} - {canShowResumeAction && ( - - )} - {canShowArchiveAction && ( - - )} - {canShowUnarchiveAction && ( - - )} - {canShowDeleteAction && ( - - )} -
+ {canShowPrimarySection && ( + <> + {canShowRenameAction && ( + + + + {t(($) => $['list.table.rename'], { ns: 'datasetDocuments' })} + + + )} + {canShowSummaryAction && ( + handleMenuOperation('summary')} + > + + + {t(($) => $['list.action.summary'], { ns: 'datasetDocuments' })} + + + )} + {canShowSettingsAction && settingsMenuItem} + {canShowDownloadAction && ( + + + + {t(($) => $['list.action.download'], { ns: 'datasetDocuments' })} + + + )} + {canShowSyncAction && ( + handleMenuOperation('sync')} + > + + + {t(($) => $['list.action.sync'], { ns: 'datasetDocuments' })} + + + )} + {(canShowStatusSection || canShowDeleteAction) && ( + + )} + + )} + {canShowPauseAction && ( + handleMenuOperation('pause')} + > + + + {t(($) => $['list.action.pause'], { ns: 'datasetDocuments' })} + + + )} + {canShowResumeAction && ( + handleMenuOperation('resume')} + > + + + {t(($) => $['list.action.resume'], { ns: 'datasetDocuments' })} + + + )} + {canShowArchiveAction && ( + handleMenuOperation('archive')} + > + + + {t(($) => $['list.action.archive'], { ns: 'datasetDocuments' })} + + + )} + {canShowUnarchiveAction && ( + handleMenuOperation('un_archive')} + > + + + {t(($) => $['list.action.unarchive'], { ns: 'datasetDocuments' })} + + + )} + {canShowDeleteAction && ( + + + + {t(($) => $['list.action.delete'], { ns: 'datasetDocuments' })} + + + )}
diff --git a/web/app/components/datasets/documents/style.module.css b/web/app/components/datasets/documents/style.module.css index b5f7fd19e8b..535b380102c 100644 --- a/web/app/components/datasets/documents/style.module.css +++ b/web/app/components/datasets/documents/style.module.css @@ -16,15 +16,6 @@ .actionIconWrapperDetail { @apply !border-[0.5px] !border-components-button-secondary-border !bg-components-button-secondary-bg !p-2 shadow-xs shadow-shadow-shadow-3 hover:!border-components-button-secondary-border-hover hover:!bg-components-button-secondary-bg-hover; } -.actionItem { - @apply mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-left hover:bg-state-base-hover; -} -.deleteActionItem { - @apply hover:!bg-state-destructive-hover; -} -.actionName { - @apply text-sm text-text-secondary; -} .addFileBtn { @apply mt-4 w-fit border-[0.5px] border-components-button-secondary-border !text-[13px] font-medium; } diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx index c7f1bed0133..11f83943744 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -3,11 +3,14 @@ import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' +import { useTranslation } from 'react-i18next' import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown, @@ -16,7 +19,6 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { getDatasetACLCapabilities } from '@/utils/permission' -import Operations from '../operations' type OperationsDropdownProps = { dataset: DataSet @@ -43,6 +45,7 @@ const OperationsDropdown = ({ }) const open = operationsMenu.open const setOpen = operationsMenu.onOpenChange + const { t } = useTranslation(['common', 'datasetPipeline', 'navigation']) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.id, @@ -106,18 +109,33 @@ const OperationsDropdown = ({ className: 'min-w-[186px]', })} > - + {datasetACLCapabilities.canEdit && ( + + + {t(($) => $['operation.edit'], { ns: 'common' })} + + )} + {dataset.runtime_mode === 'rag_pipeline' && datasetACLCapabilities.canImportExportDSL && ( + handleExportPipeline()}> + + {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} + + )} + {datasetACLCapabilities.canAccessConfig && ( + + + {t(($) => $['settings.resourceAccess'], { ns: 'navigation' })} + + )} + {datasetACLCapabilities.canDelete && ( + <> + + + + {t(($) => $['operation.delete'], { ns: 'common' })} + + + )}
diff --git a/web/app/components/datasets/list/dataset-card/operations.tsx b/web/app/components/datasets/list/dataset-card/operations.tsx deleted file mode 100644 index 02e7e94afda..00000000000 --- a/web/app/components/datasets/list/dataset-card/operations.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { DropdownMenuItem, DropdownMenuSeparator } from '@langgenius/dify-ui/dropdown-menu' -import * as React from 'react' -import { useTranslation } from 'react-i18next' - -type OperationsProps = { - showEdit?: boolean - showDelete: boolean - showExportPipeline: boolean - showAccessConfig?: boolean - openRenameModal: () => void - handleExportPipeline: () => void - detectIsUsedByApp: () => void - openAccessConfig: () => void - onClose?: () => void -} - -const Operations = ({ - showEdit = true, - showDelete, - showExportPipeline, - showAccessConfig = false, - openRenameModal, - handleExportPipeline, - detectIsUsedByApp, - openAccessConfig, - onClose, -}: OperationsProps) => { - const { t } = useTranslation(['common', 'datasetPipeline', 'navigation']) - - const handleRename = () => { - onClose?.() - openRenameModal() - } - - const handleExport = () => { - onClose?.() - handleExportPipeline() - } - - const handleDelete = () => { - onClose?.() - detectIsUsedByApp() - } - - const handleAccessConfig = () => { - onClose?.() - openAccessConfig() - } - - return ( - <> - {showEdit && ( - - - {t(($) => $['operation.edit'], { ns: 'common' })} - - )} - {showExportPipeline && ( - - - {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })} - - )} - {showAccessConfig && ( - - - {t(($) => $['settings.resourceAccess'], { ns: 'navigation' })} - - )} - {showDelete && ( - <> - - - - {t(($) => $['operation.delete'], { ns: 'common' })} - - - )} - - ) -} - -export default React.memo(Operations) diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 81411c34163..a9ecc994bfa 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -1,27 +1,218 @@ 'use client' -import type { ReactElement } from 'react' +import type { ReactElement, ReactNode } from 'react' +import type { Theme } from '@/app/components/base/theme-selector' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useTheme } from 'next-themes' +import { useQueryState } from 'nuqs' import { useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' +import PremiumBadge from '@/app/components/base/premium-badge' +import { + settingsQueryParamName, + settingsQueryParser, +} from '@/app/components/header/account-setting/query-params' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import Link from '@/next/link' +import { consoleQuery } from '@/service/console' import { useLogout } from '@/service/use-common' -import { MainNavMenuContent } from './main-nav-menu-content' +import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content' type AccountDropdownProps = { trigger: (props: { ariaLabel: string }) => ReactElement } -const mainNavMenuPopupClassName = - 'w-60 max-w-80 overflow-hidden bg-components-panel-bg-blur! p-0! backdrop-blur-[5px]' +const mainNavMenuPopupClassName = 'w-60 max-w-80 overflow-hidden p-0' const subscribeHydrationState = () => () => {} const getHydrationSnapshot = () => false const getServerHydrationSnapshot = () => true +type MainNavRadioItemContentProps = { + iconClassName?: string + label: ReactNode +} + +function MainNavRadioItemContent({ iconClassName, label }: MainNavRadioItemContentProps) { + const labelTitle = typeof label === 'string' ? label : undefined + + return ( + <> + {iconClassName && ( + + )} + + {label} + + + + ) +} + +function AppearanceSubmenu() { + const { t } = useTranslation(['accountSettings']) + const { theme, setTheme } = useTheme() + const currentTheme: Theme = + theme === 'light' || theme === 'dark' || theme === 'system' ? theme : 'system' + + return ( + + + $['account.appearanceLabel'], { ns: 'accountSettings' })} + /> + + + + value={currentTheme} + onValueChange={(nextTheme) => setTheme(nextTheme)} + > + value="light" className="mx-0 py-1"> + $['account.appearanceLight'], { ns: 'accountSettings' })} + /> + + value="dark" className="mx-0 py-1"> + $['account.appearanceDark'], { ns: 'accountSettings' })} + /> + + value="system" className="mx-0 py-1"> + $['account.appearanceSystem'], { ns: 'accountSettings' })} + /> + + + + + ) +} + +type MainNavMenuContentProps = { + onLogout: () => void +} + +function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) { + const { t } = useTranslation(['common', 'accountSettings', 'navigation']) + const { data: userProfile } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile, + }) + const { data: enableEducationPlan } = useQuery( + consoleQuery.features.get.queryOptions({ + select: (features) => features.education.enabled, + }), + ) + const { data: isEducationAccount = false } = useQuery( + consoleQuery.account.education.get.queryOptions({ + enabled: enableEducationPlan === true, + select: ({ is_student }) => is_student ?? false, + }), + ) + const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) + + return ( + <> + +
+
+
+
+ {userProfile.name} +
+ {isEducationAccount && ( + + + EDU + + )} +
+
+ {userProfile.email} +
+
+ +
+
+ + } + > + $['account.account'], { ns: 'accountSettings' })} + trailing={} + /> + + setSettingsDestination('preferences')} + > + $['settings.preferences'], { ns: 'navigation' })} + /> + + + + + + { + onLogout() + }} + > + $['userProfile.logout'], { ns: 'common' })} + /> + + + + ) +} + export default function AccountDropdown({ trigger }: AccountDropdownProps) { const isHydrating = useSyncExternalStore( subscribeHydrationState, diff --git a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx deleted file mode 100644 index 974215d6a99..00000000000 --- a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx +++ /dev/null @@ -1,211 +0,0 @@ -'use client' - -import type { ReactNode } from 'react' -import type { Theme } from '@/app/components/base/theme-selector' -import { Avatar } from '@langgenius/dify-ui/avatar' -import { cn } from '@langgenius/dify-ui/cn' -import { - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLinkItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuRadioItemIndicator, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from '@langgenius/dify-ui/dropdown-menu' -import { useQuery, useSuspenseQuery } from '@tanstack/react-query' -import { useTheme } from 'next-themes' -import { useQueryState } from 'nuqs' -import { useTranslation } from 'react-i18next' -import PremiumBadge from '@/app/components/base/premium-badge' -import { - settingsQueryParamName, - settingsQueryParser, -} from '@/app/components/header/account-setting/query-params' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import Link from '@/next/link' -import { consoleQuery } from '@/service/console' -import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content' - -type MainNavRadioItemContentProps = { - iconClassName?: string - label: ReactNode -} - -function MainNavRadioItemContent({ iconClassName, label }: MainNavRadioItemContentProps) { - const labelTitle = typeof label === 'string' ? label : undefined - - return ( - <> - {iconClassName && ( - - )} - - {label} - - - - ) -} - -function AppearanceSubmenu() { - const { t } = useTranslation(['accountSettings']) - const { theme, setTheme } = useTheme() - const currentTheme: Theme = - theme === 'light' || theme === 'dark' || theme === 'system' ? theme : 'system' - - return ( - - - $['account.appearanceLabel'], { ns: 'accountSettings' })} - /> - - - - value={currentTheme} - onValueChange={(nextTheme) => setTheme(nextTheme)} - > - - value="light" - closeOnClick - className="mx-0 h-8 gap-1 px-2 py-1" - > - $['account.appearanceLight'], { ns: 'accountSettings' })} - /> - - - value="dark" - closeOnClick - className="mx-0 h-8 gap-1 px-2 py-1" - > - $['account.appearanceDark'], { ns: 'accountSettings' })} - /> - - - value="system" - closeOnClick - className="mx-0 h-8 gap-1 px-2 py-1" - > - $['account.appearanceSystem'], { ns: 'accountSettings' })} - /> - - - - - ) -} - -type MainNavMenuContentProps = { - onLogout: () => void -} - -export function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) { - const { t } = useTranslation(['common', 'accountSettings', 'navigation']) - const { data: userProfile } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile, - }) - const { data: enableEducationPlan } = useQuery( - consoleQuery.features.get.queryOptions({ - select: (features) => features.education.enabled, - }), - ) - const { data: isEducationAccount = false } = useQuery( - consoleQuery.account.education.get.queryOptions({ - enabled: enableEducationPlan === true, - select: ({ is_student }) => is_student ?? false, - }), - ) - const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) - - return ( - <> - -
-
-
-
- {userProfile.name} -
- {isEducationAccount && ( - - - EDU - - )} -
-
- {userProfile.email} -
-
- -
-
- - } - > - $['account.account'], { ns: 'accountSettings' })} - trailing={} - /> - - setSettingsDestination('preferences')} - > - $['settings.preferences'], { ns: 'navigation' })} - /> - - - - - - { - onLogout() - }} - > - $['userProfile.logout'], { ns: 'common' })} - /> - - - - ) -} diff --git a/web/app/components/integrations/sidebar-actions.tsx b/web/app/components/integrations/sidebar-actions.tsx index 7e18118963a..a29fbd85af8 100644 --- a/web/app/components/integrations/sidebar-actions.tsx +++ b/web/app/components/integrations/sidebar-actions.tsx @@ -105,20 +105,15 @@ function IntegrationSidebarInstallActions({ disabled={!canManagement} rootClassName="w-full" triggerVariant="primary" - triggerClassName="h-8 min-w-0 justify-start gap-2 px-2.5 py-2 system-sm-medium transition-[width]" + triggerClassName="h-8 min-w-0 justify-start gap-2 px-2.5 py-2 system-sm-medium transition-[width] data-popup-open:bg-components-button-primary-bg-hover" triggerLabel={installLabel} - triggerOpenClassName="bg-components-button-primary-bg-hover" popupClassName="w-[200px]" installContextCategory={installContextCategory} showTriggerArrow={false} onSwitchToMarketplaceTab={onSwitchToMarketplace} /> - actionRowRef.current} - dropdownPlacement="bottom-start" - /> + actionRowRef.current} placement="bottom-start" />
) } diff --git a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx index 64308a69659..19d02145244 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx @@ -67,13 +67,20 @@ describe('SortDropdown', () => { expect(within(content).getByText('First Released')).toBeInTheDocument() }) - it('shows a check icon for the currently selected option', async () => { + it('exposes the currently selected sort option', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Sort by Most Popular' })) - expect(document.querySelector('.i-ri-check-line')).toBeInTheDocument() + expect(screen.getByRole('menuitemradio', { name: 'Most Popular' })).toHaveAttribute( + 'aria-checked', + 'true', + ) + expect(screen.getByRole('menuitemradio', { name: 'Recently Updated' })).toHaveAttribute( + 'aria-checked', + 'false', + ) }) it('updates the sort and closes the menu when an option is selected', async () => { @@ -81,7 +88,7 @@ describe('SortDropdown', () => { render() await user.click(screen.getByRole('button', { name: 'Sort by Most Popular' })) - await user.click(await screen.findByRole('menuitem', { name: 'Recently Updated' })) + await user.click(await screen.findByRole('menuitemradio', { name: 'Recently Updated' })) expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy: 'version_updated_at', diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index e44e5b013e5..dfe428beecf 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -2,10 +2,11 @@ import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { useState } from 'react' import { useTranslation } from '#i18n' import { useMarketplaceSort } from '../atoms' @@ -34,36 +35,39 @@ const SortDropdown = () => { }, ] const [sort, handleSortChange] = useMarketplaceSort() - const [open, setOpen] = useState(false) const selectedOption = options.find((option) => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]! return ( - - - + + + {t(($) => $['marketplace.sortBy'], { ns: 'plugin' })} - {selectedOption.text} + {selectedOption.text} - - {options.map((option) => ( - { - handleSortChange({ sortBy: option.value, sortOrder: option.order }) - setOpen(false) - }} - > - {option.text} - {sort.sortBy === option.value && sort.sortOrder === option.order && ( - - )} - - ))} + + { + const option = options.find((option) => `${option.value}-${option.order}` === nextValue) + if (option) handleSortChange({ sortBy: option.value, sortOrder: option.order }) + }} + > + {options.map((option) => ( + + {option.text} + + + ))} + ) diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx index f8f9a2b6d28..6747dfd2f23 100644 --- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -81,7 +81,6 @@ describe('InstallPluginDropdown', () => { rootClassName="custom-root" triggerClassName="custom-trigger" triggerLabel="Install" - triggerOpenClassName="custom-open" triggerVariant="primary" popupClassName="custom-popup" />, @@ -98,7 +97,6 @@ describe('InstallPluginDropdown', () => { fireEvent.click(trigger) - expect(trigger).toHaveClass('custom-open') expect(trigger).toHaveAttribute('data-popup-open', '') expect(screen.getByRole('menu')).toHaveClass('custom-popup') }) diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index 0fbafb3bb65..874c6a18899 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -7,6 +7,8 @@ import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuGroupLabel, DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' @@ -32,7 +34,6 @@ type Props = Readonly<{ rootClassName?: string triggerClassName?: string triggerLabel?: string - triggerOpenClassName?: string triggerVariant?: ButtonProps['variant'] installContextCategory?: PluginCategoryEnum showTriggerArrow?: boolean @@ -51,7 +52,6 @@ const InstallPluginDropdown = ({ rootClassName, triggerClassName, triggerLabel, - triggerOpenClassName = 'bg-state-base-hover', triggerVariant, installContextCategory, showTriggerArrow = true, @@ -160,18 +160,12 @@ const InstallPluginDropdown = ({ accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS} /> ( + render={ - )} + } /> - - {t(($) => $.installFrom, { ns: 'plugin' })} - - {installMethods.map(({ icon: Icon, text, action }) => ( - handleInstallMethodSelect(action)} - > -
- - {text} -
-
- ))} + + + {t(($) => $.installFrom, { ns: 'plugin' })} + + {installMethods.map(({ icon: Icon, text, action }) => ( + handleInstallMethodSelect(action)}> +
+ + {text} +
+
+ ))} +
{selectedAction === 'github' && ( diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index 507d957798d..62299d05c68 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -590,7 +590,6 @@ describe('PluginTasks Component', () => { // Click to open fireEvent.click(getTaskMenuTrigger()) - // The popover content should be visible (PluginTaskList) // The popover content should be visible (PluginTaskList) expect(screen.getByTestId('plugin-task-list'))!.toBeInTheDocument() }) @@ -602,12 +601,12 @@ describe('PluginTasks Component', () => { render() const taskMenuTrigger = getTaskMenuTrigger() - expect(taskMenuTrigger).not.toHaveAttribute('data-menu-open') + expect(taskMenuTrigger).not.toHaveAttribute('data-popup-open') await user.click(taskMenuTrigger) expect(taskMenuTrigger).toHaveAttribute('aria-expanded', 'true') - expect(taskMenuTrigger).toHaveAttribute('data-menu-open', '') + expect(taskMenuTrigger).toHaveAttribute('data-popup-open', '') }) it('should keep the task menu trigger enabled when it can open', () => { @@ -618,10 +617,10 @@ describe('PluginTasks Component', () => { expect(getTaskMenuTrigger()).toHaveAttribute('aria-disabled', 'false') }) - it('should apply custom dropdown positioning props', () => { + it('should apply custom popover positioning props', () => { setupMocks([createMockPlugin({ status: TaskStatus.failed })]) - render( document.body} />) + render( document.body} />) fireEvent.click(getTaskMenuTrigger()) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx index 86a4decbd55..8b18e0311cf 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx @@ -1,4 +1,4 @@ -import type { FC, ReactNode } from 'react' +import type { ReactNode } from 'react' import type { PluginStatus } from '@/app/components/plugins/types' import type { PluginLanguage } from '@/i18n/metadata' import CardIcon from '@/app/components/plugins/card/base/card-icon' @@ -14,7 +14,7 @@ type PluginItemProps = { onClear?: () => void } -const PluginItem: FC = ({ +function PluginItem({ plugin, getIconUrl, language, @@ -23,7 +23,7 @@ const PluginItem: FC = ({ statusClassName, action, onClear, -}) => { +}: PluginItemProps) { const hasPluginIcon = !!plugin.icon const pluginName = plugin.labels[language] || plugin.plugin_unique_identifier @@ -55,7 +55,7 @@ const PluginItem: FC = ({ +
+ {}} + onReply={() => {}} + onReplyEdit={() => {}} + onReplyDeleteDirect={onDelete} + /> +
+ + + ) +} + +it('lets keyboard users request reply deletion and returns focus to reply actions when cancelled', async () => { + // Native Tab and CSS visibility expose regressions hidden by DOM-only tests. + await page.viewport(1200, 800) + const onDelete = vi.fn() + const screen = await render() + const replyInput = screen.getByRole('textbox') + await expect.element(replyInput).toHaveFocus() + await userEvent.tab({ shift: true }) + const trigger = screen.getByRole('button', { + name: 'workflowComments.comments.aria.replyActions', + }) + await expect.element(trigger).toHaveFocus() + expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true) + await userEvent.keyboard('{ArrowDown}') + const edit = screen.getByRole('menuitem', { name: 'workflowComments.comments.actions.editReply' }) + await expect.element(edit).toHaveFocus() + await userEvent.keyboard('{End}') + const deleteItem = screen.getByRole('menuitem', { + name: 'workflowComments.comments.actions.deleteReply', + }) + await expect.element(deleteItem).toHaveFocus() + await userEvent.keyboard('{Enter}') + const confirmation = screen.getByRole('alertdialog', { + name: 'workflowComments.comments.actions.deleteReply', + }) + await expect.element(confirmation).toBeVisible() + await expect.element(screen.getByRole('menu')).not.toBeInTheDocument() + expect(onDelete).not.toHaveBeenCalled() + await confirmation.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect.element(confirmation).not.toBeInTheDocument() + await expect.element(trigger).toHaveFocus() + expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true) +}) + +function createPendingRequest() { + let resolve!: () => void + const promise = new Promise((complete) => { + resolve = complete + }) + return { promise, resolve } +} + +it('keeps reply deletion pending through refetch and restores focus to the input after the reply disappears', async () => { + const deletion = createPendingRequest() + const refetch = createPendingRequest() + const onDelete = vi.fn() + + function DeletionFixture() { + const [detail, setDetail] = useState(comment) + const [loading, setLoading] = useState(false) + return ( + { + setLoading(true) + onDelete(id) + await deletion.promise + await refetch.promise + setDetail({ ...comment, replies: [] }) + setLoading(false) + }} + /> + ) + } + + const screen = await render() + const replyInput = screen.getByRole('textbox') + await expect.element(replyInput).toHaveFocus() + await userEvent.tab({ shift: true }) + await userEvent.keyboard('{ArrowDown}{End}{Enter}') + const confirmation = screen.getByRole('alertdialog') + const confirm = confirmation.getByRole('button', { name: 'common.operation.delete' }) + const cancel = confirmation.getByRole('button', { name: 'common.operation.cancel' }) + await confirm.click() + await expect.element(confirm).toBeDisabled() + await expect.element(cancel).toBeDisabled() + await userEvent.keyboard('{Enter}{Escape}') + await expect.element(confirmation).toBeVisible() + expect(onDelete).toHaveBeenCalledExactlyOnceWith('reply') + + deletion.resolve() + await expect.element(confirmation).toBeVisible() + await expect.element(confirm).toBeDisabled() + refetch.resolve() + await expect.element(confirmation).not.toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'workflowComments.comments.aria.replyActions' })) + .not.toBeInTheDocument() + await expect.element(replyInput).toHaveFocus() +}) diff --git a/web/app/components/workflow/comment/thread.spec.tsx b/web/app/components/workflow/comment/thread.spec.tsx index 478e11e51c9..c955415b3f4 100644 --- a/web/app/components/workflow/comment/thread.spec.tsx +++ b/web/app/components/workflow/comment/thread.spec.tsx @@ -57,14 +57,6 @@ vi.mock('@/app/components/workflow/collaboration/utils/user-color', () => ({ getUserColor: () => '#22c55e', })) -vi.mock('@/app/components/base/inline-delete-confirm', () => ({ - default: ({ onConfirm }: { onConfirm: () => void }) => ( - - ), -})) - vi.mock('./mention-input', () => ({ MentionInput: ({ placeholder, @@ -254,7 +246,7 @@ describe('CommentThread', () => { }) await user.click(screen.getByLabelText('workflowComments.comments.aria.replyActions')) await user.click(await screen.findByText('workflowComments.comments.actions.deleteReply')) - await user.click(screen.getByTestId('confirm-delete-reply')) + await user.click(await screen.findByRole('button', { name: 'common.operation.delete' })) expect(onReplyDeleteDirect).toHaveBeenCalledWith('reply-1') }) diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx index 53ec2235a89..bae41bb218e 100644 --- a/web/app/components/workflow/comment/thread.tsx +++ b/web/app/components/workflow/comment/thread.tsx @@ -1,15 +1,25 @@ 'use client' -import type { FC, ReactNode } from 'react' +import type { ReactNode } from 'react' import type { WorkflowCommentDetail, WorkflowCommentDetailReply, } from '@/app/components/workflow/comment/types' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { Separator } from '@langgenius/dify-ui/separator' @@ -27,7 +37,6 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow, useViewport } from 'reactflow' -import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm' import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' @@ -58,7 +67,7 @@ type CommentThreadProps = { onReplyDeleteDirect?: (replyId: string) => Promise | void } -const ThreadMessage: FC<{ +type ThreadMessageProps = { authorId: string authorName: string avatarUrl?: string | null @@ -66,7 +75,17 @@ const ThreadMessage: FC<{ content: string mentionableNames: string[] className?: string -}> = ({ authorId, authorName, avatarUrl, createdAt, content, mentionableNames, className }) => { +} + +function ThreadMessage({ + authorId, + authorName, + avatarUrl, + createdAt, + content, + mentionableNames, + className, +}: ThreadMessageProps) { const { formatTimeFromNow } = useFormatTimeFromNow() const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -159,664 +178,670 @@ const ThreadMessage: FC<{ ) } -export const CommentThread: FC = memo( - ({ - comment, - loading = false, - replySubmitting = false, - replyUpdating = false, - onClose, - onDelete, - onResolve, - onPrev, - onNext, - canGoPrev, - canGoNext, - onCommentEdit, - onReply, - onReplyEdit, - onReplyDelete, - onReplyDeleteDirect, - }) => { - const params = useParams() - const appId = params.appId as string - const { flowToScreenPosition } = useReactFlow() - const viewport = useViewport() - const { data: userProfile } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile, - }) - const currentUserId = userProfile.id - const { t } = useTranslation(['common', 'workflowComments']) - const [replyContent, setReplyContent] = useState('') - const [editingCommentContent, setEditingCommentContent] = useState('') - const [activeReplyMenuId, setActiveReplyMenuId] = useState(null) - const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({ - id: '', - content: '', - }) - const [deletingReplyId, setDeletingReplyId] = useState(null) - const [isCommentEditing, setIsCommentEditing] = useState(false) - const [isSubmittingEdit, setIsSubmittingEdit] = useState(false) +function CommentThreadComponent({ + comment, + loading = false, + replySubmitting = false, + replyUpdating = false, + onClose, + onDelete, + onResolve, + onPrev, + onNext, + canGoPrev, + canGoNext, + onCommentEdit, + onReply, + onReplyEdit, + onReplyDelete, + onReplyDeleteDirect, +}: CommentThreadProps) { + const params = useParams() + const appId = params.appId as string + const { flowToScreenPosition } = useReactFlow() + const viewport = useViewport() + const { data: userProfile } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile, + }) + const currentUserId = userProfile.id + const { t } = useTranslation(['common', 'workflowComments']) + const [replyContent, setReplyContent] = useState('') + const [editingCommentContent, setEditingCommentContent] = useState('') + const [activeReplyMenuId, setActiveReplyMenuId] = useState(null) + const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({ + id: '', + content: '', + }) + const [deletingReplyId, setDeletingReplyId] = useState(null) + const [isCommentEditing, setIsCommentEditing] = useState(false) + const [isSubmittingEdit, setIsSubmittingEdit] = useState(false) - // Focus management refs - const replyInputRef = useRef(null) + // Focus management refs + const replyInputRef = useRef(null) + const replyMenuTriggersRef = useRef(new Map()) + const deleteReplyTriggerRef = useRef(null) - // Get mentionable users from store - const mentionUsersFromStore = useStore((state) => - appId ? state.mentionableUsersCache[appId] : undefined, - ) - const mentionUsers = mentionUsersFromStore ?? [] - const setCommentPreviewHovering = useStore((state) => state.setCommentPreviewHovering) + // Get mentionable users from store + const mentionUsersFromStore = useStore((state) => + appId ? state.mentionableUsersCache[appId] : undefined, + ) + const mentionUsers = mentionUsersFromStore ?? [] + const setCommentPreviewHovering = useStore((state) => state.setCommentPreviewHovering) - // Extract all mentionable names for highlighting - const mentionableNames = useMemo(() => { - const names = mentionUsers - .map((user) => user.name?.trim()) - .filter((name): name is string => Boolean(name)) - return Array.from(new Set(names)) - }, [mentionUsers]) + // Extract all mentionable names for highlighting + const mentionableNames = useMemo(() => { + const names = mentionUsers + .map((user) => user.name?.trim()) + .filter((name): name is string => Boolean(name)) + return Array.from(new Set(names)) + }, [mentionUsers]) - useEffect(() => { - Promise.resolve().then(() => { - setReplyContent('') - setEditingCommentContent('') - setIsCommentEditing(false) - setEditingReply({ id: '', content: '' }) - setActiveReplyMenuId(null) - setDeletingReplyId(null) - }) - }, [comment.id]) - - useEffect( - () => () => { - setCommentPreviewHovering(false) - }, - [setCommentPreviewHovering], - ) - - const canReply = Boolean(onReply) - - // Focus on thread transitions, not callback changes after position updates. - useEffect(() => { - const timer = setTimeout(() => { - if (replyInputRef.current && !editingReply.id && !isCommentEditing && canReply) - replyInputRef.current.focus() - }, 100) - - return () => clearTimeout(timer) - }, [comment.id, editingReply.id, isCommentEditing, canReply]) - - const handleReplySubmit = useCallback( - async (content: string, mentionedUserIds: string[]) => { - if (!onReply || replySubmitting) return - - setReplyContent('') - - try { - await onReply(content, mentionedUserIds) - - // P0: Restore focus to reply input after successful submission - setTimeout(() => { - replyInputRef.current?.focus() - }, 0) - } catch (error) { - console.error('Failed to send reply', error) - setReplyContent(content) - } - }, - [onReply, replySubmitting], - ) - - const screenPosition = useMemo(() => { - return flowToScreenPosition({ - x: comment.position_x, - y: comment.position_y, - }) - }, [ - comment.position_x, - comment.position_y, - viewport.x, - viewport.y, - viewport.zoom, - flowToScreenPosition, - ]) - const workflowContainerRect = - typeof document !== 'undefined' - ? document.getElementById('workflow-container')?.getBoundingClientRect() - : null - const containerLeft = workflowContainerRect?.left ?? 0 - const containerTop = workflowContainerRect?.top ?? 0 - const canvasPosition = useMemo( - () => ({ - x: screenPosition.x - containerLeft, - y: screenPosition.y - containerTop, - }), - [screenPosition.x, screenPosition.y, containerLeft, containerTop], - ) - - const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => { - setEditingReply({ id: reply.id, content: reply.content }) - setIsCommentEditing(false) - setActiveReplyMenuId(null) - }, []) - - const handleStartCommentEdit = useCallback(() => { - setEditingCommentContent(comment.content) - setEditingReply({ id: '', content: '' }) - setIsCommentEditing(true) - setActiveReplyMenuId(null) - }, [comment.content]) - - const handleCancelEdit = useCallback(() => { - setEditingReply({ id: '', content: '' }) - - // P1: Restore focus to reply input after canceling edit - setTimeout(() => { - replyInputRef.current?.focus() - }, 0) - }, []) - - const handleCancelCommentEdit = useCallback(() => { + useEffect(() => { + Promise.resolve().then(() => { + setReplyContent('') setEditingCommentContent('') setIsCommentEditing(false) + setEditingReply({ id: '', content: '' }) + setActiveReplyMenuId(null) + setDeletingReplyId(null) + }) + }, [comment.id]) - setTimeout(() => { - replyInputRef.current?.focus() - }, 0) - }, []) + useEffect( + () => () => { + setCommentPreviewHovering(false) + }, + [setCommentPreviewHovering], + ) - const handleCommentEditSubmit = useCallback( - async (content: string, mentionedUserIds: string[]) => { - if (!onCommentEdit) return - const trimmed = content.trim() - if (!trimmed) return + const canReply = Boolean(onReply) - setIsSubmittingEdit(true) - try { - await onCommentEdit(trimmed, mentionedUserIds) - setEditingCommentContent('') - setIsCommentEditing(false) + // Focus on thread transitions, not callback changes after position updates. + useEffect(() => { + const timer = setTimeout(() => { + if (replyInputRef.current && !editingReply.id && !isCommentEditing && canReply) + replyInputRef.current.focus() + }, 100) - setTimeout(() => { - replyInputRef.current?.focus() - }, 0) - } catch (error) { - console.error('Failed to edit comment', error) - } finally { - setIsSubmittingEdit(false) - } - }, - [onCommentEdit], - ) + return () => clearTimeout(timer) + }, [comment.id, editingReply.id, isCommentEditing, canReply]) - const handleEditSubmit = useCallback( - async (content: string, mentionedUserIds: string[]) => { - if (!onReplyEdit || !editingReply) return - const trimmed = content.trim() - if (!trimmed) return + const handleReplySubmit = useCallback( + async (content: string, mentionedUserIds: string[]) => { + if (!onReply || replySubmitting) return - setIsSubmittingEdit(true) - try { - await onReplyEdit(editingReply.id, trimmed, mentionedUserIds) - setEditingReply({ id: '', content: '' }) + setReplyContent('') - // P1: Restore focus to reply input after saving edit - setTimeout(() => { - replyInputRef.current?.focus() - }, 0) - } catch (error) { - console.error('Failed to edit reply', error) - } finally { - setIsSubmittingEdit(false) - } - }, - [editingReply, onReplyEdit], - ) + try { + await onReply(content, mentionedUserIds) - const replies = comment.replies || [] - const isOwnComment = comment.created_by_account?.id === currentUserId - const messageListRef = useRef(null) - const previousReplyCountRef = useRef(undefined) - const previousCommentIdRef = useRef(undefined) - - // Close dropdown when scrolling - useEffect(() => { - const container = messageListRef.current - if (!container || !activeReplyMenuId) return - - const handleScroll = () => { - setActiveReplyMenuId(null) + // P0: Restore focus to reply input after successful submission + setTimeout(() => { + replyInputRef.current?.focus() + }, 0) + } catch (error) { + console.error('Failed to send reply', error) + setReplyContent(content) } + }, + [onReply, replySubmitting], + ) - container.addEventListener('scroll', handleScroll) - return () => container.removeEventListener('scroll', handleScroll) - }, [activeReplyMenuId]) + const screenPosition = useMemo(() => { + return flowToScreenPosition({ + x: comment.position_x, + y: comment.position_y, + }) + }, [ + comment.position_x, + comment.position_y, + viewport.x, + viewport.y, + viewport.zoom, + flowToScreenPosition, + ]) + const workflowContainerRect = + typeof document !== 'undefined' + ? document.getElementById('workflow-container')?.getBoundingClientRect() + : null + const containerLeft = workflowContainerRect?.left ?? 0 + const containerTop = workflowContainerRect?.top ?? 0 + const canvasPosition = useMemo( + () => ({ + x: screenPosition.x - containerLeft, + y: screenPosition.y - containerTop, + }), + [screenPosition.x, screenPosition.y, containerLeft, containerTop], + ) - // Auto-scroll to bottom on new messages - useEffect(() => { - const container = messageListRef.current - if (!container) return + const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => { + setEditingReply({ id: reply.id, content: reply.content }) + setIsCommentEditing(false) + setActiveReplyMenuId(null) + }, []) - const isFirstRender = previousCommentIdRef.current === undefined - const isNewComment = comment.id !== previousCommentIdRef.current - const hasNewReply = - previousReplyCountRef.current !== undefined && - replies.length > previousReplyCountRef.current + const handleStartCommentEdit = useCallback(() => { + setEditingCommentContent(comment.content) + setEditingReply({ id: '', content: '' }) + setIsCommentEditing(true) + setActiveReplyMenuId(null) + }, [comment.content]) - // Scroll on first render, new comment, or new reply - if (isFirstRender || isNewComment || hasNewReply) { - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }) + const handleCancelEdit = useCallback(() => { + setEditingReply({ id: '', content: '' }) + + // P1: Restore focus to reply input after canceling edit + setTimeout(() => { + replyInputRef.current?.focus() + }, 0) + }, []) + + const handleCancelCommentEdit = useCallback(() => { + setEditingCommentContent('') + setIsCommentEditing(false) + + setTimeout(() => { + replyInputRef.current?.focus() + }, 0) + }, []) + + const handleCommentEditSubmit = useCallback( + async (content: string, mentionedUserIds: string[]) => { + if (!onCommentEdit) return + const trimmed = content.trim() + if (!trimmed) return + + setIsSubmittingEdit(true) + try { + await onCommentEdit(trimmed, mentionedUserIds) + setEditingCommentContent('') + setIsCommentEditing(false) + + setTimeout(() => { + replyInputRef.current?.focus() + }, 0) + } catch (error) { + console.error('Failed to edit comment', error) + } finally { + setIsSubmittingEdit(false) } + }, + [onCommentEdit], + ) - previousCommentIdRef.current = comment.id - previousReplyCountRef.current = replies.length - }, [comment.id, replies.length]) + const handleEditSubmit = useCallback( + async (content: string, mentionedUserIds: string[]) => { + if (!onReplyEdit || !editingReply) return + const trimmed = content.trim() + if (!trimmed) return - return ( + setIsSubmittingEdit(true) + try { + await onReplyEdit(editingReply.id, trimmed, mentionedUserIds) + setEditingReply({ id: '', content: '' }) + + // P1: Restore focus to reply input after saving edit + setTimeout(() => { + replyInputRef.current?.focus() + }, 0) + } catch (error) { + console.error('Failed to edit reply', error) + } finally { + setIsSubmittingEdit(false) + } + }, + [editingReply, onReplyEdit], + ) + + const replies = comment.replies || [] + const isOwnComment = comment.created_by_account?.id === currentUserId + const messageListRef = useRef(null) + const previousReplyCountRef = useRef(undefined) + const previousCommentIdRef = useRef(undefined) + + // Close dropdown when scrolling + useEffect(() => { + const container = messageListRef.current + if (!container || !activeReplyMenuId) return + + const handleScroll = () => { + setActiveReplyMenuId(null) + } + + container.addEventListener('scroll', handleScroll) + return () => container.removeEventListener('scroll', handleScroll) + }, [activeReplyMenuId]) + + // Auto-scroll to bottom on new messages + useEffect(() => { + const container = messageListRef.current + if (!container) return + + const isFirstRender = previousCommentIdRef.current === undefined + const isNewComment = comment.id !== previousCommentIdRef.current + const hasNewReply = + previousReplyCountRef.current !== undefined && replies.length > previousReplyCountRef.current + + // Scroll on first render, new comment, or new reply + if (isFirstRender || isNewComment || hasNewReply) { + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }) + } + + previousCommentIdRef.current = comment.id + previousReplyCountRef.current = replies.length + }, [comment.id, replies.length]) + + return ( +
setCommentPreviewHovering(true)} + onMouseLeave={() => setCommentPreviewHovering(false)} + > + {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- The thread handles bubbling Escape after its child editor and menus have handled it. */}
{ + if (event.defaultPrevented || event.nativeEvent.isComposing || event.key !== 'Escape') + return + if (editingReply.id || isCommentEditing) return + event.preventDefault() + event.stopPropagation() + onClose() }} - onMouseEnter={() => setCommentPreviewHovering(true)} - onMouseLeave={() => setCommentPreviewHovering(false)} + className="relative flex h-90 flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl" + role="dialog" + aria-labelledby="comment-thread-title" > - {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- The thread handles bubbling Escape after its child editor and menus have handled it. */} -
{ - if (event.defaultPrevented || event.nativeEvent.isComposing || event.key !== 'Escape') - return - if (editingReply.id || isCommentEditing) return - event.preventDefault() - event.stopPropagation() - onClose() - }} - className="relative flex h-90 flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl" - role="dialog" - aria-labelledby="comment-thread-title" - > -
-
- {t(($) => $['comments.panelTitle'], { ns: 'workflowComments' })} -
-
- - $['comments.aria.deleteComment'], { - ns: 'workflowComments', - })} - > - - - } - /> - - {t(($) => $['comments.aria.deleteComment'], { ns: 'workflowComments' })} - - - - $['comments.aria.resolveComment'], { - ns: 'workflowComments', - })} - > - {comment.resolved ? ( - - ) : ( - - )} - - } - /> - - {t(($) => $['comments.aria.resolveComment'], { ns: 'workflowComments' })} - - - - - $['comments.aria.previousComment'], { - ns: 'workflowComments', - })} - > - - - } - /> - - {t(($) => $['comments.aria.previousComment'], { ns: 'workflowComments' })} - - - - $['comments.aria.nextComment'], { - ns: 'workflowComments', - })} - > - - - } - /> - - {t(($) => $['comments.aria.nextComment'], { ns: 'workflowComments' })} - - - -
+
+
+ {t(($) => $['comments.panelTitle'], { ns: 'workflowComments' })}
-
-
- {isOwnComment && !isCommentEditing && ( -
- setActiveReplyMenuId(open ? comment.id : null)} - > - $['comments.aria.commentActions'], { - ns: 'workflowComments', - })} - > - - - - - - -
- )} - {isCommentEditing ? ( -
-
- $['comments.fallback.user'], { ns: 'workflowComments' }) - } - avatar={comment.created_by_account?.avatar_url || null} - size="sm" - className="size-8 rounded-full" - /> -
-
-
- $['comments.placeholder.editComment'], { - ns: 'workflowComments', - })} - disabled={loading} - loading={isSubmittingEdit} - isEditing={true} - className="system-sm-regular" - autoFocus - /> -
-
-
- ) : ( - $['comments.fallback.user'], { ns: 'workflowComments' }) - } - avatarUrl={comment.created_by_account?.avatar_url || null} - createdAt={comment.created_at ?? comment.updated_at ?? 0} - content={comment.content} - mentionableNames={mentionableNames} - /> - )} -
- {replies.length > 0 && ( -
- {replies.map((reply) => { - const isReplyEditing = editingReply?.id === reply.id - const isOwnReply = reply.created_by_account?.id === currentUserId - return ( -
- {isOwnReply && !isReplyEditing && ( -
- { - if (!open) setDeletingReplyId(null) - setActiveReplyMenuId(open ? reply.id : null) - }} - > - $['comments.aria.replyActions'], { - ns: 'workflowComments', - })} - > - - - -
- - -
- -
- $['comments.actions.deleteReply'], { - ns: 'workflowComments', - })} - onConfirm={() => { - setDeletingReplyId(null) - setActiveReplyMenuId(null) - onReplyDeleteDirect?.(reply.id) - }} - onCancel={() => { - setDeletingReplyId(null) - }} - className="m-0 w-full border-0 shadow-none" - /> -
-
-
-
- )} - {isReplyEditing ? ( -
-
- $['comments.fallback.user'], { ns: 'workflowComments' }) - } - avatar={reply.created_by_account?.avatar_url || null} - size="sm" - className="size-8 rounded-full" - /> -
-
-
- - setEditingReply((prev) => - prev ? { ...prev, content: newContent } : prev, - ) - } - onSubmit={handleEditSubmit} - onCancel={handleCancelEdit} - placeholder={t(($) => $['comments.placeholder.editReply'], { - ns: 'workflowComments', - })} - disabled={loading} - loading={replyUpdating || isSubmittingEdit} - isEditing={true} - className="system-sm-regular" - autoFocus - /> -
-
-
- ) : ( - $['comments.fallback.user'], { ns: 'workflowComments' }) - } - avatarUrl={reply.created_by_account?.avatar_url || null} - createdAt={reply.created_at ?? 0} - content={reply.content} - mentionableNames={mentionableNames} - /> - )} -
- ) - })} -
- )} -
- {loading && ( -
- {t(($) => $['comments.loading'], { ns: 'workflowComments' })} -
- )} - {onReply && ( -
-
- $.you, { ns: 'common' })} - size="sm" - className="size-8" - /> -
- $['comments.placeholder.reply'], { +
+ + $['comments.aria.deleteComment'], { ns: 'workflowComments', })} - disabled={loading} - loading={replySubmitting} + > + + + } + /> + + {t(($) => $['comments.aria.deleteComment'], { ns: 'workflowComments' })} + + + + $['comments.aria.resolveComment'], { + ns: 'workflowComments', + })} + > + {comment.resolved ? ( + + ) : ( + + )} + + } + /> + + {t(($) => $['comments.aria.resolveComment'], { ns: 'workflowComments' })} + + + + + $['comments.aria.previousComment'], { + ns: 'workflowComments', + })} + > + + + } + /> + + {t(($) => $['comments.aria.previousComment'], { ns: 'workflowComments' })} + + + + $['comments.aria.nextComment'], { + ns: 'workflowComments', + })} + > + + + } + /> + + {t(($) => $['comments.aria.nextComment'], { ns: 'workflowComments' })} + + + +
+
+
+
+ {isOwnComment && !isCommentEditing && ( + setActiveReplyMenuId(open ? comment.id : null)} + > + $['comments.aria.commentActions'], { + ns: 'workflowComments', + })} + > + + + + { + e.stopPropagation() + handleStartCommentEdit() + }} + > + {t(($) => $['comments.actions.editComment'], { ns: 'workflowComments' })} + + + + )} + {isCommentEditing ? ( +
+
+ $['comments.fallback.user'], { ns: 'workflowComments' }) + } + avatar={comment.created_by_account?.avatar_url || null} + size="sm" + className="size-8 rounded-full" />
+
+
+ $['comments.placeholder.editComment'], { + ns: 'workflowComments', + })} + disabled={loading} + loading={isSubmittingEdit} + isEditing={true} + className="system-sm-regular" + autoFocus + /> +
+
+ ) : ( + $['comments.fallback.user'], { ns: 'workflowComments' }) + } + avatarUrl={comment.created_by_account?.avatar_url || null} + createdAt={comment.created_at ?? comment.updated_at ?? 0} + content={comment.content} + mentionableNames={mentionableNames} + /> + )} +
+ {replies.length > 0 && ( +
+ {replies.map((reply) => { + const isReplyEditing = editingReply?.id === reply.id + const isOwnReply = reply.created_by_account?.id === currentUserId + return ( +
+ {isOwnReply && !isReplyEditing && ( + { + setActiveReplyMenuId(open ? reply.id : null) + }} + > + { + if (element) replyMenuTriggersRef.current.set(reply.id, element) + else replyMenuTriggersRef.current.delete(reply.id) + }} + className="absolute top-1 right-1 flex size-6 items-center justify-center rounded-md text-text-tertiary opacity-0 outline-hidden group-hover:opacity-100 hover:bg-state-base-hover hover:text-text-secondary focus:opacity-100 focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:opacity-100 [@media(hover:none)]:opacity-100" + data-reply-menu + aria-label={t(($) => $['comments.aria.replyActions'], { + ns: 'workflowComments', + })} + > + + + + { + event.stopPropagation() + handleStartEdit(reply) + }} + > + {t(($) => $['comments.actions.editReply'], { + ns: 'workflowComments', + })} + + { + event.stopPropagation() + setActiveReplyMenuId(null) + if (onReplyDeleteDirect) { + deleteReplyTriggerRef.current = + replyMenuTriggersRef.current.get(reply.id) ?? null + queueMicrotask(() => setDeletingReplyId(reply.id)) + } else onReplyDelete?.(reply.id) + }} + > + {t(($) => $['comments.actions.deleteReply'], { + ns: 'workflowComments', + })} + + + + )} + {isReplyEditing ? ( +
+
+ $['comments.fallback.user'], { ns: 'workflowComments' }) + } + avatar={reply.created_by_account?.avatar_url || null} + size="sm" + className="size-8 rounded-full" + /> +
+
+
+ + setEditingReply((prev) => + prev ? { ...prev, content: newContent } : prev, + ) + } + onSubmit={handleEditSubmit} + onCancel={handleCancelEdit} + placeholder={t(($) => $['comments.placeholder.editReply'], { + ns: 'workflowComments', + })} + disabled={loading} + loading={replyUpdating || isSubmittingEdit} + isEditing={true} + className="system-sm-regular" + autoFocus + /> +
+
+
+ ) : ( + $['comments.fallback.user'], { ns: 'workflowComments' }) + } + avatarUrl={reply.created_by_account?.avatar_url || null} + createdAt={reply.created_at ?? 0} + content={reply.content} + mentionableNames={mentionableNames} + /> + )} +
+ ) + })}
)}
+ {loading && ( +
+ {t(($) => $['comments.loading'], { ns: 'workflowComments' })} +
+ )} + {onReply && ( +
+
+ $.you, { ns: 'common' })} + size="sm" + className="size-8" + /> +
+ $['comments.placeholder.reply'], { + ns: 'workflowComments', + })} + disabled={loading} + loading={replySubmitting} + /> +
+
+
+ )}
- ) - }, -) + { + if (!open && loading) { + eventDetails.cancel() + return + } + if (!open) setDeletingReplyId(null) + }} + > + + deleteReplyTriggerRef.current?.isConnected + ? deleteReplyTriggerRef.current + : (replyInputRef.current ?? true) + } + > +
+ + {t(($) => $['comments.actions.deleteReply'], { ns: 'workflowComments' })} + + + {t(($) => $['operation.confirmAction'], { ns: 'common' })} + +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + { + if (deletingReplyId) await onReplyDeleteDirect?.(deletingReplyId) + setDeletingReplyId(null) + }} + > + {t(($) => $['operation.delete'], { ns: 'common' })} + + +
+
+
+ ) +} + +export const CommentThread = memo(CommentThreadComponent) CommentThread.displayName = 'CommentThread' diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx index c5cf8963cc9..adc94c6031e 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx @@ -1,45 +1,15 @@ -import type { TriggerOption } from '../test-run-menu' -import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { TriggerType } from '../test-run-menu' -import { getNormalizedShortcutKey, OptionRow, SingleOptionTrigger } from '../test-run-menu-helpers' - -const createOption = (overrides: Partial = {}): TriggerOption => ({ - id: 'user-input', - type: TriggerType.UserInput, - name: 'User Input', - icon: icon, - enabled: true, - ...overrides, -}) +import { getNormalizedShortcutKey, SingleOptionTrigger } from '../test-run-menu-helpers' describe('test-run-menu helpers', () => { beforeEach(() => { vi.clearAllMocks() }) - it('should normalize shortcut keys and render option rows with clickable shortcuts', async () => { - const user = userEvent.setup() - const onSelect = vi.fn() - const option = createOption() - + it('should normalize shortcut keys', () => { expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '`' }))).toBe('~') expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '1' }))).toBe('1') - - render( - - - - - , - ) - - expect(screen.getByText('1')).toBeInTheDocument() - - await user.click(screen.getByText('User Input')) - - expect(onSelect).toHaveBeenCalledWith(option) }) it('should run single options for element and non-element children unless the click is prevented', async () => { diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx index 66b691b3c94..99944ff3f56 100644 --- a/web/app/components/workflow/header/test-run-menu-helpers.tsx +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -1,8 +1,6 @@ import type { KeyboardEvent, MouseEvent, MouseEventHandler, ReactElement } from 'react' import type { TriggerOption } from './test-run-menu' -import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' import { cloneElement, isValidElement } from 'react' -import { ShortcutKbd } from '../shortcuts/shortcut-kbd' export type ShortcutMapping = { option: TriggerOption @@ -13,31 +11,6 @@ export const getNormalizedShortcutKey = (event: Pick) => { return event.key === '`' ? '~' : event.key } -export const OptionRow = ({ - option, - shortcutKey, - onSelect, -}: { - option: TriggerOption - shortcutKey?: string - onSelect: (option: TriggerOption) => void -}) => { - return ( - onSelect(option)} - > -
-
{option.icon}
- {option.name} -
- {shortcutKey && ( - - )} -
- ) -} - export function handleShortcutMenuKeyDown( event: KeyboardEvent, shortcutMappings: ShortcutMapping[], diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index 34c570936c6..ed3c9773c92 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -3,7 +3,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, - DropdownMenuLabel, + DropdownMenuGroupLabel, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' @@ -16,7 +17,8 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { handleShortcutMenuKeyDown, OptionRow, SingleOptionTrigger } from './test-run-menu-helpers' +import { ShortcutKbd } from '../shortcuts/shortcut-kbd' +import { handleShortcutMenuKeyDown, SingleOptionTrigger } from './test-run-menu-helpers' export const TriggerType = { UserInput: 'user_input', @@ -54,6 +56,29 @@ export type TestRunMenuRef = { toggle: () => void } +const OptionRow = ({ + option, + shortcutKey, + onSelect, +}: { + option: TriggerOption + shortcutKey?: string + onSelect: (option: TriggerOption) => void +}) => { + return ( + onSelect(option)} + > +
+
{option.icon}
+ {option.name} +
+ {shortcutKey && } +
+ ) +} + const getEnabledOptions = (options: TestRunOptions) => { const flattened: TriggerOption[] = [] @@ -169,12 +194,12 @@ const TestRunMenu = forwardRef( placement="bottom-start" sideOffset={8} alignOffset={-4} - className="w-71 p-1" + className="w-71 px-1" > - - + + {t(($) => $['common.chooseStartNodeToRun'], { ns: 'workflow' })} - +
{hasUserInput && renderOption(options.userInput!)} diff --git a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx index 8b6fc711995..62334178808 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx @@ -1,8 +1,3 @@ -import { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' import { detectPlatform } from '@tanstack/react-hotkeys' import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -17,7 +12,6 @@ import { useAvailableBlocks } from '../../hooks/use-available-blocks' import { useNodesInteractions } from '../../hooks/use-nodes-interactions' import { useNodeMetaData } from '../../hooks/use-nodes-meta-data' import { useIsChatMode, useNodesReadOnly } from '../../hooks/use-workflow' -import { NodeActionsDropdownContent } from '../dropdown-content' import { NodeActionsDropdown } from '../index' vi.mock('../../hooks/use-available-blocks', async (importOriginal) => { @@ -94,7 +88,7 @@ const mockUseHooksStore = vi.mocked(useHooksStore) const mockUseNodes = vi.mocked(useNodes) const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) -function renderDropdownContent({ +async function renderDropdownContent({ showHelpLink = true, onClose = vi.fn(), data = {}, @@ -103,23 +97,20 @@ function renderDropdownContent({ onClose?: () => void data?: Record } = {}) { - return renderWorkflowFlowComponent( - - open} /> - - - - , + const result = renderWorkflowFlowComponent( + !open && onClose()} + showHelpLink={showHelpLink} + />, { nodes: [], edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }], }, ) + await userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.more' })) + return result } describe('node actions menu details', () => { @@ -238,16 +229,17 @@ describe('node actions menu details', () => { it('should run, copy, duplicate, delete, and expose the help link', async () => { const user = userEvent.setup() - const { store } = renderDropdownContent() + const { store } = await renderDropdownContent() const deleteMenuItem = screen.getByText('common.operation.delete').closest('[role="menuitem"]') - expect(deleteMenuItem).toHaveAttribute('data-variant', 'default') - expect(deleteMenuItem).toHaveClass('text-text-secondary') - expect(deleteMenuItem).toHaveClass('data-highlighted:text-text-destructive') + expect(deleteMenuItem).toHaveAttribute('data-variant', 'destructive') await user.click(screen.getByText('workflow.panel.runThisStep')) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) await user.click(screen.getByText('workflow.common.copy')) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) await user.click(screen.getByText('workflow.common.duplicate')) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) await user.click(screen.getByText('common.operation.delete')) expect(handleNodeSelect).toHaveBeenCalledWith('node-1') @@ -256,6 +248,7 @@ describe('node actions menu details', () => { expect(handleNodesCopy).toHaveBeenCalledWith('node-1') expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1') expect(handleNodeDelete).toHaveBeenCalledWith('node-1') + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) expect(screen.getByRole('menuitem', { name: 'workflow.panel.helpLink' })).toHaveAttribute( 'href', 'https://docs.example.com/node', @@ -264,7 +257,7 @@ describe('node actions menu details', () => { it('should stop the current single run from the run action when the node is running', async () => { const user = userEvent.setup() - const { store } = renderDropdownContent({ + const { store } = await renderDropdownContent({ data: { _singleRunningStatus: NodeRunningStatus.Running, }, @@ -277,7 +270,7 @@ describe('node actions menu details', () => { expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'stop' }) }) - it('should hide change action when node is undeletable', () => { + it('should hide change action when node is undeletable', async () => { mockUseNodeMetaData.mockReturnValueOnce({ isTypeFixed: false, isSingleton: true, @@ -286,44 +279,39 @@ describe('node actions menu details', () => { author: 'Dify', } as ReturnType) - renderDropdownContent({ showHelpLink: false }) + await renderDropdownContent({ showHelpLink: false }) expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument() expect(screen.queryByText('workflow.panel.change')).not.toBeInTheDocument() expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() }) - it('should render workflow-tool and readonly variants', () => { + it('should render workflow-tool and readonly variants', async () => { mockUseAllWorkflowTools.mockReturnValueOnce({ data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }], } as any) const { rerender } = renderWorkflowFlowComponent( - - open} /> - - - - , + , { nodes: [], edges: [], }, ) + await userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.more' })) expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute( 'href', '/app/app-123/workflow', @@ -341,17 +329,11 @@ describe('node actions menu details', () => { } as ReturnType) rerender( - - open} /> - - - - , + , ) expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() diff --git a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx index def6bed31ef..7a9e622076e 100644 --- a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx +++ b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx @@ -19,7 +19,6 @@ import { useStoreApi } from 'reactflow' import { handleWorkflowMenuKeyDown } from '../shortcuts/handle-workflow-menu-key-down' import { ChangeBlockPopup } from './change-block-popup' import { - NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME, NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME, NODE_ACTIONS_MENU_WIDTH_CLASS_NAME, NodeActionsMenuAbout, @@ -116,7 +115,8 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { {hasDeleteGroup && ( diff --git a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx deleted file mode 100644 index efac120b86b..00000000000 --- a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import type { NodeActionsMenuProps } from './types' -import { - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLinkItem, - DropdownMenuPopup, - DropdownMenuPositioner, - DropdownMenuSeparator, -} from '@langgenius/dify-ui/dropdown-menu' -import { - Popover, - PopoverBackdrop, - PopoverPortal, - PopoverPositioner, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { useStoreApi } from 'reactflow' -import { handleWorkflowMenuKeyDown } from '../shortcuts/handle-workflow-menu-key-down' -import { ChangeBlockPopup } from './change-block-popup' -import { - NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME, - NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME, - NODE_ACTIONS_MENU_WIDTH_CLASS_NAME, - NodeActionsMenuAbout, - NodeActionsMenuItemContent, -} from './shared' -import { useNodeActionsMenuModel } from './use-node-actions-menu-model' - -export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { - const { t } = useTranslation(['common', 'workflow', 'workflowDebug']) - const model = useNodeActionsMenuModel(props) - const flowStore = useStoreApi() - const deletingRef = useRef(false) - const hasRunGroup = model.canRun || model.canChangeBlock - const hasEditGroup = !model.nodesReadOnly && !model.isSingleton - const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable - const singleRunActionLabel = model.isSingleRunning - ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflowDebug' }) - : t(($) => $['panel.runThisStep'], { ns: 'workflow' }) - - function handleDelete() { - deletingRef.current = true - model.handleDelete() - } - - return ( - - (deletingRef.current ? (flowStore.getState().domNode ?? true) : true)} - className={`${NODE_ACTIONS_MENU_WIDTH_CLASS_NAME} border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 shadow-lg backdrop-blur-[5px]`} - onKeyDown={(event) => - handleWorkflowMenuKeyDown(event, [ - ['workflow.copy', hasEditGroup ? model.handleCopy : undefined], - ['workflow.duplicate', hasEditGroup ? model.handleDuplicate : undefined], - ['workflow.delete', hasDeleteGroup ? handleDelete : undefined], - ]) - } - > - {hasRunGroup && ( - - {model.canRun && ( - {singleRunActionLabel} - )} - {model.canChangeBlock && ( - - } />} - className="data-popup-open:bg-state-base-hover" - > - {t(($) => $['panel.changeBlock'], { ns: 'workflow' })} - - - - - - - - - )} - - )} - {hasRunGroup && - (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && ( - - )} - {hasEditGroup && ( - - - - {t(($) => $['common.copy'], { ns: 'workflow' })} - - - - - {t(($) => $['common.duplicate'], { ns: 'workflow' })} - - - - )} - {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && ( - - )} - {hasDeleteGroup && ( - - - - {t(($) => $['operation.delete'], { ns: 'common' })} - - - - )} - {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && ( - - )} - {model.workflowAppHref && ( - - - {t(($) => $['panel.openWorkflow'], { ns: 'workflow' })} - - - )} - {model.workflowAppHref && model.helpLinkUri && } - {model.helpLinkUri && ( - - - {t(($) => $['panel.helpLink'], { ns: 'workflow' })} - - - )} - - $['panel.about'], { ns: 'workflow' })} - description={model.about.description} - author={`${t(($) => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`} - /> - - - ) -} diff --git a/web/app/components/workflow/node-actions-menu/index.tsx b/web/app/components/workflow/node-actions-menu/index.tsx index 40d8df801ec..7ab114f8b2e 100644 --- a/web/app/components/workflow/node-actions-menu/index.tsx +++ b/web/app/components/workflow/node-actions-menu/index.tsx @@ -1,14 +1,37 @@ +import type { NodeActionsMenuProps } from './types' import type { Node } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuPopup, DropdownMenuPortal, + DropdownMenuPositioner, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { useCallback, useState } from 'react' +import { + Popover, + PopoverBackdrop, + PopoverPortal, + PopoverPositioner, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { NodeActionsDropdownContent } from './dropdown-content' +import { useStoreApi } from 'reactflow' +import { handleWorkflowMenuKeyDown } from '../shortcuts/handle-workflow-menu-key-down' +import { ChangeBlockPopup } from './change-block-popup' +import { + NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME, + NODE_ACTIONS_MENU_WIDTH_CLASS_NAME, + NodeActionsMenuAbout, + NodeActionsMenuItemContent, +} from './shared' +import { useNodeActionsMenuModel } from './use-node-actions-menu-model' type NodeActionsDropdownProps = { id: string @@ -65,3 +88,139 @@ export function NodeActionsDropdown({ ) } + +function NodeActionsDropdownContent(props: NodeActionsMenuProps) { + const { t } = useTranslation(['common', 'workflow', 'workflowDebug']) + const model = useNodeActionsMenuModel(props) + const flowStore = useStoreApi() + const deletingRef = useRef(false) + const hasRunGroup = model.canRun || model.canChangeBlock + const hasEditGroup = !model.nodesReadOnly && !model.isSingleton + const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable + const singleRunActionLabel = model.isSingleRunning + ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflowDebug' }) + : t(($) => $['panel.runThisStep'], { ns: 'workflow' }) + + function handleDelete() { + deletingRef.current = true + model.handleDelete() + } + + return ( + + (deletingRef.current ? (flowStore.getState().domNode ?? true) : true)} + className={`${NODE_ACTIONS_MENU_WIDTH_CLASS_NAME} border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 shadow-lg backdrop-blur-[5px]`} + onKeyDown={(event) => + handleWorkflowMenuKeyDown(event, [ + ['workflow.copy', hasEditGroup ? model.handleCopy : undefined], + ['workflow.duplicate', hasEditGroup ? model.handleDuplicate : undefined], + ['workflow.delete', hasDeleteGroup ? handleDelete : undefined], + ]) + } + > + {hasRunGroup && ( + + {model.canRun && ( + {singleRunActionLabel} + )} + {model.canChangeBlock && ( + + } />} + className="data-popup-open:bg-state-base-hover" + > + {t(($) => $['panel.changeBlock'], { ns: 'workflow' })} + + + + + + + + + )} + + )} + {hasRunGroup && + (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && ( + + )} + {hasEditGroup && ( + + + + {t(($) => $['common.copy'], { ns: 'workflow' })} + + + + + {t(($) => $['common.duplicate'], { ns: 'workflow' })} + + + + )} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && ( + + )} + {hasDeleteGroup && ( + + + + {t(($) => $['operation.delete'], { ns: 'common' })} + + + + )} + {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && ( + + )} + {model.workflowAppHref && ( + + + {t(($) => $['panel.openWorkflow'], { ns: 'workflow' })} + + + )} + {model.workflowAppHref && model.helpLinkUri && } + {model.helpLinkUri && ( + + + {t(($) => $['panel.helpLink'], { ns: 'workflow' })} + + + )} + + $['panel.about'], { ns: 'workflow' })} + description={model.about.description} + author={`${t(($) => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`} + /> + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/shared.tsx b/web/app/components/workflow/node-actions-menu/shared.tsx index b057b0abdfb..6951943cafa 100644 --- a/web/app/components/workflow/node-actions-menu/shared.tsx +++ b/web/app/components/workflow/node-actions-menu/shared.tsx @@ -4,7 +4,6 @@ import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' export const NODE_ACTIONS_MENU_WIDTH_CLASS_NAME = 'w-[240px] rounded-lg' export const NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME = 'w-auto justify-between gap-4' -export const NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME = `${NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME} text-text-secondary data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive` export function NodeActionsMenuItemContent({ children, diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx index 5222a94f2c8..015d5eb8e83 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx @@ -4,9 +4,10 @@ import { DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' +import { RiArrowDownSLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { ErrorHandleTypeEnum } from './types' @@ -58,24 +59,20 @@ const ErrorHandleTypeSelector = ({ {selectedOption?.label} - + {availableOptions.map((option) => ( { e.stopPropagation() }} >
- {value === option.value && } +
{option.label}
diff --git a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx index d6a4e5a8660..ec0b87be3f1 100644 --- a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx @@ -27,7 +27,14 @@ describe('assigner/operation-selector', () => { expect(screen.getByText('workflowLogic.nodes.assigner.operations.set')).toBeInTheDocument() expect(screen.getByText('workflowLogic.nodes.assigner.operations.+=')).toBeInTheDocument() - await user.click(screen.getAllByText('workflowLogic.nodes.assigner.operations.+=').at(-1)!) + expect( + screen.getByRole('menuitemradio', { + name: 'workflowLogic.nodes.assigner.operations.over-write', + }), + ).toHaveAttribute('aria-checked', 'true') + await user.click( + screen.getByRole('menuitemradio', { name: 'workflowLogic.nodes.assigner.operations.+=' }), + ) expect(onSelect).toHaveBeenCalledWith({ value: WriteMode.increment, name: WriteMode.increment }) }) diff --git a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx index a853755c86f..996542403a6 100644 --- a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx +++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx @@ -1,4 +1,3 @@ -import type { FC } from 'react' import type { WriteMode } from '../types' import type { Item } from '../utils' import type { VarType } from '@/app/components/workflow/types' @@ -7,8 +6,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, + DropdownMenuGroupLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' @@ -28,7 +29,7 @@ type OperationSelectorProps = { writeModeTypesNum?: WriteMode[] } -const OperationSelector: FC = ({ +function OperationSelector({ value, onSelect, disabled = false, @@ -38,7 +39,7 @@ const OperationSelector: FC = ({ writeModeTypes, writeModeTypesArr, writeModeTypesNum, -}) => { +}: OperationSelectorProps) { const { t } = useTranslation(['workflowLogic']) const items = getOperationItems( @@ -86,31 +87,38 @@ const OperationSelector: FC = ({ className={cn('w-35', popupClassName)} > - + {t(($) => $['nodes.assigner.operations.title'], { ns: 'workflowLogic' })} - - {items.map((item) => - !isOperationItem(item) ? ( - - ) : ( - onSelect(item)} - > -
- - {t(($) => $[`nodes.assigner.operations.${item.name}`], { ns: 'workflowLogic' })} - -
- {item.value === value && ( -
- + + { + const item = items.find((item) => isOperationItem(item) && item.value === nextValue) + if (item && isOperationItem(item)) onSelect(item) + }} + > + {items.map((item) => + !isOperationItem(item) ? ( + + ) : ( + +
+ + {t(($) => $[`nodes.assigner.operations.${item.name}`], { + ns: 'workflowLogic', + })} +
- )} - - ), - )} + +
+ ), + )} +
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx index c578a931fed..fc517124426 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx @@ -4,9 +4,10 @@ import { DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' +import { RiArrowDownSLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' @@ -59,21 +60,17 @@ const MetadataFilterSelector = ({ {selectedOption.value} - + {options.map((option) => (
- {option.key === value && } +
{option.value}
diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx index 173ab9734e4..7babfd7f96b 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx @@ -158,15 +158,11 @@ describe('VersionHistoryItem', () => { expect(screen.getByText('version-1')).toBeInTheDocument() expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() - const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer') - if (!restoreItem) throw new Error('Expected restore menu item') - - fireEvent.click(restoreItem) + await user.click(screen.getByRole('menuitem', { name: 'workflow.common.restore' })) expect(handleClickActionMenuItem).toHaveBeenCalledTimes(1) expect(handleClickActionMenuItem).toHaveBeenCalledWith( VersionHistoryContextMenuOptions.restore, - VersionHistoryContextMenuOptions.restore, ) }) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx deleted file mode 100644 index 8001f748409..00000000000 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { VersionHistoryContextMenuOptions } from '../../../../types' -import ActionMenuItem from '../action-menu-item' - -describe('ActionMenuItem', () => { - it('forwards the selected operation and supports destructive styling', async () => { - const user = userEvent.setup() - const onClick = vi.fn() - - render( - - - - - , - ) - - await user.click(screen.getByText('Delete')) - - expect(onClick).toHaveBeenCalledWith(VersionHistoryContextMenuOptions.delete) - }) -}) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx index 5bb89b3efd0..b5b79b87366 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx @@ -14,11 +14,14 @@ import ActionMenu from '../index' let mockPlanType: CloudPlan = 'professional' let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD' -const renderActionMenu = (ui: React.ReactElement) => { +const renderActionMenu = (ui: React.ReactElement, pipelineId?: string) => { const queryClient = createConsoleQueryClient() seedFeatures(queryClient, { billing: { subscription: { plan: mockPlanType } } }) seedSystemFeatures(queryClient, { deployment_edition: deploymentEdition }) - return renderWorkflowComponent({ui}, { queryClient }) + return renderWorkflowComponent({ui}, { + queryClient, + initialStoreState: { pipelineId }, + }) } vi.mock('@/config', async (importOriginal) => { @@ -54,25 +57,47 @@ describe('ActionMenu', () => { const trigger = screen.getByRole('button', { name: 'common.operation.more' }) expect(trigger).not.toHaveAttribute('role') + handleClickActionMenuItem.mockImplementation(() => expect(trigger).toHaveFocus()) await user.click(trigger) await user.click(screen.getByText('workflow.common.restore')) await user.click(screen.getByText('common.operation.delete')) expect(setOpen).toHaveBeenCalled() - expect(handleClickActionMenuItem).toHaveBeenCalledWith( - VersionHistoryContextMenuOptions.restore, - VersionHistoryContextMenuOptions.restore, - ) - expect(handleClickActionMenuItem).toHaveBeenCalledWith( - VersionHistoryContextMenuOptions.delete, - VersionHistoryContextMenuOptions.delete, - ) + expect(handleClickActionMenuItem).toHaveBeenCalledWith(VersionHistoryContextMenuOptions.restore) + expect(handleClickActionMenuItem).toHaveBeenCalledWith(VersionHistoryContextMenuOptions.delete) }) - it('shows upgrade buttons beside restore and export for sandbox users', async () => { + it.each([ + { pipelineId: 'pipeline-1', canImportExportDSL: true }, + { pipelineId: undefined, canImportExportDSL: false }, + ])('omits export outside permitted app workflows: %j', ({ pipelineId, canImportExportDSL }) => { + renderActionMenu( + , + pipelineId, + ) + expect(screen.queryByRole('menuitem', { name: 'app.exportApp' })).not.toBeInTheDocument() + expect( + screen.getByRole('menuitem', { name: 'workflowHistory.versionHistory.nameThisVersion' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('menuitem', { name: 'common.operation.delete' }), + ).not.toBeInTheDocument() + }) + + it('keeps upgrade badges within one menu action for sandbox users', async () => { const user = userEvent.setup() const handleClickActionMenuItem = vi.fn() + const trackUpgrade = vi.fn() + vi.stubGlobal('gtag', trackUpgrade) mockPlanType = 'sandbox' renderActionMenu( @@ -87,13 +112,20 @@ describe('ActionMenu', () => { />, ) - const upgradeButtons = screen.getAllByRole('button', { - name: 'billing.upgradeBtn.encourageShort', + expect( + screen.queryByRole('button', { name: 'billing.upgradeBtn.encourageShort' }), + ).not.toBeInTheDocument() + const restore = screen.getByRole('menuitem', { name: /workflow.common.restore/ }) + expect(restore).toHaveTextContent('billing.upgradeBtn.encourageShort') + await user.click( + screen.getAllByText('billing.upgradeBtn.encourageShort', { selector: 'span' })[0]!, + ) + expect(handleClickActionMenuItem).toHaveBeenCalledExactlyOnceWith( + VersionHistoryContextMenuOptions.restore, + ) + expect(trackUpgrade).toHaveBeenCalledExactlyOnceWith('event', 'click_upgrade_btn', { + loc: 'workflow-version-history-menu', }) - expect(upgradeButtons).toHaveLength(2) - - await user.click(upgradeButtons[0]!) - - expect(handleClickActionMenuItem).not.toHaveBeenCalled() + vi.unstubAllGlobals() }) }) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx deleted file mode 100644 index b27a953dca5..00000000000 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { renderWorkflowHook } from '../../../../__tests__/workflow-test-env' -import { VersionHistoryContextMenuOptions } from '../../../../types' -import useActionMenu from '../use-action-menu' - -describe('useActionMenu', () => { - it('returns restore, edit, export, copy and delete operations for app workflows', () => { - const { result } = renderWorkflowHook(() => - useActionMenu({ - workflowId: 'version-1', - isNamedVersion: true, - canImportExportDSL: true, - isShowDelete: false, - open: false, - setOpen: vi.fn(), - handleClickActionMenuItem: vi.fn(), - }), - ) - - expect(result.current.deleteOperation).toEqual({ - key: VersionHistoryContextMenuOptions.delete, - name: 'common.operation.delete', - }) - expect(result.current.options.map((item) => item.key)).toEqual([ - VersionHistoryContextMenuOptions.restore, - VersionHistoryContextMenuOptions.edit, - VersionHistoryContextMenuOptions.exportDSL, - VersionHistoryContextMenuOptions.copyId, - ]) - }) - - it('omits export for pipelines and renames the edit action for unnamed versions', () => { - const { result } = renderWorkflowHook( - () => - useActionMenu({ - workflowId: 'version-1', - isNamedVersion: false, - canImportExportDSL: true, - isShowDelete: true, - open: false, - setOpen: vi.fn(), - handleClickActionMenuItem: vi.fn(), - }), - { - initialStoreState: { - pipelineId: 'pipeline-1', - }, - }, - ) - - expect(result.current.options).toEqual([ - { - key: VersionHistoryContextMenuOptions.restore, - name: 'workflow.common.restore', - disabled: false, - }, - { - key: VersionHistoryContextMenuOptions.edit, - name: 'workflowHistory.versionHistory.nameThisVersion', - }, - { - key: VersionHistoryContextMenuOptions.copyId, - name: 'workflowHistory.versionHistory.copyId', - description: 'version-1', - }, - ]) - }) - - it('omits export when import/export DSL permission is missing', () => { - const { result } = renderWorkflowHook(() => - useActionMenu({ - workflowId: 'version-1', - isNamedVersion: true, - canImportExportDSL: false, - isShowDelete: false, - open: false, - setOpen: vi.fn(), - handleClickActionMenuItem: vi.fn(), - }), - ) - - expect(result.current.options.map((item) => item.key)).toEqual([ - VersionHistoryContextMenuOptions.restore, - VersionHistoryContextMenuOptions.edit, - VersionHistoryContextMenuOptions.copyId, - ]) - }) -}) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx deleted file mode 100644 index 5db4aedac44..00000000000 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { FC } from 'react' -import type { VersionHistoryContextMenuOptions } from '../../../types' -import { cn } from '@langgenius/dify-ui/cn' -import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' -import * as React from 'react' -import UpgradeBtn from '@/app/components/billing/upgrade-btn' - -type ActionMenuItemProps = { - item: { - key: VersionHistoryContextMenuOptions - name: string - description?: string - disabled?: boolean - showUpgrade?: boolean - } - onClick: (operation: VersionHistoryContextMenuOptions) => void - isDestructive?: boolean -} - -const ActionMenuItem: FC = ({ item, onClick, isDestructive = false }) => { - return ( - { - event.stopPropagation() - const target = event.target - if (target instanceof Element && target.closest('[data-upgrade-action]')) return - - onClick(item.key) - }} - > -
-
{item.name}
- {item.description && ( -
- {item.description} -
- )} -
- {item.showUpgrade && ( -
- -
- )} -
- ) -} - -export default React.memo(ActionMenuItem) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx index bdce50c0214..299da2b8888 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx @@ -1,18 +1,24 @@ -import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import * as React from 'react' +import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' +import PremiumBadge from '@/app/components/base/premium-badge' +import { useStore } from '@/app/components/workflow/store' +import { deploymentEditionAtom } from '@/features/system-features/state' +import { consoleQuery } from '@/service/console' import { VersionHistoryContextMenuOptions } from '../../../types' -import ActionMenuItem from './action-menu-item' -import useActionMenu from './use-action-menu' -export type ActionMenuProps = { +type ActionMenuProps = { workflowId: string isShowDelete: boolean isNamedVersion: boolean @@ -22,14 +28,169 @@ export type ActionMenuProps = { handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void } -const ActionMenu: FC = (props: ActionMenuProps) => { +const useActionMenu = (props: ActionMenuProps) => { + const { workflowId, isNamedVersion, canImportExportDSL } = props + const { t } = useTranslation(['app', 'common', 'workflow', 'workflowHistory']) + const pipelineId = useStore((s) => s.pipelineId) + const deploymentEdition = useAtomValue(deploymentEditionAtom) + const { data: plan } = useQuery( + consoleQuery.features.get.queryOptions({ + enabled: deploymentEdition === 'CLOUD', + select: (data) => data.billing.subscription.plan, + }), + ) + const shouldShowUpgrade = deploymentEdition === 'CLOUD' && plan === 'sandbox' + + const deleteOperation = { + key: VersionHistoryContextMenuOptions.delete, + name: t(($) => $['operation.delete'], { ns: 'common' }), + } + + const options = useMemo(() => { + return [ + { + key: VersionHistoryContextMenuOptions.restore, + name: t(($) => $['common.restore'], { ns: 'workflow' }), + disabled: deploymentEdition === 'CLOUD' && plan === undefined, + ...(shouldShowUpgrade ? { showUpgrade: true } : {}), + }, + isNamedVersion + ? { + key: VersionHistoryContextMenuOptions.edit, + name: t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflowHistory' }), + } + : { + key: VersionHistoryContextMenuOptions.edit, + name: t(($) => $['versionHistory.nameThisVersion'], { ns: 'workflowHistory' }), + }, + // todo: pipeline support export specific version DSL + ...(canImportExportDSL && !pipelineId + ? [ + { + key: VersionHistoryContextMenuOptions.exportDSL, + name: t(($) => $.exportApp, { ns: 'app' }), + disabled: deploymentEdition === 'CLOUD' && plan === undefined, + ...(shouldShowUpgrade ? { showUpgrade: true } : {}), + }, + ] + : []), + { + key: VersionHistoryContextMenuOptions.copyId, + name: t(($) => $['versionHistory.copyId'], { ns: 'workflowHistory' }), + description: workflowId, + }, + ] + }, [ + deploymentEdition, + plan, + canImportExportDSL, + isNamedVersion, + pipelineId, + shouldShowUpgrade, + t, + workflowId, + ]) + + return { + deleteOperation, + options, + } +} + +type ActionMenuItemProps = { + item: { + key: VersionHistoryContextMenuOptions + name: string + description?: string + disabled?: boolean + showUpgrade?: boolean + } + onClick: (operation: VersionHistoryContextMenuOptions) => void + isDestructive?: boolean +} + +function ActionMenuItem({ item, onClick, isDestructive = false }: ActionMenuItemProps) { + const { t } = useTranslation(['billing']) + return ( + { + event.stopPropagation() + onClick(item.key) + if (item.showUpgrade) { + const gtag = ( + window as Window & { + gtag?: ( + command: 'event', + action: 'click_upgrade_btn', + payload: { loc: string }, + ) => void + } + ).gtag + gtag?.('event', 'click_upgrade_btn', { loc: 'workflow-version-history-menu' }) + } + }} + > +
+
{item.name}
+ {item.description && ( +
+ {item.description} +
+ )} +
+ {item.showUpgrade && ( + + + + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} + + + )} +
+ ) +} + +function ActionMenu(props: ActionMenuProps) { const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props const { deleteOperation, options } = useActionMenu(props) const { t } = useTranslation(['common']) + const triggerRef = useRef(null) + + function handleAction(operation: VersionHistoryContextMenuOptions) { + queueMicrotask(() => { + triggerRef.current?.focus() + handleClickActionMenuItem(operation) + }) + } return ( = (props: ActionMenuProps) => { className="w-max max-w-[calc(100vw-24px)] min-w-46 shadow-shadow-shadow-5" > {options.map((option) => ( - + ))} {isShowDelete && ( <> - - + + )} diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts deleted file mode 100644 index 606053eddcc..00000000000 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { ActionMenuProps } from './index' -import { useQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { useStore } from '@/app/components/workflow/store' -import { deploymentEditionAtom } from '@/features/system-features/state' -import { consoleQuery } from '@/service/console' -import { VersionHistoryContextMenuOptions } from '../../../types' - -const useActionMenu = (props: ActionMenuProps) => { - const { workflowId, isNamedVersion, canImportExportDSL } = props - const { t } = useTranslation(['app', 'common', 'workflow', 'workflowHistory']) - const pipelineId = useStore((s) => s.pipelineId) - const deploymentEdition = useAtomValue(deploymentEditionAtom) - const { data: plan } = useQuery( - consoleQuery.features.get.queryOptions({ - enabled: deploymentEdition === 'CLOUD', - select: (data) => data.billing.subscription.plan, - }), - ) - const shouldShowUpgrade = deploymentEdition === 'CLOUD' && plan === 'sandbox' - - const deleteOperation = { - key: VersionHistoryContextMenuOptions.delete, - name: t(($) => $['operation.delete'], { ns: 'common' }), - } - - const options = useMemo(() => { - return [ - { - key: VersionHistoryContextMenuOptions.restore, - name: t(($) => $['common.restore'], { ns: 'workflow' }), - disabled: deploymentEdition === 'CLOUD' && plan === undefined, - ...(shouldShowUpgrade ? { showUpgrade: true } : {}), - }, - isNamedVersion - ? { - key: VersionHistoryContextMenuOptions.edit, - name: t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflowHistory' }), - } - : { - key: VersionHistoryContextMenuOptions.edit, - name: t(($) => $['versionHistory.nameThisVersion'], { ns: 'workflowHistory' }), - }, - // todo: pipeline support export specific version DSL - ...(canImportExportDSL && !pipelineId - ? [ - { - key: VersionHistoryContextMenuOptions.exportDSL, - name: t(($) => $.exportApp, { ns: 'app' }), - disabled: deploymentEdition === 'CLOUD' && plan === undefined, - ...(shouldShowUpgrade ? { showUpgrade: true } : {}), - }, - ] - : []), - { - key: VersionHistoryContextMenuOptions.copyId, - name: t(($) => $['versionHistory.copyId'], { ns: 'workflowHistory' }), - description: workflowId, - }, - ] - }, [ - deploymentEdition, - plan, - canImportExportDSL, - isNamedVersion, - pipelineId, - shouldShowUpgrade, - t, - workflowId, - ]) - - return { - deleteOperation, - options, - } -} - -export default useActionMenu diff --git a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx index 1ec4aeb8ab3..265336aac8d 100644 --- a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx @@ -36,7 +36,7 @@ const formatVersion = (versionHistory: VersionHistory, latestVersionId: string): } } -const VersionHistoryItem: React.FC = ({ +function VersionHistoryItem({ item, currentVersion, latestVersionId, @@ -45,7 +45,7 @@ const VersionHistoryItem: React.FC = ({ canImportExportDSL, isLast, hideActionMenu, -}) => { +}: VersionHistoryItemProps) { const { t } = useTranslation(['workflowHistory']) const [open, setOpen] = useState(false) @@ -76,7 +76,6 @@ const VersionHistoryItem: React.FC = ({ 'group relative flex gap-x-1 rounded-lg p-2', isSelected ? 'bg-state-accent-active' : 'hover:bg-state-base-hover', )} - onMouseLeave={() => setOpen(false)} onContextMenu={(e) => { if (hideActionMenu) return @@ -157,22 +156,15 @@ const VersionHistoryItem: React.FC = ({
{/* Action Menu */} {!hideActionMenu && !isDraft && ( -
- -
+ )}
) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 9f05067ddef..806c9703b10 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -3,8 +3,8 @@ import type { I18nKeysWithPrefix } from '@/types/i18n' import { ContextMenuContent, ContextMenuGroup, + ContextMenuGroupLabel, ContextMenuItem, - ContextMenuLabel, ContextMenuSeparator, } from '@langgenius/dify-ui/context-menu' import { produce } from 'immer' @@ -463,7 +463,8 @@ export function SelectionContextmenu({ onClose }: { onClose: () => void }) { @@ -476,9 +477,9 @@ export function SelectionContextmenu({ onClose }: { onClose: () => void }) { {menuSections.map((section, sectionIndex) => ( {sectionIndex > 0 && } - + {t(($) => $[section.titleKey], { defaultValue: section.titleKey, ns: 'workflow' })} - + {section.items.map((item) => { return ( { name: 'agentV2.agentDetail.configure.skills.moreActions:{"name":"Tender Analyzer"}', }), ) - expect(embeddedBadge).toHaveClass('opacity-0') const deleteAction = screen.getByText('common.operation.delete') fireEvent.mouseEnter(deleteAction.closest('[data-agent-skill-remove-button]')!) @@ -1307,7 +1306,6 @@ describe('AgentSkills', () => { name: 'agentV2.agentDetail.configure.skills.moreActions:{"name":"Refund approval"}', }), ) - expect(screen.getByText('refund-approval')).toHaveClass('opacity-0') const removeAction = await screen.findByText( 'agentV2.agentDetail.configure.skills.removeAction', diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx index 8ebe57f6129..617201e3320 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx @@ -356,7 +356,6 @@ function WorkspaceAgentSkillItem({ }) { const { t } = useTranslation(['agentV2']) const readOnly = useAgentOrchestrateReadOnly() - const [isActionsOpen, setIsActionsOpen] = useState(false) const [isRemoveHighlighted, setIsRemoveHighlighted] = useState(false) const displayName = skill.display_name || skill.name const handleOpenInLibrary = useCallback(() => { @@ -395,7 +394,7 @@ function WorkspaceAgentSkillItem({ className={cn( 'shrink-0 system-xs-regular text-text-tertiary', !readOnly && 'group-focus-within:opacity-0 group-hover:opacity-0', - isActionsOpen && 'opacity-0', + 'group-has-data-popup-open:opacity-0', )} > {skill.name} @@ -404,7 +403,6 @@ function WorkspaceAgentSkillItem({ { - setIsActionsOpen(open) if (!open) setIsRemoveHighlighted(false) }} > diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx index f07baf4883f..35b2ea3985a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx @@ -38,7 +38,6 @@ export function AgentSkillItem({ const { t: tCommon } = useTranslation(['common']) const queryClient = useQueryClient() const [isPreviewOpen, setIsPreviewOpen] = useState(false) - const [isActionsOpen, setIsActionsOpen] = useState(false) const [isRemoveHighlighted, setIsRemoveHighlighted] = useState(false) const handleRemove = useCallback(() => { onRemove(skill.id) @@ -147,7 +146,7 @@ export function AgentSkillItem({ className={cn( 'shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary', hasActions && 'group-focus-within:opacity-0 group-hover:opacity-0', - isActionsOpen && 'opacity-0', + 'group-has-data-popup-open:opacity-0', )} > {t(($) => $['agentDetail.configure.skills.addMenu.upload.badge'])} @@ -164,7 +163,6 @@ export function AgentSkillItem({ { - setIsActionsOpen(open) if (!open) setIsRemoveHighlighted(false) }} > diff --git a/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx b/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx index bf9ef727abe..40d6e7bbbed 100644 --- a/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx +++ b/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx @@ -6,7 +6,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, - DropdownMenuLabel, + DropdownMenuGroupLabel, DropdownMenuLinkItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' @@ -52,9 +52,9 @@ export function AgentWorkflowReferencesDropdown({ - + {t(($) => $['roster.references.label'], { ns: 'agentRoster', name: agentName })} - + {publishedReferences.map((reference) => { const { iconType, imageUrl } = getWorkflowReferenceIcon(reference) @@ -68,7 +68,7 @@ export function AgentWorkflowReferencesDropdown({ rel="noopener noreferrer" /> } - className="group mx-0 h-8 gap-2 px-2 py-1 pr-2.5 system-md-regular text-text-secondary" + className="group mx-0 gap-2 py-1 pr-2.5 system-md-regular text-text-secondary" >