fix(ui): align menu semantics and focus behavior (#43325)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
34
packages/dify-ui/src/dropdown-menu/__tests__/types.tsx
Normal file
34
packages/dify-ui/src/dropdown-menu/__tests__/types.tsx
Normal 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 }
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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!)}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "الصفحة غير متاحة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "صفحه در دسترس نیست",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "पृष्ठ उपलब्ध नहीं है",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ページは利用できません",
|
||||
|
||||
@@ -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": "페이지를 사용할 수 없습니다",
|
||||
|
||||
@@ -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": "ໜ້າເວັບບໍ່ພ້ອມໃຊ້ງານ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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": "Страница недоступна",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ไม่สามารถใช้งานหน้านี้ได้",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сторінка недоступна",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "页面不可用",
|
||||
|
||||
@@ -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": "頁面不可用",
|
||||
|
||||
2
web/i18n/resources.generated.d.ts
vendored
2
web/i18n/resources.generated.d.ts
vendored
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user