fix(ui): align menu semantics and focus behavior (#43325)

This commit is contained in:
yyh
2026-10-01 15:22:26 +00:00
committed by GitHub
parent f004f6b67d
commit 0bd950fd01
98 changed files with 2230 additions and 2824 deletions

View File

@@ -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

View File

@@ -6,8 +6,8 @@ import {
ContextMenuCheckboxItemIndicator,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuLabel,
ContextMenuLinkItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
@@ -88,13 +88,13 @@ export const WithGroupLabel: Story = {
<TriggerArea />
<ContextMenuContent className="w-44">
<ContextMenuGroup>
<ContextMenuLabel>Actions</ContextMenuLabel>
<ContextMenuGroupLabel>Actions</ContextMenuGroupLabel>
<ContextMenuItem>Rename</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuLabel>Danger Zone</ContextMenuLabel>
<ContextMenuGroupLabel>Danger Zone</ContextMenuGroupLabel>
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>

View File

@@ -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 (
<BaseContextMenu.GroupLabel
className={(state) => 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,

View File

@@ -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<Payload>()
expectTypeOf(handle).toEqualTypeOf<DropdownMenuHandle<Payload>>()
const trigger = (
<DropdownMenuTrigger handle={handle} payload={{ documentId: 'document-1' }}>
Actions
</DropdownMenuTrigger>
)
const menu = (
<DropdownMenu handle={handle}>
{({ payload }) => {
expectTypeOf(payload).toEqualTypeOf<Payload | undefined>()
return <span>{payload?.documentId}</span>
}}
</DropdownMenu>
)
const invalid = (
// @ts-expect-error Detached triggers preserve the handle payload contract.
<DropdownMenuTrigger handle={handle} payload={{ documentId: 1 }}>
Actions
</DropdownMenuTrigger>
)
return { trigger, menu, invalid }
}

View File

@@ -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 = {
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuGroupLabel>Actions</DropdownMenuGroupLabel>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Export</DropdownMenuLabel>
<DropdownMenuGroupLabel>Export</DropdownMenuGroupLabel>
<DropdownMenuItem>Export as PDF</DropdownMenuItem>
<DropdownMenuItem>Export as CSV</DropdownMenuItem>
</DropdownMenuGroup>
@@ -276,7 +278,7 @@ function ComplexDemo() {
<TriggerButton label="Actions" />
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Edit</DropdownMenuLabel>
<DropdownMenuGroupLabel>Edit</DropdownMenuGroupLabel>
<DropdownMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
Rename
@@ -313,7 +315,7 @@ function ComplexDemo() {
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuGroupLabel>Sort by</DropdownMenuGroupLabel>
<DropdownMenuRadioGroup<SortOrder> value={sortOrder} onValueChange={setSortOrder}>
<DropdownMenuRadioItem<SortOrder> value="newest">
Newest first
@@ -348,3 +350,51 @@ function ComplexDemo() {
export const Complex: Story = {
render: () => <ComplexDemo />,
}
function DetachedTriggerDemo() {
const handle = React.useMemo(() => createDropdownMenuHandle<{ name: string }>(), [])
const [selected, setSelected] = React.useState('')
return (
<React.Fragment>
<DropdownMenuTrigger
handle={handle}
payload={{ name: 'Report' }}
className="rounded-lg border border-divider-subtle px-3 py-1.5 focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
Report actions
</DropdownMenuTrigger>
<DropdownMenu handle={handle}>
{({ payload }) => (
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuGroupLabel>{payload?.name}</DropdownMenuGroupLabel>
<DropdownMenuItem onClick={() => setSelected(payload?.name ?? '')}>
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
)}
</DropdownMenu>
<output aria-label="Archived document">{selected}</output>
</React.Fragment>
)
}
export const DetachedTrigger: Story = {
render: () => <DetachedTriggerDemo />,
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()
},
}

View File

@@ -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<Payload = unknown> = Menu.Handle<Payload>
type DropdownMenuProps<Payload = unknown> = Menu.Root.Props<Payload>
type DropdownMenuTriggerProps<Payload = unknown> = Menu.Trigger.Props<Payload>
@@ -104,9 +107,9 @@ function DropdownMenuCheckboxItemIndicator({
type DropdownMenuCheckboxItemIndicatorProps = Omit<Menu.CheckboxItemIndicator.Props, 'children'>
type DropdownMenuLabelProps = Menu.GroupLabel.Props
type DropdownMenuGroupLabelProps = Menu.GroupLabel.Props
function DropdownMenuLabel({ className, ...props }: DropdownMenuLabelProps) {
function DropdownMenuGroupLabel({ className, ...props }: DropdownMenuGroupLabelProps) {
return (
<Menu.GroupLabel
className={(state) => 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,

View File

@@ -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 = () => <span aria-hidden className="i-ri-edit-line" />
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(<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />)
// 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(<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />)
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(<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />)
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(<MenuItem name="Edit" Icon={TestEditIcon} />)
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(
<Menu
showDelete
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
/>,
)
// 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(
<Menu
showDelete={false}
showAccessConfig
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
openAccessConfig={vi.fn()}
/>,
)
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(
<Menu
showDelete={false}
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
/>,
)
// 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(
<Menu
showDelete
openRenameModal={openRenameModal}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
/>,
)
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(
<Menu
showDelete
openRenameModal={vi.fn()}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>,
)
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(
<Menu
showDelete={false}
showAccessConfig
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
openAccessConfig={openAccessConfig}
/>,
)
await user.click(screen.getByText('navigation.settings.resourceAccess'))
expect(openAccessConfig).toHaveBeenCalledTimes(1)
})
})
})
describe('Dropdown', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -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) => {
</IconButton>
}
/>
<DropdownMenuPortal>
<DropdownMenuPositioner placement={expand ? 'bottom-end' : 'right-start'} sideOffset={4}>
<DropdownMenuPopup>
<Menu
showEdit={datasetACLCapabilities.canEdit}
showDelete={datasetACLCapabilities.canDelete}
showExportPipeline={datasetACLCapabilities.canImportExportDSL}
showAccessConfig={datasetACLCapabilities.canAccessConfig}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
/>
</DropdownMenuPopup>
</DropdownMenuPositioner>
</DropdownMenuPortal>
<DropdownMenuContent
placement={expand ? 'bottom-end' : 'right-start'}
sideOffset={4}
className="w-50"
>
{datasetACLCapabilities.canEdit && (
<DropdownMenuItem onClick={openRenameModal}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
{t(($) => $['operation.edit'], { ns: 'common' })}
</DropdownMenuItem>
)}
{datasetACLCapabilities.canImportExportDSL && dataset.runtime_mode === 'rag_pipeline' && (
<DropdownMenuItem onClick={() => void handleExportPipeline()}>
<span aria-hidden className="i-ri-file-download-line size-4 text-text-tertiary" />
{t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
</DropdownMenuItem>
)}
{datasetACLCapabilities.canAccessConfig && (
<DropdownMenuItem onClick={openAccessConfig}>
<span aria-hidden className="i-ri-lock-2-line size-4 text-text-tertiary" />
{t(($) => $['settings.resourceAccess'], { ns: 'navigation' })}
</DropdownMenuItem>
)}
{datasetACLCapabilities.canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={detectIsUsedByApp}>
<span aria-hidden className="i-ri-delete-bin-line size-4 text-text-tertiary" />
{t(($) => $['operation.delete'], { ns: 'common' })}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}

View File

@@ -1,27 +0,0 @@
import type { RemixiconComponentType } from '@remixicon/react'
import * as React from 'react'
type MenuItemProps = {
name: string
Icon: RemixiconComponentType
handleClick?: () => void
}
const MenuItem = ({ Icon, name, handleClick }: MenuItemProps) => {
return (
<button
type="button"
className="flex appearance-none items-center gap-x-1 rounded-lg px-2 py-1.5 text-start outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleClick?.()
}}
>
<Icon aria-hidden className="size-4 text-text-tertiary" />
<span className="px-1 system-md-regular text-text-secondary">{name}</span>
</button>
)
}
export default React.memo(MenuItem)

View File

@@ -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 (
<div className="flex w-50 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
<div className="flex flex-col p-1">
{showEdit && (
<MenuItem
Icon={RiEditLine}
name={t(($) => $['operation.edit'], { ns: 'common' })}
handleClick={openRenameModal}
/>
)}
{showExportPipeline && runtimeMode === 'rag_pipeline' && (
<MenuItem
Icon={RiFileDownloadLine}
name={t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
handleClick={handleExportPipeline}
/>
)}
{showAccessConfig && (
<MenuItem
Icon={RiLock2Line}
name={t(($) => $['settings.resourceAccess'], { ns: 'navigation' })}
handleClick={openAccessConfig}
/>
)}
</div>
{showDelete && (
<>
<Separator orientation="horizontal" className="my-0 h-[0.5px] bg-divider-subtle" />
<div className="flex flex-col p-1">
<MenuItem
Icon={RiDeleteBinLine}
name={t(($) => $['operation.delete'], { ns: 'common' })}
handleClick={detectIsUsedByApp}
/>
</div>
</>
)}
</div>
)
}
export default React.memo(Menu)

View File

@@ -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 (
<DropdownMenuItem className="mx-0 flex gap-2 px-2 py-1.5" onClick={onClick}>
<span aria-hidden className="i-ri-instance-line size-4 shrink-0 text-text-tertiary" />
<span className="grow truncate system-md-regular text-text-secondary">
{environment.name}
</span>
</DropdownMenuItem>
)
}

View File

@@ -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 (
<DropdownMenuItem className="mx-0 gap-2 py-1.5" onClick={onClick}>
<span aria-hidden className="i-ri-instance-line size-4 shrink-0 text-text-tertiary" />
<span className="grow truncate system-md-regular text-text-secondary">
{environment.name}
</span>
</DropdownMenuItem>
)
}
export function PublisherEnvironmentTabs({
environments,
joinedEnvironmentIds,
@@ -150,11 +167,7 @@ export function PublisherEnvironmentTabs({
</button>
}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
className="w-42 rounded-xl p-0"
>
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-42 p-0">
{overflowEnvironments.length > 0 && (
<DropdownMenuGroup className="p-1">
{overflowEnvironments.map((environment) => (
@@ -167,13 +180,13 @@ export function PublisherEnvironmentTabs({
</DropdownMenuGroup>
)}
{overflowEnvironments.length > 0 && undeployedEnvironments.length > 0 && (
<DropdownMenuSeparator className="my-0 bg-divider-subtle" />
<DropdownMenuSeparator className="my-0" />
)}
{undeployedEnvironments.length > 0 && (
<DropdownMenuGroup className="p-1">
<DropdownMenuLabel className="px-2 py-1 system-xs-medium-uppercase text-text-tertiary">
<DropdownMenuGroupLabel className="px-2 py-1">
{t(($) => $['card.notDeployed'], { ns: 'deployments' })}
</DropdownMenuLabel>
</DropdownMenuGroupLabel>
{undeployedEnvironments.map((environment) => (
<EnvironmentMenuItem
key={environment.id}

View File

@@ -1,11 +1,13 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroupLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import { RiArrowDownSLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@@ -15,7 +17,7 @@ type VersionSelectorProps = {
onChange: (index: number) => void
}
const VersionSelector: React.FC<VersionSelectorProps> = ({ 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<VersionSelectorProps> = ({ 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"
>
<div className="flex h-5.5 items-center px-3 pl-3 system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['generate.versions'], { ns: 'appGeneration' })}
</div>
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
onChange(nextValue)
}}
>
<DropdownMenuGroupLabel className="flex h-5.5 items-center py-0">
{t(($) => $['generate.versions'], { ns: 'appGeneration' })}
</DropdownMenuGroupLabel>
{versions.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
className="h-7 rounded-lg px-2 system-sm-medium text-text-secondary"
className="h-7 system-sm-medium text-text-secondary"
title={option.label}
>
<div className="mr-1 grow truncate px-1 pl-1">{option.label}</div>
{value === option.value && (
<RiCheckLine className="size-4 shrink-0 text-text-accent" />
)}
<div className="mr-1 grow truncate px-1">{option.label}</div>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>

View File

@@ -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) && (
<div className="py-1">
<DropdownMenuGroup className="py-1">
{onCreateBlank && (
<DropdownMenuItem
className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary"
className="py-1 system-md-regular text-text-secondary"
onClick={onCreateBlank}
>
<span
@@ -80,7 +81,7 @@ export function CreateAppDropdown({
)}
{onCreateTemplate && (
<DropdownMenuItem
className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary"
className="py-1 system-md-regular text-text-secondary"
onClick={onCreateTemplate}
>
<span
@@ -92,20 +93,14 @@ export function CreateAppDropdown({
</span>
</DropdownMenuItem>
)}
</div>
</DropdownMenuGroup>
)}
{onImportDSL && (onCreateBlank || onCreateTemplate) && (
<div className="h-px bg-divider-subtle" />
<DropdownMenuSeparator className="my-0" />
)}
{onImportDSL && (
<div className="py-1">
<DropdownMenuItem
className={cn(
'h-auto items-start gap-1 rounded-lg px-2 py-1.5',
'hover:bg-state-base-hover focus:bg-state-base-hover',
)}
onClick={onImportDSL}
>
<DropdownMenuGroup className="py-1">
<DropdownMenuItem className="h-auto items-start py-1.5" onClick={onImportDSL}>
<span className="flex h-5 shrink-0 items-center py-0.5">
<span aria-hidden className="i-ri-file-upload-line size-4 text-text-secondary" />
</span>
@@ -118,7 +113,7 @@ export function CreateAppDropdown({
</span>
</span>
</DropdownMenuItem>
</div>
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -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)

View File

@@ -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"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="px-2 py-1 system-xs-medium-uppercase text-text-tertiary">
<DropdownMenuGroupLabel className="px-2 py-1">
{t(($) => $['card.notDeployed'])}
</DropdownMenuLabel>
</DropdownMenuGroupLabel>
{isLoading ? (
<LoadingPlaceholder className="h-7" />
) : isError ? (
@@ -68,17 +68,21 @@ export function EnvironmentDeployMenu({
<p role="alert" className="px-2 py-1.5 system-xs-regular text-text-destructive">
{t(($) => $['common.loadFailed'])}
</p>
<Button
type="button"
size="small"
variant="ghost"
loading={isRetrying}
className="gap-1 px-2"
<DropdownMenuItem
closeOnClick={false}
disabled={isRetrying}
className="mx-0 h-6 w-fit justify-center rounded-md text-xs font-medium text-components-button-ghost-text"
onClick={() => void refetchEnvironments()}
>
<span aria-hidden className="i-ri-reset-left-line size-3" />
<span>{tCommon(($) => $['operation.retry'])}</span>
</Button>
{isRetrying && (
<span
aria-hidden
className="i-ri-loader-2-line size-3 animate-spin motion-reduce:animate-none"
/>
)}
</DropdownMenuItem>
</div>
) : undeployedEnvironments.length === 0 ? (
<p role="status" className="px-2 py-1.5 system-xs-regular text-text-tertiary">
@@ -90,7 +94,7 @@ export function EnvironmentDeployMenu({
undeployedEnvironments.map((environment) => (
<DropdownMenuItem
key={environment.id}
className="mx-0 flex gap-2 px-2 py-1.5"
className="mx-0 gap-2 py-1.5"
onClick={() => onSelectEnvironment(environment)}
>
<span

View File

@@ -1,28 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ThemeSwitcher from '../theme-switcher'
const setTheme = vi.fn()
vi.mock('next-themes', () => ({
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(<ThemeSwitcher />)
await user.click(screen.getByRole('button', { name }))
expect(setTheme).toHaveBeenCalledWith(theme)
})
})

View File

@@ -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(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
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(
<InlineDeleteConfirm
title="Remove?"
confirmText="Confirm"
cancelText="Cancel"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
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(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
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(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
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(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
fireEvent.click(getByText('Yes'))
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onCancel).not.toHaveBeenCalled()
})
})
})

View File

@@ -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<typeof InlineDeleteConfirm>
export default meta
type Story = StoryObj<typeof meta>
const InlineDeleteConfirmDemo = (args: Story['args']) => {
const [visible, setVisible] = useState(true)
return (
<div className="flex flex-col items-start gap-3">
<button
type="button"
className="rounded-md border border-divider-subtle px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setVisible(true)}
>
Trigger inline confirm
</button>
{visible && (
<InlineDeleteConfirm
{...args}
onConfirm={() => {
console.log('✅ Confirm clicked')
setVisible(false)
}}
onCancel={() => {
console.log('❎ Cancel clicked')
setVisible(false)
}}
/>
)}
</div>
)
}
export const Playground: Story = {
render: (args) => <InlineDeleteConfirmDemo {...args} />,
}
export const WarningVariant: Story = {
render: (args) => <InlineDeleteConfirmDemo {...args} />,
args: {
variant: 'warning',
title: 'Archive conversation?',
confirmText: 'Archive',
cancelText: 'Keep',
},
}
export const InfoVariant: Story = {
render: (args) => <InlineDeleteConfirmDemo {...args} />,
args: {
variant: 'info',
title: 'Remove collaborator?',
confirmText: 'Remove',
cancelText: 'Keep',
},
}

View File

@@ -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<InlineDeleteConfirmProps> = ({
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 (
<div
role="group"
aria-labelledby={titleId}
aria-describedby={descriptionId}
className={cn(
'flex w-30 flex-col justify-center gap-1.5',
'rounded-[10px] border-[0.5px] border-components-panel-border-subtle',
'bg-components-panel-bg-blur px-2 pt-1.5 pb-2',
'backdrop-blur-[10px]',
'shadow-lg',
className,
)}
>
<div id={titleId} className="system-xs-semibold text-text-primary">
{titleText}
</div>
<div className="flex w-full items-center justify-center gap-1">
<Button size="small" variant="secondary" onClick={onCancel} className="flex-1">
{cancelTxt}
</Button>
<Button
size="small"
variant="primary"
tone={variant === 'delete' ? 'destructive' : 'default'}
onClick={onConfirm}
className="flex-1"
>
{confirmTxt}
</Button>
</div>
<span id={descriptionId} className="sr-only">
{t(($) => $['operation.confirmAction'], {
ns: 'common',
defaultValue: 'Please confirm your action.',
})}
</span>
</div>
)
}
InlineDeleteConfirm.displayName = 'InlineDeleteConfirm'
export default InlineDeleteConfirm

View File

@@ -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 (
<div className="inline-flex max-w-full min-w-0 items-center">
<div className="inline-flex max-w-full min-w-0 items-center gap-px">
<DropdownMenu>
<div className="relative min-w-0 flex-1">
<DropdownMenuTrigger className="flex min-h-8 min-w-0 cursor-pointer items-center rounded-l-lg border-none bg-components-input-bg-normal px-2 py-1 outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt">
@@ -54,7 +55,7 @@ function Sort({ order, value, items, onSelect }: Props) {
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
className="relative w-60 rounded-xl bg-components-panel-bg-blur p-0"
className="relative w-60 p-0"
>
<DropdownMenuRadioGroup
value={value}
@@ -66,7 +67,7 @@ function Sort({ order, value, items, onSelect }: Props) {
key={item.value}
value={item.value}
closeOnClick
className="mx-0 gap-2 rounded-lg px-2 py-1.5"
className="mx-0 gap-2 py-1.5"
>
<div
title={item.name}
@@ -74,12 +75,7 @@ function Sort({ order, value, items, onSelect }: Props) {
>
{item.name}
</div>
{value === item.value && (
<span
aria-hidden
className="i-ri-check-line size-4 shrink-0 text-util-colors-blue-light-blue-light-600"
/>
)}
<DropdownMenuRadioItemIndicator className="text-util-colors-blue-light-blue-light-600" />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@@ -89,7 +85,7 @@ function Sort({ order, value, items, onSelect }: Props) {
<button
type="button"
aria-label={t(($) => $[`filter.${order ? 'ascending' : 'descending'}`], { ns: 'appLog' })}
className="ml-px shrink-0 cursor-pointer rounded-r-lg border-none bg-components-button-tertiary-bg p-2 outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
className="shrink-0 cursor-pointer rounded-r-lg border-none bg-components-button-tertiary-bg p-2 outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => onSelect(`${order ? '' : '-'}${value}`)}
>
{!order && (

View File

@@ -1,68 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useTheme } from 'next-themes'
type Theme = 'light' | 'dark' | 'system'
export default function ThemeSwitcher() {
const { theme, setTheme } = useTheme()
const handleThemeChange = (newTheme: Theme) => {
setTheme(newTheme)
}
return (
<div className="flex items-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5">
<button
type="button"
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
theme === 'system' &&
'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => handleThemeChange('system')}
aria-label="System theme"
>
<div className="p-0.5">
<span className="i-ri-computer-line size-4" />
</div>
</button>
<div
className={cn('h-3.5 w-px bg-transparent', theme === 'dark' && 'bg-divider-regular')}
data-testid="divider"
></div>
<button
type="button"
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
theme === 'light' &&
'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => handleThemeChange('light')}
aria-label="Light theme"
>
<div className="p-0.5">
<span className="i-ri-sun-line size-4" />
</div>
</button>
<div
className={cn('h-3.5 w-px bg-transparent', theme === 'system' && 'bg-divider-regular')}
data-testid="divider"
></div>
<button
type="button"
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
theme === 'dark' &&
'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => handleThemeChange('dark')}
aria-label="Dark theme"
>
<div className="p-0.5">
<span className="i-ri-moon-line size-4" />
</div>
</button>
</div>
)
}

View File

@@ -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 (
<div
className={cn(
'absolute bottom-0 left-0 z-10 flex w-full items-center gap-x-1 bg-pipeline-template-card-hover-bg p-4 pt-8',
!isMoreOperationsOpen &&
'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100',
)}
>
<div className="pointer-events-none absolute bottom-0 left-0 z-10 flex w-full items-center gap-x-1 bg-pipeline-template-card-hover-bg p-4 pt-8 opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100">
<Button variant="primary" onClick={onApplyTemplate} className="grow">
<span aria-hidden className="i-ri-add-line size-4" />
<span>{t(($) => $['operations.choose'], { ns: 'datasetPipeline' })}</span>
@@ -51,29 +43,42 @@ const Actions = ({
}
/>
{showMoreOperations && (
<DropdownMenu open={isMoreOperationsOpen} onOpenChange={setIsMoreOperationsOpen}>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
className={cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3',
'data-popup-open:bg-state-base-hover',
'outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover',
)}
onClick={(e) => e.stopPropagation()}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuPositioner placement="bottom-end" sideOffset={4}>
<DropdownMenuPopup className="min-w-40">
<Operations
openEditModal={openEditModal}
onExport={handleExportDSL}
onDelete={handleDelete}
onClose={() => setIsMoreOperationsOpen(false)}
/>
</DropdownMenuPopup>
</DropdownMenuPositioner>
</DropdownMenuPortal>
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="min-w-40">
<DropdownMenuItem
onClick={() => {
openEditModal()
}}
>
{t(($) => $['operations.editInfo'], { ns: 'datasetPipeline' })}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleExportDSL()
}}
>
{t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
handleDelete()
}}
>
{t(($) => $['operation.delete'], { ns: 'common' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>

View File

@@ -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<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClose?.()
openEditModal()
}
const onClickExport = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClose?.()
onExport()
}
const onClickDelete = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClose?.()
onDelete()
}
return (
<div className="relative flex w-full flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
<div className="flex flex-col p-1">
<div
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={onClickEdit}
>
<span className="px-1 system-md-regular text-text-secondary">
{t(($) => $['operations.editInfo'], { ns: 'datasetPipeline' })}
</span>
</div>
<div
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={onClickExport}
>
<span className="px-1 system-md-regular text-text-secondary">
{t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
</span>
</div>
</div>
<Separator orientation="horizontal" className="my-0 h-[0.5px] bg-divider-subtle" />
<div className="flex flex-col p-1">
<div
className="group flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-destructive-hover"
onClick={onClickDelete}
>
<span className="px-1 system-md-regular text-text-secondary group-hover:text-text-destructive">
{t(($) => $['operation.delete'], { ns: 'common' })}
</span>
</div>
</div>
</div>
)
}
export default React.memo(Operations)

View File

@@ -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<HTMLElement>) => {
evt.preventDefault()
@@ -311,16 +312,12 @@ const Operations = ({
[canViewSettings, closeOperationsMenu, datasetId, detail.id, router],
)
const settingsMenuItem = (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={handleSettingsClick}
>
<DropdownMenuItem className={menuActionClassName} onClick={handleSettingsClick}>
<span aria-hidden className="i-ri-equalizer-2-line size-4 text-text-tertiary" />
<span className={s.actionName}>
<span className="text-sm text-text-secondary">
{t(($) => $['list.action.settings'], { ns: 'datasetDocuments' })}
</span>
</button>
</DropdownMenuItem>
)
const enabledLabel = `${t(($) => $['list.status.enabled'], { ns: 'datasetDocuments' })}: ${name}`
const renderListSwitch = () => {
@@ -417,141 +414,119 @@ const Operations = ({
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
className={cn('w-50 py-0', className)}
className={cn('w-50', className)}
>
<div className="w-full py-1">
{canShowPrimarySection && (
<>
{canShowRenameAction && (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={handleShowRename}
>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
<span className={s.actionName}>
{t(($) => $['list.table.rename'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
{canShowSummaryAction && (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={() => handleMenuOperation('summary')}
>
<span
aria-hidden
className="i-custom-vender-knowledge-search-lines-sparkle size-4 text-text-tertiary"
/>
<span className={s.actionName}>
{t(($) => $['list.action.summary'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
{canShowSettingsAction && settingsMenuItem}
{canShowDownloadAction && (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={handleDownloadClick}
>
<span
aria-hidden
className="i-ri-download-2-line size-4 text-text-tertiary"
/>
<span className={s.actionName}>
{t(($) => $['list.action.download'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
{canShowSyncAction && (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={() => handleMenuOperation('sync')}
>
<span
aria-hidden
className="i-ri-loop-left-line size-4 text-text-tertiary"
/>
<span className={s.actionName}>
{t(($) => $['list.action.sync'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
{(canShowStatusSection || canShowDeleteAction) && (
<DropdownMenuSeparator className="h-[0.5px] w-full shrink-0 bg-divider-regular" />
)}
</>
)}
{canShowPauseAction && (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={() => handleMenuOperation('pause')}
>
<span
aria-hidden
className="i-ri-pause-circle-line size-4 text-text-tertiary"
/>
<span className={s.actionName}>
{t(($) => $['list.action.pause'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
{canShowResumeAction && (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={() => handleMenuOperation('resume')}
>
<span aria-hidden className="i-ri-play-circle-line size-4 text-text-tertiary" />
<span className={s.actionName}>
{t(($) => $['list.action.resume'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
{canShowArchiveAction && (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={() => handleMenuOperation('archive')}
>
<span aria-hidden className="i-ri-archive-2-line size-4 text-text-tertiary" />
<span className={s.actionName}>
{t(($) => $['list.action.archive'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
{canShowUnarchiveAction && (
<button
type="button"
className={cn(menuActionClassName, 'text-left')}
onClick={() => handleMenuOperation('un_archive')}
>
<span aria-hidden className="i-ri-archive-2-line size-4 text-text-tertiary" />
<span className={s.actionName}>
{t(($) => $['list.action.unarchive'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
{canShowDeleteAction && (
<button
type="button"
className={cn(menuDeleteActionClassName, 'text-left')}
onClick={handleDeleteClick}
>
<span
aria-hidden
className="i-ri-delete-bin-line size-4 text-text-tertiary group-hover:text-text-destructive"
/>
<span className={cn(s.actionName, 'group-hover:text-text-destructive')}>
{t(($) => $['list.action.delete'], { ns: 'datasetDocuments' })}
</span>
</button>
)}
</div>
{canShowPrimarySection && (
<>
{canShowRenameAction && (
<DropdownMenuItem className={menuActionClassName} onClick={handleShowRename}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
<span className="text-sm text-text-secondary">
{t(($) => $['list.table.rename'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
{canShowSummaryAction && (
<DropdownMenuItem
className={menuActionClassName}
onClick={() => handleMenuOperation('summary')}
>
<span
aria-hidden
className="i-custom-vender-knowledge-search-lines-sparkle size-4 text-text-tertiary"
/>
<span className="text-sm text-text-secondary">
{t(($) => $['list.action.summary'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
{canShowSettingsAction && settingsMenuItem}
{canShowDownloadAction && (
<DropdownMenuItem className={menuActionClassName} onClick={handleDownloadClick}>
<span
aria-hidden
className="i-ri-download-2-line size-4 text-text-tertiary"
/>
<span className="text-sm text-text-secondary">
{t(($) => $['list.action.download'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
{canShowSyncAction && (
<DropdownMenuItem
className={menuActionClassName}
onClick={() => handleMenuOperation('sync')}
>
<span aria-hidden className="i-ri-loop-left-line size-4 text-text-tertiary" />
<span className="text-sm text-text-secondary">
{t(($) => $['list.action.sync'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
{(canShowStatusSection || canShowDeleteAction) && (
<DropdownMenuSeparator className="h-[0.5px] w-full shrink-0 bg-divider-regular" />
)}
</>
)}
{canShowPauseAction && (
<DropdownMenuItem
className={menuActionClassName}
onClick={() => handleMenuOperation('pause')}
>
<span aria-hidden className="i-ri-pause-circle-line size-4 text-text-tertiary" />
<span className="text-sm text-text-secondary">
{t(($) => $['list.action.pause'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
{canShowResumeAction && (
<DropdownMenuItem
className={menuActionClassName}
onClick={() => handleMenuOperation('resume')}
>
<span aria-hidden className="i-ri-play-circle-line size-4 text-text-tertiary" />
<span className="text-sm text-text-secondary">
{t(($) => $['list.action.resume'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
{canShowArchiveAction && (
<DropdownMenuItem
className={menuActionClassName}
onClick={() => handleMenuOperation('archive')}
>
<span aria-hidden className="i-ri-archive-2-line size-4 text-text-tertiary" />
<span className="text-sm text-text-secondary">
{t(($) => $['list.action.archive'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
{canShowUnarchiveAction && (
<DropdownMenuItem
className={menuActionClassName}
onClick={() => handleMenuOperation('un_archive')}
>
<span aria-hidden className="i-ri-archive-2-line size-4 text-text-tertiary" />
<span className="text-sm text-text-secondary">
{t(($) => $['list.action.unarchive'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
{canShowDeleteAction && (
<DropdownMenuItem
variant="destructive"
className={menuDeleteActionClassName}
onClick={handleDeleteClick}
>
<span
aria-hidden
className="i-ri-delete-bin-line size-4 text-text-tertiary group-data-highlighted:text-text-destructive"
/>
<span className="text-sm text-text-secondary group-data-highlighted:text-text-destructive">
{t(($) => $['list.action.delete'], { ns: 'datasetDocuments' })}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>

View File

@@ -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;
}

View File

@@ -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]',
})}
>
<Operations
showEdit={datasetACLCapabilities.canEdit}
showDelete={datasetACLCapabilities.canDelete}
showExportPipeline={
dataset.runtime_mode === 'rag_pipeline' && datasetACLCapabilities.canImportExportDSL
}
showAccessConfig={datasetACLCapabilities.canAccessConfig}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
/>
{datasetACLCapabilities.canEdit && (
<DropdownMenuItem className="gap-2" onClick={openRenameModal}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
{t(($) => $['operation.edit'], { ns: 'common' })}
</DropdownMenuItem>
)}
{dataset.runtime_mode === 'rag_pipeline' && datasetACLCapabilities.canImportExportDSL && (
<DropdownMenuItem className="gap-2" onClick={() => handleExportPipeline()}>
<span aria-hidden className="i-ri-file-download-line size-4 text-text-tertiary" />
{t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
</DropdownMenuItem>
)}
{datasetACLCapabilities.canAccessConfig && (
<DropdownMenuItem className="gap-2" onClick={openAccessConfig}>
<span aria-hidden className="i-ri-lock-line size-4 text-text-tertiary" />
{t(($) => $['settings.resourceAccess'], { ns: 'navigation' })}
</DropdownMenuItem>
)}
{datasetACLCapabilities.canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2" variant="destructive" onClick={detectIsUsedByApp}>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t(($) => $['operation.delete'], { ns: 'common' })}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -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 && (
<DropdownMenuItem onClick={handleRename}>
<span aria-hidden className="mr-1 i-ri-edit-line size-4 text-text-tertiary" />
{t(($) => $['operation.edit'], { ns: 'common' })}
</DropdownMenuItem>
)}
{showExportPipeline && (
<DropdownMenuItem onClick={handleExport}>
<span aria-hidden className="mr-1 i-ri-file-download-line size-4 text-text-tertiary" />
{t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
</DropdownMenuItem>
)}
{showAccessConfig && (
<DropdownMenuItem onClick={handleAccessConfig}>
<span aria-hidden className="mr-1 i-ri-lock-line size-4 text-text-tertiary" />
{t(($) => $['settings.resourceAccess'], { ns: 'navigation' })}
</DropdownMenuItem>
)}
{showDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<span aria-hidden className="mr-1 i-ri-delete-bin-line size-4" />
{t(($) => $['operation.delete'], { ns: 'common' })}
</DropdownMenuItem>
</>
)}
</>
)
}
export default React.memo(Operations)

View File

@@ -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 && (
<span aria-hidden className={cn('size-4 shrink-0 text-text-tertiary', iconClassName)} />
)}
<span
className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary"
title={labelTitle}
>
{label}
</span>
<DropdownMenuRadioItemIndicator />
</>
)
}
function AppearanceSubmenu() {
const { t } = useTranslation(['accountSettings'])
const { theme, setTheme } = useTheme()
const currentTheme: Theme =
theme === 'light' || theme === 'dark' || theme === 'system' ? theme : 'system'
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="mx-0 px-3 py-1">
<MenuItemContent
iconClassName="i-ri-sun-line"
label={t(($) => $['account.appearanceLabel'], { ns: 'accountSettings' })}
/>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
placement="right-start"
sideOffset={6}
className="max-h-90 w-34.75 px-1"
>
<DropdownMenuRadioGroup<Theme>
value={currentTheme}
onValueChange={(nextTheme) => setTheme(nextTheme)}
>
<DropdownMenuRadioItem<Theme> value="light" className="mx-0 py-1">
<MainNavRadioItemContent
iconClassName="i-ri-sun-line"
label={t(($) => $['account.appearanceLight'], { ns: 'accountSettings' })}
/>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem<Theme> value="dark" className="mx-0 py-1">
<MainNavRadioItemContent
iconClassName="i-ri-moon-line"
label={t(($) => $['account.appearanceDark'], { ns: 'accountSettings' })}
/>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem<Theme> value="system" className="mx-0 py-1">
<MainNavRadioItemContent
iconClassName="i-ri-computer-line"
label={t(($) => $['account.appearanceSystem'], { ns: 'accountSettings' })}
/>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
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 (
<>
<DropdownMenuGroup className="p-1">
<div className="flex items-center gap-3 rounded-xl bg-linear-to-b from-background-section-burn to-background-section p-3">
<div className="flex min-w-0 grow flex-col gap-1">
<div className="flex min-w-0 items-center gap-1">
<div
className="min-w-0 flex-1 truncate body-md-medium text-text-primary"
title={userProfile.name}
>
{userProfile.name}
</div>
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="shrink-0 px-2!">
<span aria-hidden className="mr-1 i-ri-graduation-cap-fill h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div
className="truncate system-xs-regular text-text-tertiary"
title={userProfile.email}
>
{userProfile.email}
</div>
</div>
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
size="lg"
className="shrink-0"
/>
</div>
</DropdownMenuGroup>
<DropdownMenuGroup className="p-1">
<DropdownMenuLinkItem
className="mx-0 justify-between px-3 py-1"
render={<Link href="/account" />}
>
<MenuItemContent
iconClassName="i-ri-account-circle-line"
label={t(($) => $['account.account'], { ns: 'accountSettings' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuLinkItem>
<DropdownMenuItem
className="mx-0 px-3 py-1"
onClick={() => setSettingsDestination('preferences')}
>
<MenuItemContent
iconClassName="i-ri-equalizer-2-line"
label={t(($) => $['settings.preferences'], { ns: 'navigation' })}
/>
</DropdownMenuItem>
<AppearanceSubmenu />
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0!" />
<DropdownMenuGroup className="p-1">
<DropdownMenuItem
className="mx-0 px-3 py-1"
onClick={() => {
onLogout()
}}
>
<MenuItemContent
iconClassName="i-ri-shut-down-line"
label={t(($) => $['userProfile.logout'], { ns: 'common' })}
/>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)
}
export default function AccountDropdown({ trigger }: AccountDropdownProps) {
const isHydrating = useSyncExternalStore(
subscribeHydrationState,

View File

@@ -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 && (
<span aria-hidden className={cn('size-4 shrink-0 text-text-tertiary', iconClassName)} />
)}
<span
className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary"
title={labelTitle}
>
{label}
</span>
<DropdownMenuRadioItemIndicator />
</>
)
}
function AppearanceSubmenu() {
const { t } = useTranslation(['accountSettings'])
const { theme, setTheme } = useTheme()
const currentTheme: Theme =
theme === 'light' || theme === 'dark' || theme === 'system' ? theme : 'system'
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="mx-0 h-8 gap-1 px-3 py-1">
<MenuItemContent
iconClassName="i-ri-sun-line"
label={t(($) => $['account.appearanceLabel'], { ns: 'accountSettings' })}
/>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
placement="right-start"
sideOffset={6}
className="max-h-90 w-34.75 bg-components-panel-bg-blur p-1 backdrop-blur-[5px]"
>
<DropdownMenuRadioGroup<Theme>
value={currentTheme}
onValueChange={(nextTheme) => setTheme(nextTheme)}
>
<DropdownMenuRadioItem<Theme>
value="light"
closeOnClick
className="mx-0 h-8 gap-1 px-2 py-1"
>
<MainNavRadioItemContent
iconClassName="i-ri-sun-line"
label={t(($) => $['account.appearanceLight'], { ns: 'accountSettings' })}
/>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem<Theme>
value="dark"
closeOnClick
className="mx-0 h-8 gap-1 px-2 py-1"
>
<MainNavRadioItemContent
iconClassName="i-ri-moon-line"
label={t(($) => $['account.appearanceDark'], { ns: 'accountSettings' })}
/>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem<Theme>
value="system"
closeOnClick
className="mx-0 h-8 gap-1 px-2 py-1"
>
<MainNavRadioItemContent
iconClassName="i-ri-computer-line"
label={t(($) => $['account.appearanceSystem'], { ns: 'accountSettings' })}
/>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
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 (
<>
<DropdownMenuGroup className="p-1">
<div className="flex items-center gap-3 rounded-xl bg-linear-to-b from-background-section-burn to-background-section p-3">
<div className="flex min-w-0 grow flex-col gap-1">
<div className="flex min-w-0 items-center gap-1">
<div
className="min-w-0 flex-1 truncate body-md-medium text-text-primary"
title={userProfile.name}
>
{userProfile.name}
</div>
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="shrink-0 px-2!">
<span aria-hidden className="mr-1 i-ri-graduation-cap-fill h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div
className="truncate system-xs-regular text-text-tertiary"
title={userProfile.email}
>
{userProfile.email}
</div>
</div>
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
size="lg"
className="shrink-0"
/>
</div>
</DropdownMenuGroup>
<DropdownMenuGroup className="p-1">
<DropdownMenuLinkItem
className="mx-0 h-8 justify-between gap-1 px-3 py-1"
render={<Link href="/account" />}
>
<MenuItemContent
iconClassName="i-ri-account-circle-line"
label={t(($) => $['account.account'], { ns: 'accountSettings' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuLinkItem>
<DropdownMenuItem
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => setSettingsDestination('preferences')}
>
<MenuItemContent
iconClassName="i-ri-equalizer-2-line"
label={t(($) => $['settings.preferences'], { ns: 'navigation' })}
/>
</DropdownMenuItem>
<AppearanceSubmenu />
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0! bg-divider-subtle" />
<DropdownMenuGroup className="p-1">
<DropdownMenuItem
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => {
onLogout()
}}
>
<MenuItemContent
iconClassName="i-ri-shut-down-line"
label={t(($) => $['userProfile.logout'], { ns: 'common' })}
/>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)
}

View File

@@ -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}
/>
</PermissionTooltipWrapper>
<PluginTasks
animatedSlot
dropdownAnchor={() => actionRowRef.current}
dropdownPlacement="bottom-start"
/>
<PluginTasks animatedSlot anchor={() => actionRowRef.current} placement="bottom-start" />
</div>
)
}

View File

@@ -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(<SortDropdown />)
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(<SortDropdown />)
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',

View File

@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger className="flex h-8 cursor-pointer items-center rounded-lg bg-state-base-hover-alt px-2 pr-3">
<span className="mr-1 system-sm-regular text-text-secondary">
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 cursor-pointer items-center gap-1 rounded-lg bg-state-base-hover-alt px-2 pr-3">
<span className="system-sm-regular text-text-secondary">
{t(($) => $['marketplace.sortBy'], { ns: 'plugin' })}
</span>
<span className="mr-1 system-sm-medium text-text-primary">{selectedOption.text}</span>
<span className="system-sm-medium text-text-primary">{selectedOption.text}</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" sideOffset={4} className="p-1">
{options.map((option) => (
<DropdownMenuItem
key={`${option.value}-${option.order}`}
className="justify-between px-3 pr-2 system-md-regular text-text-primary"
onClick={() => {
handleSortChange({ sortBy: option.value, sortOrder: option.order })
setOpen(false)
}}
>
{option.text}
{sort.sortBy === option.value && sort.sortOrder === option.order && (
<span aria-hidden className="ml-2 i-ri-check-line size-4 text-text-accent" />
)}
</DropdownMenuItem>
))}
<DropdownMenuContent placement="bottom-start" sideOffset={4} className="px-1">
<DropdownMenuRadioGroup
value={`${selectedOption.value}-${selectedOption.order}`}
onValueChange={(nextValue) => {
const option = options.find((option) => `${option.value}-${option.order}` === nextValue)
if (option) handleSortChange({ sortBy: option.value, sortOrder: option.order })
}}
>
{options.map((option) => (
<DropdownMenuRadioItem
key={`${option.value}-${option.order}`}
value={`${option.value}-${option.order}`}
closeOnClick
className="justify-between pl-3 system-md-regular text-text-primary"
>
{option.text}
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -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')
})

View File

@@ -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}
/>
<DropdownMenuTrigger
render={(props, state) => (
render={
<Button
{...props}
variant={triggerVariant}
disabled={disabled}
title={buttonLabel}
className={cn(
'size-full p-2',
triggerClassName,
state.open && triggerOpenClassName,
props.className,
)}
className={cn('size-full p-2 data-popup-open:bg-state-base-hover', triggerClassName)}
>
<RiAddCircleFill className="size-4 shrink-0" />
<span className={cn(!showTriggerArrow && 'min-w-0 flex-1 text-left')}>
@@ -179,28 +173,26 @@ const InstallPluginDropdown = ({
</span>
{showTriggerArrow && <RiArrowDownSLine className="size-4" />}
</Button>
)}
}
/>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
className={cn('w-50 pb-2', popupClassName)}
>
<span className="flex items-start self-stretch px-3 pt-1 pb-0.5 system-xs-medium-uppercase text-text-tertiary">
{t(($) => $.installFrom, { ns: 'plugin' })}
</span>
{installMethods.map(({ icon: Icon, text, action }) => (
<DropdownMenuItem
key={action}
className="gap-1 px-2"
onClick={() => handleInstallMethodSelect(action)}
>
<div className="flex items-center gap-1">
<Icon className="size-4 text-text-tertiary" />
<span className="px-1 system-md-regular text-text-secondary">{text}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuGroup>
<DropdownMenuGroupLabel>
{t(($) => $.installFrom, { ns: 'plugin' })}
</DropdownMenuGroupLabel>
{installMethods.map(({ icon: Icon, text, action }) => (
<DropdownMenuItem key={action} onClick={() => handleInstallMethodSelect(action)}>
<div className="flex items-center gap-1">
<Icon className="size-4 text-text-tertiary" />
<span className="px-1 system-md-regular text-text-secondary">{text}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</div>
{selectedAction === 'github' && (

View File

@@ -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(<PluginTasks />)
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(<PluginTasks dropdownPlacement="bottom-start" dropdownAnchor={() => document.body} />)
render(<PluginTasks placement="bottom-start" anchor={() => document.body} />)
fireEvent.click(getTaskMenuTrigger())

View File

@@ -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<PluginItemProps> = ({
function PluginItem({
plugin,
getIconUrl,
language,
@@ -23,7 +23,7 @@ const PluginItem: FC<PluginItemProps> = ({
statusClassName,
action,
onClear,
}) => {
}: PluginItemProps) {
const hasPluginIcon = !!plugin.icon
const pluginName = plugin.labels[language] || plugin.plugin_unique_identifier
@@ -55,7 +55,7 @@ const PluginItem: FC<PluginItemProps> = ({
<button
type="button"
aria-label={`Clear ${pluginName}`}
className="invisible flex size-6 shrink-0 items-center justify-center self-start rounded-md group-hover/item:visible hover:bg-state-base-hover-alt"
className="flex size-6 shrink-0 items-center justify-center self-start rounded-md opacity-0 outline-hidden group-hover/item:opacity-100 hover:bg-state-base-hover-alt focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-state-accent-solid [@media(hover:none)]:opacity-100"
onClick={onClear}
>
<span className="i-ri-close-line size-4 text-text-tertiary" />

View File

@@ -7,7 +7,7 @@
@apply cursor-default bg-components-panel-bg text-components-button-secondary-text shadow-none inset-ring-components-panel-border-subtle backdrop-blur-[5px];
}
&[data-menu-open] {
&[data-popup-open] {
@apply bg-components-button-secondary-bg-hover shadow-xs shadow-shadow-shadow-3 inset-ring-components-button-secondary-border-hover backdrop-blur-[5px];
}
@@ -15,7 +15,7 @@
@apply cursor-pointer bg-state-destructive-hover text-components-button-destructive-secondary-text shadow-xs shadow-shadow-shadow-3 inset-ring-components-button-destructive-secondary-border-hover backdrop-blur-[5px];
&:hover,
&[data-menu-open] {
&[data-popup-open] {
@apply bg-state-destructive-hover-alt;
}
}

View File

@@ -1,12 +1,12 @@
import type { DropdownMenuPositionerProps } from '@langgenius/dify-ui/dropdown-menu'
import type { PopoverPositionerProps } from '@langgenius/dify-ui/popover'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuPopup,
DropdownMenuPortal,
DropdownMenuPositioner,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
Popover,
PopoverPopup,
PopoverPortal,
PopoverPositioner,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
@@ -16,15 +16,11 @@ import { usePluginTaskStatus } from './hooks'
type PluginTasksProps = {
animatedSlot?: boolean
dropdownAnchor?: () => Element | null
dropdownPlacement?: DropdownMenuPositionerProps['placement']
anchor?: () => Element | null
placement?: PopoverPositionerProps['placement']
}
const PluginTasks = ({
animatedSlot = false,
dropdownAnchor,
dropdownPlacement = 'bottom',
}: PluginTasksProps) => {
const PluginTasks = ({ animatedSlot = false, anchor, placement = 'bottom' }: PluginTasksProps) => {
const { t } = useTranslation(['plugin'])
const [open, setOpen] = useState(false)
const {
@@ -44,7 +40,7 @@ const PluginTasks = ({
} = usePluginTaskStatus()
const { getIconUrl } = useGetIcon()
const hasPluginTasks = totalPluginsLength > 0
const canOpenMenu =
const canOpenPopover =
isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess
// Generate tooltip text based on status
@@ -124,8 +120,8 @@ const PluginTasks = ({
return (
<div className={rootClassName}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<TaskStatusIndicator
id="plugin-task-trigger"
@@ -137,18 +133,13 @@ const PluginTasks = ({
isFailed={isFailed}
successPluginsLength={successPluginsLength}
runningPluginsLength={runningPluginsLength}
data-menu-open={open ? '' : undefined}
/>
}
disabled={!canOpenMenu}
disabled={!canOpenPopover}
/>
<DropdownMenuPortal>
<DropdownMenuPositioner
placement={dropdownPlacement}
sideOffset={4}
anchor={dropdownAnchor}
>
<DropdownMenuPopup>
<PopoverPortal>
<PopoverPositioner placement={placement} sideOffset={4} anchor={anchor}>
<PopoverPopup aria-label={tip}>
<PluginTaskList
runningPlugins={runningPlugins}
successPlugins={successPlugins}
@@ -158,10 +149,10 @@ const PluginTasks = ({
onClearErrors={handleClearErrors}
onClearSingle={handleClearSingle}
/>
</DropdownMenuPopup>
</DropdownMenuPositioner>
</DropdownMenuPortal>
</DropdownMenu>
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
import PluginTasks from './index'
const service = vi.hoisted(() => ({ clear: vi.fn().mockResolvedValue({}), refetch: vi.fn() }))
vi.mock('@/service/use-plugins', () => ({
usePluginTaskList: () => ({
pluginTasks: [
{
id: 'task-1',
plugins: [
{
plugin_unique_identifier: 'plugin-1',
plugin_id: 'plugin-1',
source: PluginSource.marketplace,
status: TaskStatus.success,
message: '',
icon: '',
labels: { en_US: 'Installed Plugin' } as Record<string, string>,
} satisfies Omit<PluginStatus, 'taskId'>,
],
},
],
handleRefetch: service.refetch,
}),
useMutationClearTaskPlugin: () => ({ mutateAsync: service.clear }),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: () => null,
}))
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({ getIconUrl: () => '' }),
}))
vi.mock('@/context/i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('@/context/i18n')>()),
useGetLanguage: () => 'en_US',
}))
it('clears a completed plugin installation task using the keyboard and returns focus to installation status', async () => {
// Native Tab must include the button that CSS used to hide from keyboard users.
const screen = await render(<PluginTasks />)
const trigger = screen.getByRole('button', { name: /plugin.task.installSuccess/ })
await trigger.click()
const taskDetails = screen.getByRole('dialog')
await expect.element(taskDetails).toBeVisible()
await expect.element(screen.getByRole('menu')).not.toBeInTheDocument()
const clearAll = screen.getByRole('button', {
name: 'plugin.task.successPlugins plugin.task.clearAll',
})
clearAll.element().focus()
await userEvent.tab()
const clear = screen.getByRole('button', { name: 'Clear Installed Plugin' })
await expect.element(clear).toHaveFocus()
expect(clear.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard('{Enter}')
expect(service.clear).toHaveBeenCalledExactlyOnceWith({ taskId: 'task-1', pluginId: 'plugin-1' })
await expect.element(taskDetails).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
})

View File

@@ -1,6 +1,7 @@
import type { SiteInfo } from '@/models/share'
import type { WebAppAddress } from '@/service/webapp-address'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import MenuDropdown from '../menu-dropdown'
@@ -26,6 +27,9 @@ vi.mock('../info-modal', () => ({
},
}))
const mockSetTheme = vi.fn()
vi.mock('next-themes', () => ({ useTheme: () => ({ theme: 'system', setTheme: mockSetTheme }) }))
const mockReplace = vi.fn()
const mockPathname = '/test-path'
vi.mock('@/next/navigation', () => ({
@@ -108,6 +112,23 @@ describe('MenuDropdown', () => {
})
})
it('exposes checked theme items and keeps the menu open after changing the theme', async () => {
const user = userEvent.setup()
render(<MenuDropdown data={baseSiteInfo} />)
const trigger = screen.getByRole('button')
await user.click(trigger)
expect(screen.getByRole('menuitemradio', { name: 'common.theme.auto' })).toHaveAttribute(
'aria-checked',
'true',
)
const light = screen.getByRole('menuitemradio', { name: 'common.theme.light' })
await user.click(light)
expect(mockSetTheme).toHaveBeenCalledWith('light', expect.anything())
expect(screen.getByRole('menu')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
})
describe('privacy policy link', () => {
it('should show privacy policy link when provided', async () => {
const siteInfoWithPrivacy: SiteInfo = {

View File

@@ -1,20 +1,22 @@
'use client'
import type { DropdownMenuContentProps } from '@langgenius/dify-ui/dropdown-menu'
import type { FC } from 'react'
import type { SiteInfo } from '@/models/share'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useTheme } from 'next-themes'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
@@ -29,7 +31,7 @@ type Props = Readonly<
}
>
const MenuDropdown: FC<Props> = ({ data, placement, hideLogout }) => {
function MenuDropdown({ data, placement, hideLogout }: Props) {
const webAppAccessMode = useWebAppStore((s) => s.webAppAccessMode)
const router = useRouter()
const pathname = usePathname()
@@ -40,6 +42,7 @@ const MenuDropdown: FC<Props> = ({ data, placement, hideLogout }) => {
router.replace(`/webapp-signin?redirect_url=${pathname}`)
}
const { theme, setTheme } = useTheme()
const [show, setShow] = useState(false)
const handleOpenInfoModal = () => {
queueMicrotask(() => {
@@ -62,16 +65,63 @@ const MenuDropdown: FC<Props> = ({ data, placement, hideLogout }) => {
}
/>
<DropdownMenuContent placement={placement || 'bottom-end'} sideOffset={4} className="w-56">
<div className="px-3 py-1.5 system-md-regular text-text-secondary">
<div className="flex items-center gap-2">
<div className="grow">{t(($) => $['theme.theme'], { ns: 'common' })}</div>
<ThemeSwitcher />
<DropdownMenuRadioGroup
value={theme ?? 'system'}
onValueChange={setTheme}
className="flex items-center gap-2 px-3 system-md-regular text-text-secondary"
>
<DropdownMenuGroupLabel className="grow px-0 py-0 system-md-regular text-text-secondary normal-case">
{t(($) => $['theme.theme'], { ns: 'common' })}
</DropdownMenuGroupLabel>
<div className="flex items-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5">
{(['system', 'light', 'dark'] as const).map((value, index) => (
<React.Fragment key={value}>
{index > 0 && (
<div
aria-hidden
className={
(index === 1 && theme === 'dark') ||
(index === 2 && (theme ?? 'system') === 'system')
? 'h-3.5 w-px bg-divider-regular'
: 'h-3.5 w-px bg-transparent'
}
/>
)}
<DropdownMenuRadioItem
value={value}
aria-label={t(
($) =>
$[
value === 'system'
? 'theme.auto'
: value === 'light'
? 'theme.light'
: 'theme.dark'
],
{ ns: 'common' },
)}
className="mx-0 h-auto py-1 text-text-tertiary data-checked:bg-components-segmented-control-item-active-bg data-checked:text-text-accent-light-mode-only data-checked:shadow-sm data-highlighted:text-text-secondary data-checked:data-highlighted:bg-components-segmented-control-item-active-bg data-checked:data-highlighted:text-text-accent-light-mode-only"
>
<span aria-hidden className="p-0.5">
<span
className={
value === 'system'
? 'i-ri-computer-line size-4'
: value === 'light'
? 'i-ri-sun-line size-4'
: 'i-ri-moon-line size-4'
}
/>
</span>
</DropdownMenuRadioItem>
</React.Fragment>
))}
</div>
</div>
<DropdownMenuSeparator className="my-0" />
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{data?.privacy_policy && (
<DropdownMenuLinkItem
className="px-3 system-md-regular"
className="system-md-regular"
href={data.privacy_policy}
target="_blank"
rel="noreferrer"
@@ -81,7 +131,7 @@ const MenuDropdown: FC<Props> = ({ data, placement, hideLogout }) => {
</span>
</DropdownMenuLinkItem>
)}
<DropdownMenuItem className="px-3 system-md-regular" onClick={handleOpenInfoModal}>
<DropdownMenuItem className="system-md-regular" onClick={handleOpenInfoModal}>
{t(($) => $['userProfile.about'], { ns: 'common' })}
</DropdownMenuItem>
{!(
@@ -89,7 +139,7 @@ const MenuDropdown: FC<Props> = ({ data, placement, hideLogout }) => {
webAppAccessMode === AccessMode.EXTERNAL_MEMBERS ||
webAppAccessMode === AccessMode.PUBLIC
) && (
<DropdownMenuItem className="px-3 system-md-regular" onClick={handleLogout}>
<DropdownMenuItem className="system-md-regular" onClick={handleLogout}>
{t(($) => $['userProfile.logout'], { ns: 'common' })}
</DropdownMenuItem>
)}

View File

@@ -10,7 +10,6 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -188,14 +187,7 @@ const SnippetCard = ({
</div>
</div>
{canShowOperations && (
<div
className={cn(
'absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="pointer-events-none absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100">
<div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular" />
<DropdownMenu
modal={false}

View File

@@ -1,5 +1,5 @@
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '../types'
import { act, screen } from '@testing-library/react'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { renderWithAccountProfile } from '@/test/console/account-profile'
@@ -136,6 +136,59 @@ describe('Comment thread focus', () => {
expect(onPositionUpdate).toHaveBeenCalledTimes(2)
})
it('confirms reply deletion outside the menu and restores focus when cancelled', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onDelete = vi.fn()
const onClose = vi.fn()
const comment = createComment()
comment.replies = [
{
id: 'reply-1',
content: 'A reply',
created_at: 2,
created_by: 'user-1',
created_by_account: comment.created_by_account,
},
]
renderWithAccountProfile(
<CommentThread
comment={comment}
onClose={onClose}
onReply={() => {}}
onReplyEdit={() => {}}
onReplyDeleteDirect={onDelete}
/>,
)
await act(() => vi.advanceTimersByTimeAsync(100))
const trigger = screen.getByRole('button', {
name: 'workflowComments.comments.aria.replyActions',
})
await user.click(trigger)
const deleteItem = screen.getByRole('menuitem', {
name: 'workflowComments.comments.actions.deleteReply',
})
await user.click(deleteItem)
const confirm = await screen.findByRole('alertdialog', {
name: 'workflowComments.comments.actions.deleteReply',
})
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(onDelete).not.toHaveBeenCalled()
await user.click(within(confirm).getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(trigger).toHaveFocus())
expect(onClose).not.toHaveBeenCalled()
await user.click(trigger)
await user.click(
await screen.findByRole('menuitem', {
name: 'workflowComments.comments.actions.deleteReply',
}),
)
const reopened = await screen.findByRole('alertdialog')
await user.click(within(reopened).getByRole('button', { name: 'common.operation.delete' }))
expect(onDelete).toHaveBeenCalledExactlyOnceWith('reply-1')
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(onClose).not.toHaveBeenCalled()
})
it('focuses the reply input when navigating to another comment', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onReply = vi.fn()

View File

@@ -0,0 +1,172 @@
import type { WorkflowCommentDetail } from './types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Suspense, useState } from 'react'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { CommentThread } from './thread'
vi.mock('@/next/navigation', () => ({ useParams: () => ({ appId: 'app-1' }) }))
vi.mock('reactflow', () => ({
useReactFlow: () => ({ flowToScreenPosition: (position: { x: number; y: number }) => position }),
useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
}))
const store = vi.hoisted(() => ({
mentionableUsersCache: { 'app-1': [] },
setCommentPreviewHovering: () => {},
}))
vi.mock('../store', () => ({
useStore: (selector: (state: typeof store) => unknown) => selector(store),
useWorkflowStore: () => ({ getState: () => store }),
}))
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({
queryKey: ['profile'],
queryFn: async () => ({ profile: { id: 'author', name: 'Alice', avatar_url: null } }),
}),
}))
const author = { id: 'author', name: 'Alice', email: 'alice@example.com', avatar_url: null }
const comment: WorkflowCommentDetail = {
id: 'comment',
content: 'Review this node',
created_by: 'author',
created_by_account: author,
position_x: 100,
position_y: 100,
created_at: 1,
updated_at: 1,
resolved: false,
mentions: [],
replies: [
{
id: 'reply',
content: 'A reply',
created_by: 'author',
created_by_account: author,
created_at: 2,
},
],
}
function Fixture({
onDelete,
detail = comment,
loading = false,
}: {
onDelete: (id: string) => Promise<void> | void
detail?: WorkflowCommentDetail
loading?: boolean
}) {
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
)
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={<p>Loading profile</p>}>
<button type="button">Outside thread</button>
<div id="workflow-container" style={{ position: 'relative', width: 1000, height: 650 }}>
<CommentThread
comment={detail}
loading={loading}
onClose={() => {}}
onReply={() => {}}
onReplyEdit={() => {}}
onReplyDeleteDirect={onDelete}
/>
</div>
</Suspense>
</QueryClientProvider>
)
}
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(<Fixture onDelete={onDelete} />)
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<void>((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 (
<Fixture
detail={detail}
loading={loading}
onDelete={async (id) => {
setLoading(true)
onDelete(id)
await deletion.promise
await refetch.promise
setDetail({ ...comment, replies: [] })
setLoading(false)
}}
/>
)
}
const screen = await render(<DeletionFixture />)
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()
})

View File

@@ -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 }) => (
<button type="button" data-testid="confirm-delete-reply" onClick={onConfirm}>
confirm delete
</button>
),
}))
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')
})

File diff suppressed because it is too large Load Diff

View File

@@ -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> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,
name: 'User Input',
icon: <span>icon</span>,
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(
<DropdownMenu open>
<DropdownMenuContent>
<OptionRow option={option} shortcutKey="1" onSelect={onSelect} />
</DropdownMenuContent>
</DropdownMenu>,
)
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 () => {

View File

@@ -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<KeyboardEvent, 'key'>) => {
return event.key === '`' ? '~' : event.key
}
export const OptionRow = ({
option,
shortcutKey,
onSelect,
}: {
option: TriggerOption
shortcutKey?: string
onSelect: (option: TriggerOption) => void
}) => {
return (
<DropdownMenuItem
className="h-auto px-3 py-1.5 system-md-regular"
onClick={() => onSelect(option)}
>
<div className="flex min-w-0 flex-1 items-center">
<div className="flex size-6 shrink-0 items-center justify-center">{option.icon}</div>
<span className="ml-2 truncate">{option.name}</span>
</div>
{shortcutKey && (
<ShortcutKbd displayKey={shortcutKey} className="ml-2" textColor="secondary" />
)}
</DropdownMenuItem>
)
}
export function handleShortcutMenuKeyDown(
event: KeyboardEvent,
shortcutMappings: ShortcutMapping[],

View File

@@ -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 (
<DropdownMenuItem
className="h-auto gap-3 px-3 py-1.5 system-md-regular"
onClick={() => onSelect(option)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex size-6 shrink-0 items-center justify-center">{option.icon}</div>
<span className="truncate">{option.name}</span>
</div>
{shortcutKey && <ShortcutKbd displayKey={shortcutKey} textColor="secondary" />}
</DropdownMenuItem>
)
}
const getEnabledOptions = (options: TestRunOptions) => {
const flattened: TriggerOption[] = []
@@ -169,12 +194,12 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(
placement="bottom-start"
sideOffset={8}
alignOffset={-4}
className="w-71 p-1"
className="w-71 px-1"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="mb-1 px-3 pt-2 text-sm font-medium text-text-primary">
<DropdownMenuGroup className="flex flex-col gap-1">
<DropdownMenuGroupLabel className="pt-2 text-sm font-medium text-text-primary">
{t(($) => $['common.chooseStartNodeToRun'], { ns: 'workflow' })}
</DropdownMenuLabel>
</DropdownMenuGroupLabel>
<div>
{hasUserInput && renderOption(options.userInput!)}

View File

@@ -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<string, unknown>
} = {}) {
return renderWorkflowFlowComponent(
<DropdownMenu open>
<DropdownMenuTrigger render={<button type="button">open</button>} />
<DropdownMenuPortal>
<NodeActionsDropdownContent
id="node-1"
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '', ...data } as any}
onClose={onClose}
showHelpLink={showHelpLink}
/>
</DropdownMenuPortal>
</DropdownMenu>,
const result = renderWorkflowFlowComponent(
<NodeActionsDropdown
id="node-1"
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '', ...data } as any}
onOpenChange={(open) => !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<typeof useNodeMetaData>)
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(
<DropdownMenu open>
<DropdownMenuTrigger render={<button type="button">open</button>} />
<DropdownMenuPortal>
<NodeActionsDropdownContent
id="node-2"
data={
{
type: BlockEnum.Tool,
title: 'Workflow Tool',
desc: '',
provider_type: 'workflow',
provider_id: 'workflow-tool',
} as any
}
onClose={vi.fn()}
showHelpLink={false}
/>
</DropdownMenuPortal>
</DropdownMenu>,
<NodeActionsDropdown
id="node-2"
data={
{
type: BlockEnum.Tool,
title: 'Workflow Tool',
desc: '',
provider_type: 'workflow',
provider_id: 'workflow-tool',
} as any
}
showHelpLink={false}
/>,
{
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<typeof useNodeMetaData>)
rerender(
<DropdownMenu open>
<DropdownMenuTrigger render={<button type="button">open</button>} />
<DropdownMenuPortal>
<NodeActionsDropdownContent
id="node-3"
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
onClose={vi.fn()}
showHelpLink={false}
/>
</DropdownMenuPortal>
</DropdownMenu>,
<NodeActionsDropdown
id="node-3"
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
showHelpLink={false}
/>,
)
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()

View File

@@ -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 && (
<ContextMenuGroup>
<ContextMenuItem
className={NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME}
variant="destructive"
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={handleDelete}
>
<NodeActionsMenuItemContent shortcut="workflow.delete">

View File

@@ -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 (
<DropdownMenuPositioner placement="bottom-end">
<DropdownMenuPopup
finalFocus={() => (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 && (
<DropdownMenuGroup>
{model.canRun && (
<DropdownMenuItem onClick={model.handleRun}>{singleRunActionLabel}</DropdownMenuItem>
)}
{model.canChangeBlock && (
<Popover modal="trap-focus">
<DropdownMenuItem
closeOnClick={false}
render={<PopoverTrigger nativeButton={false} render={<div />} />}
className="data-popup-open:bg-state-base-hover"
>
{t(($) => $['panel.changeBlock'], { ns: 'workflow' })}
</DropdownMenuItem>
<PopoverPortal>
<PopoverBackdrop />
<PopoverPositioner placement="right-start" positionMethod="fixed">
<ChangeBlockPopup
nodeId={model.id}
nodeData={model.data}
sourceHandle={model.sourceHandle}
onComplete={props.onClose}
/>
</PopoverPositioner>
</PopoverPortal>
</Popover>
)}
</DropdownMenuGroup>
)}
{hasRunGroup &&
(hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && (
<DropdownMenuSeparator />
)}
{hasEditGroup && (
<DropdownMenuGroup>
<DropdownMenuItem
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleCopy}
>
<NodeActionsMenuItemContent shortcut="workflow.copy">
{t(($) => $['common.copy'], { ns: 'workflow' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
<DropdownMenuItem
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleDuplicate}
>
<NodeActionsMenuItemContent shortcut="workflow.duplicate">
{t(($) => $['common.duplicate'], { ns: 'workflow' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
{hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && (
<DropdownMenuSeparator />
)}
{hasDeleteGroup && (
<DropdownMenuGroup>
<DropdownMenuItem
className={NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME}
onClick={handleDelete}
>
<NodeActionsMenuItemContent shortcut="workflow.delete">
{t(($) => $['operation.delete'], { ns: 'common' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
{hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && (
<DropdownMenuSeparator />
)}
{model.workflowAppHref && (
<DropdownMenuGroup>
<DropdownMenuLinkItem
href={model.workflowAppHref}
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['panel.openWorkflow'], { ns: 'workflow' })}
</DropdownMenuLinkItem>
</DropdownMenuGroup>
)}
{model.workflowAppHref && model.helpLinkUri && <DropdownMenuSeparator />}
{model.helpLinkUri && (
<DropdownMenuGroup>
<DropdownMenuLinkItem
href={model.helpLinkUri}
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['panel.helpLink'], { ns: 'workflow' })}
</DropdownMenuLinkItem>
</DropdownMenuGroup>
)}
<DropdownMenuSeparator />
<NodeActionsMenuAbout
title={t(($) => $['panel.about'], { ns: 'workflow' })}
description={model.about.description}
author={`${t(($) => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`}
/>
</DropdownMenuPopup>
</DropdownMenuPositioner>
)
}

View File

@@ -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({
</DropdownMenu>
)
}
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 (
<DropdownMenuPositioner placement="bottom-end">
<DropdownMenuPopup
finalFocus={() => (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 && (
<DropdownMenuGroup>
{model.canRun && (
<DropdownMenuItem onClick={model.handleRun}>{singleRunActionLabel}</DropdownMenuItem>
)}
{model.canChangeBlock && (
<Popover modal="trap-focus">
<DropdownMenuItem
closeOnClick={false}
render={<PopoverTrigger nativeButton={false} render={<div />} />}
className="data-popup-open:bg-state-base-hover"
>
{t(($) => $['panel.changeBlock'], { ns: 'workflow' })}
</DropdownMenuItem>
<PopoverPortal>
<PopoverBackdrop />
<PopoverPositioner placement="right-start" positionMethod="fixed">
<ChangeBlockPopup
nodeId={model.id}
nodeData={model.data}
sourceHandle={model.sourceHandle}
onComplete={props.onClose}
/>
</PopoverPositioner>
</PopoverPortal>
</Popover>
)}
</DropdownMenuGroup>
)}
{hasRunGroup &&
(hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && (
<DropdownMenuSeparator />
)}
{hasEditGroup && (
<DropdownMenuGroup>
<DropdownMenuItem
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleCopy}
>
<NodeActionsMenuItemContent shortcut="workflow.copy">
{t(($) => $['common.copy'], { ns: 'workflow' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
<DropdownMenuItem
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={model.handleDuplicate}
>
<NodeActionsMenuItemContent shortcut="workflow.duplicate">
{t(($) => $['common.duplicate'], { ns: 'workflow' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
{hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && (
<DropdownMenuSeparator />
)}
{hasDeleteGroup && (
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
onClick={handleDelete}
>
<NodeActionsMenuItemContent shortcut="workflow.delete">
{t(($) => $['operation.delete'], { ns: 'common' })}
</NodeActionsMenuItemContent>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
{hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && (
<DropdownMenuSeparator />
)}
{model.workflowAppHref && (
<DropdownMenuGroup>
<DropdownMenuLinkItem
href={model.workflowAppHref}
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['panel.openWorkflow'], { ns: 'workflow' })}
</DropdownMenuLinkItem>
</DropdownMenuGroup>
)}
{model.workflowAppHref && model.helpLinkUri && <DropdownMenuSeparator />}
{model.helpLinkUri && (
<DropdownMenuGroup>
<DropdownMenuLinkItem
href={model.helpLinkUri}
target="_blank"
rel="noopener noreferrer"
>
{t(($) => $['panel.helpLink'], { ns: 'workflow' })}
</DropdownMenuLinkItem>
</DropdownMenuGroup>
)}
<DropdownMenuSeparator />
<NodeActionsMenuAbout
title={t(($) => $['panel.about'], { ns: 'workflow' })}
description={model.about.description}
author={`${t(($) => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`}
/>
</DropdownMenuPopup>
</DropdownMenuPositioner>
)
}

View File

@@ -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,

View File

@@ -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}
<RiArrowDownSLine className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
className="w-70 rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-70 p-1">
<DropdownMenuRadioGroup value={value} onValueChange={onSelected}>
{availableOptions.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
className="h-auto items-start rounded-lg p-2 pr-3"
className="h-auto items-start p-2 pr-3"
onClick={(e) => {
e.stopPropagation()
}}
>
<div className="mr-1 w-4 shrink-0">
{value === option.value && <RiCheckLine className="size-4 text-text-accent" />}
<DropdownMenuRadioItemIndicator />
</div>
<div className="grow">
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>

View File

@@ -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 })
})

View File

@@ -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<OperationSelectorProps> = ({
function OperationSelector({
value,
onSelect,
disabled = false,
@@ -38,7 +39,7 @@ const OperationSelector: FC<OperationSelectorProps> = ({
writeModeTypes,
writeModeTypesArr,
writeModeTypesNum,
}) => {
}: OperationSelectorProps) {
const { t } = useTranslation(['workflowLogic'])
const items = getOperationItems(
@@ -86,31 +87,38 @@ const OperationSelector: FC<OperationSelectorProps> = ({
className={cn('w-35', popupClassName)}
>
<DropdownMenuGroup>
<DropdownMenuLabel>
<DropdownMenuGroupLabel>
{t(($) => $['nodes.assigner.operations.title'], { ns: 'workflowLogic' })}
</DropdownMenuLabel>
{items.map((item) =>
!isOperationItem(item) ? (
<DropdownMenuSeparator key="divider" />
) : (
<DropdownMenuItem
key={item.value}
className="gap-1 px-2 py-1"
onClick={() => onSelect(item)}
>
<div className="flex min-h-5 grow items-center gap-1 px-1">
<span className="flex grow system-sm-medium text-text-secondary">
{t(($) => $[`nodes.assigner.operations.${item.name}`], { ns: 'workflowLogic' })}
</span>
</div>
{item.value === value && (
<div className="flex items-center justify-center">
<span aria-hidden className="i-ri-check-line size-4 text-text-accent" />
</DropdownMenuGroupLabel>
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
const item = items.find((item) => isOperationItem(item) && item.value === nextValue)
if (item && isOperationItem(item)) onSelect(item)
}}
>
{items.map((item) =>
!isOperationItem(item) ? (
<DropdownMenuSeparator key="divider" />
) : (
<DropdownMenuRadioItem
key={item.value}
className="py-1"
value={item.value}
closeOnClick
>
<div className="flex min-h-5 grow items-center gap-1 px-1">
<span className="flex grow system-sm-medium text-text-secondary">
{t(($) => $[`nodes.assigner.operations.${item.name}`], {
ns: 'workflowLogic',
})}
</span>
</div>
)}
</DropdownMenuItem>
),
)}
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
),
)}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -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}
<RiArrowDownSLine className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
className="w-70 rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-70 p-1">
<DropdownMenuRadioGroup value={value} onValueChange={onSelect}>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.key}
value={option.key}
closeOnClick
className="h-auto items-start rounded-lg p-2 pr-3"
className="h-auto items-start p-2 pr-3"
>
<div className="w-4 shrink-0">
{option.key === value && <RiCheckLine className="size-4 text-text-accent" />}
<DropdownMenuRadioItemIndicator />
</div>
<div className="grow">
<div className="system-sm-semibold text-text-secondary">{option.value}</div>

View File

@@ -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,
)
})

View File

@@ -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(
<DropdownMenu open onOpenChange={vi.fn()}>
<DropdownMenuContent>
<ActionMenuItem
item={{
key: VersionHistoryContextMenuOptions.delete,
name: 'Delete',
}}
isDestructive
onClick={onClick}
/>
</DropdownMenuContent>
</DropdownMenu>,
)
await user.click(screen.getByText('Delete'))
expect(onClick).toHaveBeenCalledWith(VersionHistoryContextMenuOptions.delete)
})
})

View File

@@ -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(<NuqsTestingAdapter>{ui}</NuqsTestingAdapter>, { queryClient })
return renderWorkflowComponent(<NuqsTestingAdapter>{ui}</NuqsTestingAdapter>, {
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(
<ActionMenu
workflowId="version-1"
isNamedVersion={false}
isShowDelete={false}
canImportExportDSL={canImportExportDSL}
open
setOpen={vi.fn()}
handleClickActionMenuItem={vi.fn()}
/>,
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()
})
})

View File

@@ -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,
])
})
})

View File

@@ -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<ActionMenuItemProps> = ({ item, onClick, isDestructive = false }) => {
return (
<DropdownMenuItem
disabled={item.disabled}
variant={isDestructive ? 'destructive' : 'default'}
className={cn(
'justify-between gap-x-3 px-2 py-1.5 whitespace-nowrap',
item.description && 'h-auto py-1',
isDestructive && 'data-highlighted:bg-state-destructive-hover',
)}
onClick={(event) => {
event.stopPropagation()
const target = event.target
if (target instanceof Element && target.closest('[data-upgrade-action]')) return
onClick(item.key)
}}
>
<div
className={cn(
'min-w-0 flex-1 px-1 py-0.5 system-md-regular whitespace-nowrap text-text-primary',
item.description && 'flex flex-col gap-y-0.5 text-text-secondary',
isDestructive && 'text-inherit',
)}
>
<div className="w-full truncate">{item.name}</div>
{item.description && (
<div
className="w-full max-w-38 truncate system-2xs-regular text-text-tertiary"
title={item.description}
>
{item.description}
</div>
)}
</div>
{item.showUpgrade && (
<div data-upgrade-action className="shrink-0">
<UpgradeBtn
size="custom"
isShort
loc="workflow-version-history-menu"
className="h-5! rounded-md! px-1!"
/>
</div>
)}
</DropdownMenuItem>
)
}
export default React.memo(ActionMenuItem)

View File

@@ -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<ActionMenuProps> = (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 (
<DropdownMenuItem
disabled={item.disabled}
variant={isDestructive ? 'destructive' : 'default'}
className={cn(
'justify-between gap-x-3 py-1.5 whitespace-nowrap',
item.description && 'h-auto py-1',
)}
onClick={(event) => {
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' })
}
}}
>
<div
className={cn(
'min-w-0 flex-1 px-1 py-0.5 system-md-regular whitespace-nowrap text-text-primary',
item.description && 'flex flex-col gap-y-0.5 text-text-secondary',
isDestructive && 'text-inherit',
)}
>
<div className="w-full truncate">{item.name}</div>
{item.description && (
<div
className="w-full max-w-38 truncate system-2xs-regular text-text-tertiary"
title={item.description}
>
{item.description}
</div>
)}
</div>
{item.showUpgrade && (
<PremiumBadge
size="custom"
color="blue"
allowHover
className="h-5! shrink-0 rounded-md! px-1!"
>
<span
aria-hidden
className="i-custom-public-common-sparkles-soft flex size-3.5 items-center bg-clip-content bg-origin-content mask-clip-content mask-origin-content py-px pl-0.75 text-components-premium-badge-indigo-text-stop-0"
/>
<span className="p-1 system-xs-medium">
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
</span>
</PremiumBadge>
)}
</DropdownMenuItem>
)
}
function ActionMenu(props: ActionMenuProps) {
const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props
const { deleteOperation, options } = useActionMenu(props)
const { t } = useTranslation(['common'])
const triggerRef = useRef<HTMLButtonElement>(null)
function handleAction(operation: VersionHistoryContextMenuOptions) {
queueMicrotask(() => {
triggerRef.current?.focus()
handleClickActionMenuItem(operation)
})
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
ref={triggerRef}
className="absolute top-1 right-1 z-10 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100 data-popup-open:opacity-100 [@media(hover:none)]:opacity-100"
render={
<IconButton
size="md"
@@ -47,23 +208,12 @@ const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
className="w-max max-w-[calc(100vw-24px)] min-w-46 shadow-shadow-shadow-5"
>
{options.map((option) => (
<ActionMenuItem
key={option.key}
item={option}
onClick={handleClickActionMenuItem.bind(null, option.key)}
/>
<ActionMenuItem key={option.key} item={option} onClick={handleAction} />
))}
{isShowDelete && (
<>
<DropdownMenuSeparator className="my-1" />
<ActionMenuItem
item={deleteOperation}
isDestructive
onClick={handleClickActionMenuItem.bind(
null,
VersionHistoryContextMenuOptions.delete,
)}
/>
<DropdownMenuSeparator />
<ActionMenuItem item={deleteOperation} isDestructive onClick={handleAction} />
</>
)}
</DropdownMenuContent>

View File

@@ -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

View File

@@ -36,7 +36,7 @@ const formatVersion = (versionHistory: VersionHistory, latestVersionId: string):
}
}
const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
function VersionHistoryItem({
item,
currentVersion,
latestVersionId,
@@ -45,7 +45,7 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
canImportExportDSL,
isLast,
hideActionMenu,
}) => {
}: VersionHistoryItemProps) {
const { t } = useTranslation(['workflowHistory'])
const [open, setOpen] = useState(false)
@@ -76,7 +76,6 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
'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<VersionHistoryItemProps> = ({
</div>
{/* Action Menu */}
{!hideActionMenu && !isDraft && (
<div
className={cn(
'invisible absolute top-1 right-1 z-10 group-focus-within:visible group-hover:visible',
open && 'visible',
)}
>
<ActionMenu
workflowId={item.id}
isShowDelete={!isLatest}
isNamedVersion={!!item.marked_name}
canImportExportDSL={canImportExportDSL}
open={open}
setOpen={setOpen}
handleClickActionMenuItem={handleClickActionMenuItem}
/>
</div>
<ActionMenu
workflowId={item.id}
isShowDelete={!isLatest}
isNamedVersion={!!item.marked_name}
canImportExportDSL={canImportExportDSL}
open={open}
setOpen={setOpen}
handleClickActionMenuItem={handleClickActionMenuItem}
/>
)}
</div>
)

View File

@@ -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 }) {
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive"
variant="destructive"
className="justify-between px-3"
onClick={handleDeleteNodes}
>
<span>
@@ -476,9 +477,9 @@ export function SelectionContextmenu({ onClose }: { onClose: () => void }) {
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}
<ContextMenuLabel>
<ContextMenuGroupLabel>
{t(($) => $[section.titleKey], { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuLabel>
</ContextMenuGroupLabel>
{section.items.map((item) => {
return (
<ContextMenuItem

View File

@@ -585,7 +585,6 @@ describe('AgentSkills', () => {
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',

View File

@@ -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({
<DropdownMenu
modal={false}
onOpenChange={(open) => {
setIsActionsOpen(open)
if (!open) setIsRemoveHighlighted(false)
}}
>

View File

@@ -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({
<DropdownMenu
modal={false}
onOpenChange={(open) => {
setIsActionsOpen(open)
if (!open) setIsRemoveHighlighted(false)
}}
>

View File

@@ -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({
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" sideOffset={4} className="w-66 p-1">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex w-full min-w-0 truncate pt-2 pr-3 pb-1.5 pl-2 system-xs-medium text-text-tertiary normal-case">
<DropdownMenuGroupLabel className="flex w-full min-w-0 truncate pt-2 pr-3 pb-1.5 pl-2 system-xs-medium normal-case">
{t(($) => $['roster.references.label'], { ns: 'agentRoster', name: agentName })}
</DropdownMenuLabel>
</DropdownMenuGroupLabel>
{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"
>
<span aria-hidden className="shrink-0">
<AppIcon

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "المزيد من الإجراءات لـ {{name}}",
"operation.moveDown": "نقل لأسفل",
"operation.moveUp": "نقل لأعلى",
"operation.no": "لا",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "لم يتم العثور على {{content}}",
"operation.ok": "موافق",
@@ -222,7 +221,6 @@
"operation.view": "عرض",
"operation.viewDetails": "عرض التفاصيل",
"operation.viewMore": "عرض المزيد",
"operation.yes": "نعم",
"operation.zoomIn": "تكبير",
"operation.zoomOut": "تصغير",
"pageUnavailable": "الصفحة غير متاحة",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "{{name}} üçün daha çox əməliyyat",
"operation.moveDown": "Aşağı köçür",
"operation.moveUp": "Yuxarı köçür",
"operation.no": "Xeyr",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Heç bir {{content}} tapılmadı",
"operation.ok": "Təsdiqlə",
@@ -222,7 +221,6 @@
"operation.view": "Bax",
"operation.viewDetails": "Ətraflı bax",
"operation.viewMore": "Daha çoxuna bax",
"operation.yes": "Bəli",
"operation.zoomIn": "Yaxınlaşdır",
"operation.zoomOut": "Uzaqlaşdır",
"pageUnavailable": "Səhifə əlçatan deyil",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Weitere Aktionen für {{name}}",
"operation.moveDown": "Nach unten verschieben",
"operation.moveUp": "Nach oben verschieben",
"operation.no": "Nein",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Es wurden keine {{content}} gefunden",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "Ansehen",
"operation.viewDetails": "Details anzeigen",
"operation.viewMore": "MEHR SEHEN",
"operation.yes": "Ja",
"operation.zoomIn": "Vergrößern",
"operation.zoomOut": "Verkleinern",
"pageUnavailable": "Seite nicht verfügbar",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "More actions for {{name}}",
"operation.moveDown": "Move down",
"operation.moveUp": "Move up",
"operation.no": "No",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "No {{content}} were found",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "View",
"operation.viewDetails": "View Details",
"operation.viewMore": "VIEW MORE",
"operation.yes": "Yes",
"operation.zoomIn": "Zoom In",
"operation.zoomOut": "Zoom Out",
"pageUnavailable": "Page not available",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Más acciones para {{name}}",
"operation.moveDown": "Mover hacia abajo",
"operation.moveUp": "Mover hacia arriba",
"operation.no": "No",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "No se encontraron {{content}}",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "Vista",
"operation.viewDetails": "Ver detalles",
"operation.viewMore": "VER MÁS",
"operation.yes": "Sí",
"operation.zoomIn": "Acercar",
"operation.zoomOut": "Alejar",
"pageUnavailable": "Página no disponible",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "اقدامات بیشتر برای {{name}}",
"operation.moveDown": "انتقال به پایین",
"operation.moveUp": "انتقال به بالا",
"operation.no": "نه",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "هیچ {{content}} یافت نشد",
"operation.ok": "تایید",
@@ -222,7 +221,6 @@
"operation.view": "مشاهده",
"operation.viewDetails": "دیدن جزئیات",
"operation.viewMore": "بیشتر ببینید",
"operation.yes": "بله",
"operation.zoomIn": "بزرگنمایی",
"operation.zoomOut": "کوچک نمایی",
"pageUnavailable": "صفحه در دسترس نیست",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Plus d'actions pour {{name}}",
"operation.moveDown": "Déplacer vers le bas",
"operation.moveUp": "Déplacer vers le haut",
"operation.no": "Non",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Aucun {{content}} n'a été trouvé",
"operation.ok": "D'accord",
@@ -222,7 +221,6 @@
"operation.view": "Vue",
"operation.viewDetails": "Voir les détails",
"operation.viewMore": "VOIR PLUS",
"operation.yes": "Oui",
"operation.zoomIn": "Zoom avant",
"operation.zoomOut": "Zoom arrière",
"pageUnavailable": "Page non disponible",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "{{name}} के लिए अधिक कार्रवाइयाँ",
"operation.moveDown": "नीचे ले जाएँ",
"operation.moveUp": "ऊपर ले जाएँ",
"operation.no": "नहीं",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "कोई {{content}} नहीं मिला",
"operation.ok": "ठीक है",
@@ -222,7 +221,6 @@
"operation.view": "देखना",
"operation.viewDetails": "विवरण देखें",
"operation.viewMore": "और देखें",
"operation.yes": "हाँ",
"operation.zoomIn": "ज़ूम इन करें",
"operation.zoomOut": "ज़ूम आउट करें",
"pageUnavailable": "पृष्ठ उपलब्ध नहीं है",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Tindakan lainnya untuk {{name}}",
"operation.moveDown": "Pindahkan ke bawah",
"operation.moveUp": "Pindahkan ke atas",
"operation.no": "Tidak",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Tidak ada {{content}} yang ditemukan",
"operation.ok": "OKE",
@@ -222,7 +221,6 @@
"operation.view": "Lihat",
"operation.viewDetails": "Lihat Detail",
"operation.viewMore": "LIHAT LEBIH BANYAK",
"operation.yes": "Ya",
"operation.zoomIn": "Perbesar",
"operation.zoomOut": "Perkecil",
"pageUnavailable": "Halaman tidak tersedia",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Altre azioni per {{name}}",
"operation.moveDown": "Sposta giù",
"operation.moveUp": "Sposta su",
"operation.no": "No",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Nessun {{content}} è stato trovato",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "Vista",
"operation.viewDetails": "Visualizza dettagli",
"operation.viewMore": "SCOPRI DI PIÙ",
"operation.yes": "Sì",
"operation.zoomIn": "Ingrandisci",
"operation.zoomOut": "Zoom indietro",
"pageUnavailable": "Pagina non disponibile",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "{{name}} のその他の操作",
"operation.moveDown": "下に移動",
"operation.moveUp": "上に移動",
"operation.no": "いいえ",
"operation.noSearchCount": "0件の{{content}}",
"operation.noSearchResults": "{{content}}は見つかりませんでした",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "表示",
"operation.viewDetails": "詳細を見る",
"operation.viewMore": "さらに表示",
"operation.yes": "はい",
"operation.zoomIn": "ズームインする",
"operation.zoomOut": "ズームアウト",
"pageUnavailable": "ページは利用できません",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "{{name}}에 대한 추가 작업",
"operation.moveDown": "아래로 이동",
"operation.moveUp": "위로 이동",
"operation.no": "아니요",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "{{content}}가(이) 발견되지 않았습니다",
"operation.ok": "확인",
@@ -222,7 +221,6 @@
"operation.view": "보기",
"operation.viewDetails": "세부 정보보기",
"operation.viewMore": "더보기",
"operation.yes": "네",
"operation.zoomIn": "확대",
"operation.zoomOut": "축소",
"pageUnavailable": "페이지를 사용할 수 없습니다",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "ການດຳເນີນການເພີ່ມເຕີມສຳລັບ {{name}}",
"operation.moveDown": "ຍ້າຍລົງ",
"operation.moveUp": "ຍ້າຍຂຶ້ນ",
"operation.no": "ບໍ່",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "ບໍ່ພົບ {{content}}",
"operation.ok": "ຕົກລົງ",
@@ -222,7 +221,6 @@
"operation.view": "ເບິ່ງ",
"operation.viewDetails": "ເບິ່ງລາຍລະອຽດ",
"operation.viewMore": "ເບິ່ງເພີ່ມເຕີມ",
"operation.yes": "ແມ່ນ",
"operation.zoomIn": "ຂະຫຍາຍເຂົ້າ",
"operation.zoomOut": "ຂະຫຍາຍອອກ",
"pageUnavailable": "ໜ້າເວັບບໍ່ພ້ອມໃຊ້ງານ",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Meer acties voor {{name}}",
"operation.moveDown": "Omlaag verplaatsen",
"operation.moveUp": "Omhoog verplaatsen",
"operation.no": "No",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "No {{content}} were found",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "View",
"operation.viewDetails": "View Details",
"operation.viewMore": "VIEW MORE",
"operation.yes": "Yes",
"operation.zoomIn": "Zoom In",
"operation.zoomOut": "Zoom Out",
"pageUnavailable": "Pagina niet beschikbaar",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Więcej akcji dla {{name}}",
"operation.moveDown": "Przenieś w dół",
"operation.moveUp": "Przenieś w górę",
"operation.no": "Nie",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Nie znaleziono {{content}}",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "Widok",
"operation.viewDetails": "Wyświetl szczegóły",
"operation.viewMore": "ZOBACZ WIĘCEJ",
"operation.yes": "Tak",
"operation.zoomIn": "Powiększenie",
"operation.zoomOut": "Pomniejszanie",
"pageUnavailable": "Strona niedostępna",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Mais ações para {{name}}",
"operation.moveDown": "Mover para baixo",
"operation.moveUp": "Mover para cima",
"operation.no": "Não",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Nenhum {{content}} foi encontrado",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "Vista",
"operation.viewDetails": "Ver detalhes",
"operation.viewMore": "VER MAIS",
"operation.yes": "Sim",
"operation.zoomIn": "Ampliar",
"operation.zoomOut": "Diminuir o zoom",
"pageUnavailable": "Página indisponível",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Mai multe acțiuni pentru {{name}}",
"operation.moveDown": "Mută în jos",
"operation.moveUp": "Mută în sus",
"operation.no": "Nu",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Nu au fost găsite {{content}}",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "Vedere",
"operation.viewDetails": "Vezi detalii",
"operation.viewMore": "VEZI MAI MULT",
"operation.yes": "Da",
"operation.zoomIn": "Măriți",
"operation.zoomOut": "Micșorare",
"pageUnavailable": "Pagina nu este disponibilă",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Дополнительные действия для {{name}}",
"operation.moveDown": "Переместить вниз",
"operation.moveUp": "Переместить вверх",
"operation.no": "Нет",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Ничего {{content}} не найдено",
"operation.ok": "ОК",
@@ -222,7 +221,6 @@
"operation.view": "Вид",
"operation.viewDetails": "Подробнее",
"operation.viewMore": "ПОДРОБНЕЕ",
"operation.yes": "Да",
"operation.zoomIn": "Увеличить",
"operation.zoomOut": "Уменьшение масштаба",
"pageUnavailable": "Страница недоступна",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Več dejanj za {{name}}",
"operation.moveDown": "Premakni navzdol",
"operation.moveUp": "Premakni navzgor",
"operation.no": "Ne",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Ni bilo najdenih {{content}}",
"operation.ok": "V redu",
@@ -222,7 +221,6 @@
"operation.view": "Pogled",
"operation.viewDetails": "Poglej podrobnosti",
"operation.viewMore": "POGLEJ VEČ",
"operation.yes": "Da",
"operation.zoomIn": "Povečava",
"operation.zoomOut": "Pomanjšanje",
"pageUnavailable": "Stran ni na voljo",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "การดำเนินการเพิ่มเติมสำหรับ {{name}}",
"operation.moveDown": "เลื่อนลง",
"operation.moveUp": "เลื่อนขึ้น",
"operation.no": "ไม่",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "ไม่พบ {{content}}",
"operation.ok": "ตกลง, ได้",
@@ -222,7 +221,6 @@
"operation.view": "ทิวทัศน์",
"operation.viewDetails": "ดูรายละเอียด",
"operation.viewMore": "ดูเพิ่มเติม",
"operation.yes": "ใช่",
"operation.zoomIn": "ซูมเข้า",
"operation.zoomOut": "ซูมออก",
"pageUnavailable": "ไม่สามารถใช้งานหน้านี้ได้",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "{{name}} için daha fazla işlem",
"operation.moveDown": "Aşağı taşı",
"operation.moveUp": "Yukarı taşı",
"operation.no": "Hayır",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Hiç {{content}} bulunamadı",
"operation.ok": "Tamam",
@@ -222,7 +221,6 @@
"operation.view": "Görüntüle",
"operation.viewDetails": "Detayları Görüntüle",
"operation.viewMore": "DAHA FAZLA GÖSTER",
"operation.yes": "Evet",
"operation.zoomIn": "Yakınlaştırma",
"operation.zoomOut": "Uzaklaştırma",
"pageUnavailable": "Sayfa kullanılamıyor",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Більше дій для {{name}}",
"operation.moveDown": "Перемістити вниз",
"operation.moveUp": "Перемістити вгору",
"operation.no": "Ні",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Жодного {{content}} не знайдено",
"operation.ok": "ОК",
@@ -222,7 +221,6 @@
"operation.view": "Вид",
"operation.viewDetails": "Перегляд докладних відомостей",
"operation.viewMore": "ДИВИТИСЬ БІЛЬШЕ",
"operation.yes": "Так",
"operation.zoomIn": "Збільшити масштаб",
"operation.zoomOut": "Зменшити масштаб",
"pageUnavailable": "Сторінка недоступна",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "Thêm hành động cho {{name}}",
"operation.moveDown": "Di chuyển xuống",
"operation.moveUp": "Di chuyển lên",
"operation.no": "Không",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "Không tìm thấy {{content}} nào",
"operation.ok": "OK",
@@ -222,7 +221,6 @@
"operation.view": "Cảnh",
"operation.viewDetails": "Xem chi tiết",
"operation.viewMore": "XEM THÊM",
"operation.yes": "Vâng",
"operation.zoomIn": "Phóng to",
"operation.zoomOut": "Thu nhỏ",
"pageUnavailable": "Trang không khả dụng",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "{{name}} 的更多操作",
"operation.moveDown": "下移",
"operation.moveUp": "上移",
"operation.no": "否",
"operation.noSearchCount": "0 个 {{content}}",
"operation.noSearchResults": "没有找到{{content}}",
"operation.ok": "好的",
@@ -222,7 +221,6 @@
"operation.view": "查看",
"operation.viewDetails": "查看详情",
"operation.viewMore": "查看更多",
"operation.yes": "是",
"operation.zoomIn": "放大",
"operation.zoomOut": "缩小",
"pageUnavailable": "页面不可用",

View File

@@ -189,7 +189,6 @@
"operation.moreActionsFor": "{{name}} 的更多操作",
"operation.moveDown": "下移",
"operation.moveUp": "上移",
"operation.no": "不",
"operation.noSearchCount": "0 {{content}}",
"operation.noSearchResults": "未找到 {{content}}",
"operation.ok": "好的",
@@ -222,7 +221,6 @@
"operation.view": "視圖",
"operation.viewDetails": "查看詳情",
"operation.viewMore": "查看更多",
"operation.yes": "是",
"operation.zoomIn": "放大",
"operation.zoomOut": "縮小",
"pageUnavailable": "頁面不可用",

View File

@@ -1740,7 +1740,6 @@ export default interface Resources {
'operation.moreActionsFor': 'More actions for {{name}}'
'operation.moveDown': 'Move down'
'operation.moveUp': 'Move up'
'operation.no': 'No'
'operation.noSearchCount': '0 {{content}}'
'operation.noSearchResults': 'No {{content}} were found'
'operation.ok': 'OK'
@@ -1773,7 +1772,6 @@ export default interface Resources {
'operation.view': 'View'
'operation.viewDetails': 'View Details'
'operation.viewMore': 'VIEW MORE'
'operation.yes': 'Yes'
'operation.zoomIn': 'Zoom In'
'operation.zoomOut': 'Zoom Out'
pageUnavailable: 'Page not available'