refactor(web): use dropdown data attributes (#36431)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-05-20 15:32:11 +08:00 committed by GitHub
parent cc9b90a5ae
commit 5a585c8618
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 402 additions and 732 deletions

View File

@ -784,11 +784,6 @@
"count": 10
}
},
"web/app/components/base/chip/index.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/base/date-and-time-picker/hooks.ts": {
"react/no-unnecessary-use-prefix": {
"count": 2
@ -1607,11 +1602,6 @@
"count": 1
}
},
"web/app/components/base/sort/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/base/svg-gallery/index.tsx": {
"node/prefer-global/buffer": {
"count": 1
@ -2120,11 +2110,6 @@
"count": 2
}
},
"web/app/components/explore/item-operation/index.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/explore/try-app/tab.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -2596,11 +2581,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx": {
"react/set-state-in-effect": {
"count": 2
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -2778,11 +2758,6 @@
"count": 1
}
},
"web/app/components/share/text-generation/menu-dropdown.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/share/text-generation/no-data/index.tsx": {
"ts/no-empty-object-type": {
"count": 1
@ -2950,11 +2925,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/market-place-plugin/action.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/workflow/block-selector/market-place-plugin/item.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -4051,11 +4021,6 @@
"count": 1
}
},
"web/app/components/workflow/operator/zoom-in-out.tsx": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/workflow/panel/__tests__/index.spec.tsx": {
"react/static-components": {
"count": 2
@ -4378,11 +4343,6 @@
"count": 1
}
},
"web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/education-apply/hooks.ts": {
"react/set-state-in-effect": {
"count": 5

View File

@ -134,7 +134,7 @@ const DropDown = ({
render={(
<ActionButton
aria-label={t('operation.more', { ns: 'common' })}
className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md', open && 'bg-state-base-hover')}
className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md', 'data-popup-open:bg-state-base-hover')}
/>
)}
>

View File

@ -7,9 +7,7 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
type VersionSelectorProps = {
@ -20,17 +18,7 @@ type VersionSelectorProps = {
const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, onChange }) => {
const { t } = useTranslation()
const [isOpen, {
setFalse: handleOpenFalse,
set: handleOpenSet,
}] = useBoolean(false)
const moreThanOneVersion = versionLen > 1
const handleOpen = useCallback((nextOpen: boolean) => {
if (moreThanOneVersion)
handleOpenSet(nextOpen)
}, [moreThanOneVersion, handleOpenSet])
const versions = Array.from({ length: versionLen }, (_, index) => ({
label: `${t('generate.version', { ns: 'appDebug' })} ${index + 1}${index === versionLen - 1 ? ` · ${t('generate.latest', { ns: 'appDebug' })}` : ''}`,
value: index,
@ -39,14 +27,12 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on
const isLatest = value === versionLen - 1
return (
<DropdownMenu
open={isOpen}
onOpenChange={handleOpen}
>
<DropdownMenu>
<DropdownMenuTrigger
nativeButton={false}
render={(
<div className={cn('flex items-center system-xs-medium text-text-tertiary', isOpen && 'text-text-secondary', moreThanOneVersion && 'cursor-pointer')} />
disabled={!moreThanOneVersion}
className={cn(
'flex items-center border-none bg-transparent p-0 system-xs-medium text-text-tertiary',
moreThanOneVersion ? 'cursor-pointer data-popup-open:text-text-secondary' : 'cursor-default',
)}
>
<div>
@ -63,14 +49,13 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on
alignOffset={-12}
popupClassName="w-[208px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<div className={cn('flex h-[22px] items-center px-3 pl-3 system-xs-medium-uppercase text-text-tertiary')}>
<div className="flex h-[22px] items-center px-3 pl-3 system-xs-medium-uppercase text-text-tertiary">
{t('generate.versions', { ns: 'appDebug' })}
</div>
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
onChange(nextValue)
handleOpenFalse()
}}
>
{versions.map(option => (

View File

@ -1,6 +1,5 @@
import type { CSSProperties, FC } from 'react'
import type { ModelAndParameter } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -8,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { memo, useState } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -42,10 +41,8 @@ const DebugItem: FC<DebugItemProps> = ({
const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id)
const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider)
const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model)
const [open, setOpen] = useState(false)
const handleDuplicate = () => {
setOpen(false)
if (multipleModelConfigs.length >= 4)
return
@ -63,12 +60,10 @@ const DebugItem: FC<DebugItemProps> = ({
}
const handleDebugAsSingleModel = () => {
setOpen(false)
onDebugWithMultipleModelChange(modelAndParameter)
}
const handleRemove = () => {
setOpen(false)
onMultipleModelConfigsChange(
true,
multipleModelConfigs.filter(item => item.id !== modelAndParameter.id),
@ -92,11 +87,11 @@ const DebugItem: FC<DebugItemProps> = ({
<ModelParameterTrigger
modelAndParameter={modelAndParameter}
/>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger
render={(
<ActionButton
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
className="focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
aria-label={t('operation.more', { ns: 'common' })}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />

View File

@ -8,7 +8,7 @@
*/
import type { QueryParam } from '../index'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import Filter, { TIME_PERIOD_MAPPING } from '../filter'
@ -297,13 +297,13 @@ describe('Filter', () => {
it('should call setQueryParams when typing in search', async () => {
const user = userEvent.setup()
const setQueryParams = vi.fn()
const onSetQueryParams = vi.fn()
const Wrapper = () => {
const [queryParams, updateQueryParams] = useState<QueryParam>(createDefaultQueryParams())
const [queryParams, setQueryParams] = useState<QueryParam>(() => createDefaultQueryParams())
const handleSetQueryParams = (next: QueryParam) => {
updateQueryParams(next)
setQueryParams(next)
onSetQueryParams(next)
}
return (
<Filter
@ -319,7 +319,7 @@ describe('Filter', () => {
await user.type(input, 'workflow')
// Should call setQueryParams for each character typed
expect(setQueryParams).toHaveBeenLastCalledWith(
expect(onSetQueryParams).toHaveBeenLastCalledWith(
expect.objectContaining({ keyword: 'workflow' }),
)
})
@ -335,7 +335,9 @@ describe('Filter', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.clear' }))
const searchInput = screen.getByPlaceholderText('common.operation.search')
const searchField = searchInput.closest('div')!
await user.click(within(searchField).getByRole('button', { name: 'common.operation.clear' }))
expect(setQueryParams).toHaveBeenCalledWith({
status: 'all',

View File

@ -532,8 +532,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex size-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'flex size-8 items-center justify-center rounded-md border-none bg-transparent p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset data-popup-open:bg-state-base-hover data-popup-open:shadow-none',
)}
onClick={(e) => {
e.stopPropagation()

View File

@ -95,10 +95,11 @@ describe('Operation Component', () => {
const trigger = screen.getByText('Chat Title').closest('.cursor-pointer')
// closed state
expect(trigger).not.toHaveClass('bg-state-base-hover')
expect(trigger).toHaveClass('data-popup-open:bg-state-base-hover')
expect(trigger).not.toHaveAttribute('data-popup-open')
// open state
await user.click(screen.getByText('Chat Title'))
expect(trigger).toHaveClass('bg-state-base-hover')
expect(trigger).toHaveAttribute('data-popup-open')
})
})

View File

@ -1,7 +1,6 @@
'use client'
import type { Placement } from '@langgenius/dify-ui/dropdown-menu'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -9,7 +8,6 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
type Props = {
@ -23,6 +21,10 @@ type Props = {
placement?: Placement
}
const deferAction = (action: () => void) => {
queueMicrotask(action)
}
const Operation: FC<Props> = ({
title,
isPinned,
@ -34,22 +36,11 @@ const Operation: FC<Props> = ({
placement = 'bottom-start',
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleDeferredAction = useCallback((action: () => void) => {
setOpen(false)
queueMicrotask(action)
}, [])
return (
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
'flex cursor-pointer items-center rounded-lg border-none bg-transparent p-1.5 pl-2 text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
open && 'bg-state-base-hover',
)}
className="flex cursor-pointer items-center rounded-lg border-none bg-transparent p-1.5 pl-2 text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
>
<span className="system-md-semibold">{title}</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
@ -65,7 +56,7 @@ const Operation: FC<Props> = ({
{isShowRenameConversation && (
<DropdownMenuItem
className="system-md-regular"
onClick={() => onRenameConversation && handleDeferredAction(onRenameConversation)}
onClick={() => onRenameConversation && deferAction(onRenameConversation)}
>
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</DropdownMenuItem>
@ -74,7 +65,7 @@ const Operation: FC<Props> = ({
<DropdownMenuItem
variant="destructive"
className="system-md-regular"
onClick={() => handleDeferredAction(onDelete)}
onClick={() => deferAction(onDelete)}
>
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</DropdownMenuItem>

View File

@ -10,11 +10,6 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiEditBoxLine,
RiExpandRightLine,
RiLayoutLeft2Line,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import {
useCallback,
@ -35,7 +30,7 @@ type Props = {
panelVisible?: boolean
}
const Sidebar = ({ isPanel, panelVisible }: Props) => {
const Sidebar = ({ isPanel }: Props) => {
const { t } = useTranslation()
const {
isInstalledApp,
@ -112,18 +107,18 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => {
<div className={cn('grow truncate system-md-semibold text-text-secondary')}>{appData?.site.title}</div>
{!isMobile && isSidebarCollapsed && (
<ActionButton size="l" onClick={() => handleSidebarCollapse(false)}>
<RiExpandRightLine className="h-[18px] w-[18px]" />
<span aria-hidden className="i-ri-expand-right-line h-[18px] w-[18px]" />
</ActionButton>
)}
{!isMobile && !isSidebarCollapsed && (
<ActionButton size="l" onClick={() => handleSidebarCollapse(true)}>
<RiLayoutLeft2Line className="h-[18px] w-[18px]" />
<span aria-hidden className="i-ri-layout-left-2-line h-[18px] w-[18px]" />
</ActionButton>
)}
</div>
<div className="shrink-0 px-3 py-4">
<Button variant="secondary-accent" disabled={isResponding} className="w-full justify-center" onClick={handleNewConversation}>
<RiEditBoxLine className="mr-1 size-4" />
<span aria-hidden className="mr-1 i-ri-edit-box-line size-4" />
{t('chat.newChat', { ns: 'share' })}
</Button>
</div>
@ -156,7 +151,6 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => {
hideLogout={isInstalledApp}
placement="top-start"
data={appData?.site}
forceClose={isPanel && !panelVisible}
/>
{/* powered by */}
<div className="shrink-0">

View File

@ -40,7 +40,7 @@ describe('Chip', () => {
// Helper function to get the trigger element
const getTrigger = (container: HTMLElement) => {
return container.querySelector('[role="button"][aria-haspopup="menu"]') as HTMLElement | null
return container.querySelector('button[aria-haspopup="menu"], [role="button"][aria-haspopup="menu"]') as HTMLElement | null
}
// Helper function to open dropdown panel
@ -98,12 +98,11 @@ describe('Chip', () => {
})
it('should hide left icon when showLeftIcon is false', () => {
renderChip({ showLeftIcon: false })
renderChip({ showLeftIcon: false, value: '' })
// When showLeftIcon is false, there should be no filter icon before the text
const textElement = screen.getByText('All Items')
const parent = textElement.closest('[role="button"]')
const icons = parent?.querySelectorAll('svg')
const trigger = getTrigger(document.body)
const icons = trigger?.querySelectorAll('svg')
// Should only have the arrow icon, not the filter icon
expect(icons?.length).toBe(1)
@ -190,12 +189,7 @@ describe('Chip', () => {
it('should call onClear when clear button is clicked', () => {
const { container } = renderChip({ value: 'active' })
// Find the close icon (last SVG in the trigger) and click its parent
const trigger = getTrigger(container)
const svgs = trigger?.querySelectorAll('svg')
// The close icon should be the last SVG element
const closeIcon = svgs?.[svgs.length - 1]
const clearButton = closeIcon?.parentElement
const clearButton = container.querySelector('button[aria-label="common.operation.clear"]')
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
@ -210,10 +204,7 @@ describe('Chip', () => {
const trigger = getTrigger(container)
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
// Find the close icon (last SVG) and click its parent
const svgs = trigger?.querySelectorAll('svg')
const closeIcon = svgs?.[svgs.length - 1]
const clearButton = closeIcon?.parentElement
const clearButton = container.querySelector('button[aria-label="common.operation.clear"]')
if (clearButton)
fireEvent.click(clearButton)

View File

@ -1,4 +1,4 @@
import type { FC } from 'react'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -8,24 +8,28 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine, RiCheckLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export type Item = {
value: number | string
type ItemValue = number | string
export type Item<T extends ItemValue = ItemValue> = {
value: T
name: string
} & Record<string, any>
} & Record<string, unknown>
type Props = {
type Props<T extends ItemValue> = {
className?: string
panelClassName?: string
showLeftIcon?: boolean
leftIcon?: any
value: number | string
items: Item[]
onSelect: (item: any) => void
leftIcon?: ReactNode
value: T
items: Item<T>[]
onSelect: (item: Item<T>) => void
onClear: () => void
}
const Chip: FC<Props> = ({
function Chip<T extends ItemValue>({
className,
panelClassName,
showLeftIcon = true,
@ -34,31 +38,24 @@ const Chip: FC<Props> = ({
items,
onSelect,
onClear,
}) => {
const [open, setOpen] = useState(false)
}: Props<T>) {
const { t } = useTranslation()
const triggerContent = useMemo(() => {
return items.find(item => item.value === value)?.name || ''
}, [items, value])
return (
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenu>
<div className="relative">
<DropdownMenuTrigger
nativeButton={false}
render={<div className="block" />}
>
<div className={cn(
<div
className={cn(
'flex min-h-8 cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
open && !value && 'bg-state-base-hover-alt! hover:bg-state-base-hover-alt',
!open && !!value && 'border-components-button-secondary-border! bg-components-button-secondary-bg! shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover!',
open && !!value && 'border-components-button-secondary-border-hover! bg-components-button-secondary-bg-hover! shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover!',
!value && 'has-data-popup-open:bg-state-base-hover-alt! has-data-popup-open:hover:bg-state-base-hover-alt',
!!value && 'border-components-button-secondary-border! bg-components-button-secondary-bg! shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! has-data-popup-open:border-components-button-secondary-border-hover! has-data-popup-open:bg-components-button-secondary-bg-hover! has-data-popup-open:hover:border-components-button-secondary-border-hover has-data-popup-open:hover:bg-components-button-secondary-bg-hover!',
className,
)}
>
>
<DropdownMenuTrigger className="flex min-w-0 grow items-center border-none bg-transparent p-0 text-left">
{showLeftIcon && (
<div className="p-0.5">
{leftIcon || (
@ -72,19 +69,21 @@ const Chip: FC<Props> = ({
</div>
</div>
{!value && <RiArrowDownSLine className="size-4 text-text-tertiary" />}
{!!value && (
<div
className="group/clear cursor-pointer p-px"
onClick={(e) => {
e.stopPropagation()
onClear()
}}
>
<RiCloseCircleFill className="size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" />
</div>
)}
</div>
</DropdownMenuTrigger>
</DropdownMenuTrigger>
{!!value && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear cursor-pointer border-none bg-transparent p-px"
onClick={(e) => {
e.stopPropagation()
onClear()
}}
>
<RiCloseCircleFill className="size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" />
</button>
)}
</div>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}

View File

@ -1,4 +1,3 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -8,28 +7,28 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiArrowDownSLine, RiCheckLine, RiSortAsc, RiSortDesc } from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
type Item = {
value: number | string
name: string
} & Record<string, any>
} & Record<string, unknown>
type Props = {
order?: string
value: number | string
items: Item[]
onSelect: (item: any) => void
onSelect: (value: string) => void
}
const Sort: FC<Props> = ({
function Sort({
order,
value,
items,
onSelect,
}) => {
}: Props) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const triggerContent = useMemo(() => {
return items.find(item => item.value === value)?.name || ''
@ -37,28 +36,18 @@ const Sort: FC<Props> = ({
return (
<div className="inline-flex items-center">
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenu>
<div className="relative">
<DropdownMenuTrigger
nativeButton={false}
render={<div className="block" />}
className="flex min-h-8 cursor-pointer items-center rounded-l-lg border-none bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt"
>
<div className={cn(
'flex min-h-8 cursor-pointer items-center rounded-l-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt! hover:bg-state-base-hover-alt',
)}
>
<div className="flex items-center gap-0.5 px-1">
<div className="system-sm-regular text-text-tertiary">{t('filter.sortBy', { ns: 'appLog' })}</div>
<div className={cn('system-sm-regular text-text-tertiary', !!value && 'text-text-secondary')}>
{triggerContent}
</div>
<div className="flex items-center gap-0.5 px-1">
<div className="system-sm-regular text-text-tertiary">{t('filter.sortBy', { ns: 'appLog' })}</div>
<div className={cn('system-sm-regular text-text-tertiary', !!value && 'text-text-secondary')}>
{triggerContent}
</div>
<RiArrowDownSLine className="size-4 text-text-tertiary" />
</div>
<RiArrowDownSLine className="size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"

View File

@ -50,7 +50,7 @@ const PreviewDocumentPicker: FC<Props> = ({
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover', className)}>
<div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover data-popup-open:bg-state-base-hover', className)}>
<FileIcon name={name} extension={extension} size="lg" />
<div className="ml-1 flex flex-col items-start">
<div className="flex items-center space-x-0.5">

View File

@ -59,7 +59,7 @@ const Actions = ({
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',
isMoreOperationsOpen && 'bg-state-base-hover',
'data-popup-open:bg-state-base-hover',
)}
onClick={e => e.stopPropagation()}
>

View File

@ -368,9 +368,9 @@ describe('Dropdown', () => {
// Act - Open dropdown
fireEvent.click(button)
// Assert - Open state: should have bg-state-base-hover
// Assert - Open state is exposed declaratively via data-popup-open
await waitFor(() => {
expect(button)!.toHaveClass('bg-state-base-hover')
expect(button).toHaveAttribute('data-popup-open')
})
})
})

View File

@ -1,7 +1,19 @@
import type { ReactElement } from 'react'
import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from '../item'
const renderItem = (ui: ReactElement) => {
return render(
<DropdownMenu open>
<DropdownMenuContent>
{ui}
</DropdownMenuContent>
</DropdownMenu>,
)
}
describe('Item', () => {
const defaultProps = {
name: 'Documents',
@ -16,7 +28,7 @@ describe('Item', () => {
// Rendering: verify the breadcrumb name is displayed
describe('Rendering', () => {
it('should render breadcrumb name', () => {
render(<Item {...defaultProps} />)
renderItem(<Item {...defaultProps} />)
expect(screen.getByText('Documents')).toBeInTheDocument()
})
@ -25,7 +37,7 @@ describe('Item', () => {
// User interactions: clicking triggers callback with correct index
describe('User Interactions', () => {
it('should call onBreadcrumbClick with correct index on click', () => {
render(<Item {...defaultProps} />)
renderItem(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('Documents'))
@ -34,7 +46,7 @@ describe('Item', () => {
})
it('should pass different index values correctly', () => {
render(<Item {...defaultProps} index={5} />)
renderItem(<Item {...defaultProps} index={5} />)
fireEvent.click(screen.getByText('Documents'))

View File

@ -1,7 +1,19 @@
import type { ReactElement } from 'react'
import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Menu from '../menu'
const renderMenu = (ui: ReactElement) => {
return render(
<DropdownMenu open>
<DropdownMenuContent>
{ui}
</DropdownMenuContent>
</DropdownMenu>,
)
}
describe('Menu', () => {
const defaultProps = {
breadcrumbs: ['Folder A', 'Folder B', 'Folder C'],
@ -16,7 +28,7 @@ describe('Menu', () => {
// Rendering: verify all breadcrumb items are displayed
describe('Rendering', () => {
it('should render all breadcrumb items', () => {
render(<Menu {...defaultProps} />)
renderMenu(<Menu {...defaultProps} />)
expect(screen.getByText('Folder A')).toBeInTheDocument()
expect(screen.getByText('Folder B')).toBeInTheDocument()
@ -36,7 +48,7 @@ describe('Menu', () => {
// Index mapping: startIndex offsets are applied correctly
describe('Index Mapping', () => {
it('should pass correct index (startIndex + offset) to each item', () => {
render(<Menu {...defaultProps} />)
renderMenu(<Menu {...defaultProps} />)
fireEvent.click(screen.getByText('Folder A'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
@ -49,7 +61,7 @@ describe('Menu', () => {
})
it('should offset from startIndex of zero', () => {
render(
renderMenu(
<Menu
breadcrumbs={['First', 'Second']}
startIndex={0}
@ -68,7 +80,7 @@ describe('Menu', () => {
// User interactions: clicking items triggers the callback
describe('User Interactions', () => {
it('should call onBreadcrumbClick with correct index when item clicked', () => {
render(<Menu {...defaultProps} />)
renderMenu(<Menu {...defaultProps} />)
fireEvent.click(screen.getByText('Folder B'))

View File

@ -5,7 +5,6 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Menu from './menu'
@ -21,18 +20,9 @@ const Dropdown = ({
onBreadcrumbClick,
}: DropdownProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleBreadCrumbClick = useCallback((index: number) => {
onBreadcrumbClick(index)
setOpen(false)
}, [onBreadcrumbClick])
return (
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
@ -40,8 +30,7 @@ const Dropdown = ({
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex size-6 items-center justify-center rounded-md',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover',
)}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
@ -56,7 +45,7 @@ const Dropdown = ({
<Menu
breadcrumbs={breadcrumbs}
startIndex={startIndex}
onBreadcrumbClick={handleBreadCrumbClick}
onBreadcrumbClick={onBreadcrumbClick}
/>
</DropdownMenuContent>
<span className="system-xs-regular text-divider-deep">/</span>

View File

@ -1,5 +1,5 @@
import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useCallback } from 'react'
type ItemProps = {
name: string
@ -12,17 +12,13 @@ const Item = ({
index,
onBreadcrumbClick,
}: ItemProps) => {
const handleClick = useCallback(() => {
onBreadcrumbClick(index)
}, [index, onBreadcrumbClick])
return (
<div
<DropdownMenuItem
className="rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
onClick={handleClick}
onClick={() => onBreadcrumbClick(index)}
>
{name}
</div>
</DropdownMenuItem>
)
}

View File

@ -1,93 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import StatusItem from '../status-item'
describe('StatusItem', () => {
const defaultItem = {
value: '1',
name: 'Test Status',
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<StatusItem item={defaultItem} selected={false} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render item name', () => {
render(<StatusItem item={defaultItem} selected={false} />)
expect(screen.getByText('Test Status')).toBeInTheDocument()
})
it('should render with correct styling classes', () => {
const { container } = render(<StatusItem item={defaultItem} selected={false} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-between')
})
})
describe('Props', () => {
it('should show check icon when selected is true', () => {
const { container } = render(<StatusItem item={defaultItem} selected={true} />)
// Assert - RiCheckLine icon should be present
const checkIcon = container.querySelector('.text-text-accent')
expect(checkIcon).toBeInTheDocument()
})
it('should not show check icon when selected is false', () => {
const { container } = render(<StatusItem item={defaultItem} selected={false} />)
// Assert - RiCheckLine icon should not be present
const checkIcon = container.querySelector('.text-text-accent')
expect(checkIcon).not.toBeInTheDocument()
})
it('should render different item names', () => {
const item = { value: '2', name: 'Different Status' }
render(<StatusItem item={item} selected={false} />)
expect(screen.getByText('Different Status')).toBeInTheDocument()
})
})
describe('Memoization', () => {
it('should render consistently with same props', () => {
const { container: container1 } = render(<StatusItem item={defaultItem} selected={true} />)
const { container: container2 } = render(<StatusItem item={defaultItem} selected={true} />)
expect(container1.textContent).toBe(container2.textContent)
})
})
describe('Edge Cases', () => {
it('should handle empty item name', () => {
const emptyItem = { value: '1', name: '' }
const { container } = render(<StatusItem item={emptyItem} selected={false} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle special characters in item name', () => {
const specialItem = { value: '1', name: 'Status <>&"' }
render(<StatusItem item={specialItem} selected={false} />)
expect(screen.getByText('Status <>&"')).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
const { rerender } = render(<StatusItem item={defaultItem} selected={false} />)
rerender(<StatusItem item={defaultItem} selected={true} />)
expect(screen.getByText('Test Status')).toBeInTheDocument()
})
})
})

View File

@ -11,10 +11,6 @@ vi.mock('../../display-toggle', () => ({
),
}))
vi.mock('../../status-item', () => ({
default: ({ item }: { item: { name: string } }) => <div data-testid="status-item">{item.name}</div>,
}))
describe('MenuBar', () => {
const defaultProps = {
hasSelectableSegments: true,
@ -87,22 +83,19 @@ describe('MenuBar', () => {
expect(defaultProps.onInputChange).toHaveBeenCalledWith('')
})
it('should render select with status items via renderOption', () => {
it('should render the selected status in the trigger', () => {
renderMenuBar()
expect(screen.getByText('All')).toBeInTheDocument()
})
it('should call renderOption for each item when dropdown is opened', async () => {
it('should render status options when dropdown is opened', async () => {
renderMenuBar()
const selectButton = screen.getByRole('combobox')
fireEvent.click(selectButton)
// After opening, renderOption is called for each item, rendering the mocked StatusItem
const statusItems = await screen.findAllByTestId('status-item')
expect(statusItems.length).toBe(3)
expect(statusItems[0]).toHaveTextContent('All')
expect(statusItems[1]).toHaveTextContent('Enabled')
expect(statusItems[2]).toHaveTextContent('Disabled')
expect(await screen.findByRole('option', { name: 'All' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Enabled' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Disabled' })).toBeInTheDocument()
})
})

View File

@ -1,12 +1,10 @@
'use client'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import DisplayToggle from '../display-toggle'
import StatusItem from '../status-item'
import s from '../style.module.css'
type Item = {
@ -67,15 +65,14 @@ function MenuBar({
onChangeStatus(nextItem)
}}
>
<SelectTrigger className={cn(s.select, 'mr-2 h-fit')}>
<SelectTrigger className="mr-2 w-[100px] shrink-0 shadow-none">
{selectedStatus?.name ?? ''}
</SelectTrigger>
<SelectContent popupClassName="w-[160px]">
{statusList.map(item => (
<SelectItem key={item.value} value={String(item.value)} className="h-auto p-0">
<SelectItemText className="sr-only m-0 p-0">{item.name}</SelectItemText>
<StatusItem item={item} selected={item.value === selectDefaultValue} />
{item.value === selectDefaultValue && <SelectItemIndicator className="hidden" />}
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>

View File

@ -1,27 +0,0 @@
import type { FC } from 'react'
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
type StatusOption = {
value: string | number
name: string
}
type IStatusItemProps = {
item: StatusOption
selected: boolean
}
const StatusItem: FC<IStatusItemProps> = ({
item,
selected,
}) => {
return (
<div className="flex items-center justify-between px-2 py-1.5">
<span className="system-md-regular">{item.name}</span>
{selected && <RiCheckLine className="size-4 text-text-accent" />}
</div>
)
}
export default React.memo(StatusItem)

View File

@ -33,9 +33,6 @@
background: linear-gradient(to left, white, 90%, transparent);
@apply text-primary-600 font-semibold text-xs absolute right-0 hidden h-12 pl-12 items-center;
}
.select {
@apply !h-8 !w-[100px] !py-0 !pr-5 !shadow-none;
}
.segModalContent {
@apply h-96 text-gray-800 text-base break-all overflow-y-scroll;
white-space: pre-line;

View File

@ -143,6 +143,18 @@ describe('SegmentAdd', () => {
expect(mockShowBatchModal).toHaveBeenCalledTimes(1)
})
it('should show plan upgrade modal instead of batch modal for sandbox users', async () => {
mockPlan = { type: Plan.sandbox }
const mockShowBatchModal = vi.fn()
render(<SegmentAdd {...defaultProps} showBatchModal={mockShowBatchModal} />)
fireEvent.click(screen.getByRole('button', { name: /list\.action\.batchAdd/i }))
fireEvent.click(await screen.findByRole('menuitem', { name: /list\.action\.batchAdd/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(mockShowBatchModal).not.toHaveBeenCalled()
})
})
// Disabled state (embedding)

View File

@ -7,7 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
@ -30,9 +30,7 @@ export function SegmentAdd({
embedding,
}: SegmentAddProps) {
const { t } = useTranslation()
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
const { plan, enableBilling } = useProviderContext()
const canAddChunks = !enableBilling || plan.type !== Plan.sandbox
@ -40,24 +38,13 @@ export function SegmentAdd({
? 'text-components-button-secondary-accent-text-disabled'
: 'text-components-button-secondary-accent-text'
const handleAddClick = () => {
const openSegmentDialog = (openDialog: () => void) => {
if (!canAddChunks) {
setIsPlanUpgradeModalOpen(true)
return
}
showNewSegmentModal()
}
const handleBatchAddClick = () => {
setIsBatchMenuOpen(false)
if (!canAddChunks) {
setIsPlanUpgradeModalOpen(true)
return
}
showBatchModal()
openDialog()
}
if (importStatus) {
@ -115,7 +102,6 @@ export function SegmentAdd({
return (
<div
ref={batchMenuAnchorRef}
className={cn(
'relative z-20 flex items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
embedding && 'border-components-button-secondary-border-disabled bg-components-button-secondary-bg-disabled',
@ -125,7 +111,7 @@ export function SegmentAdd({
type="button"
className={`inline-flex items-center rounded-l-lg border-0 border-r border-r-divider-subtle bg-transparent px-2.5 py-2 text-left
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`}
onClick={handleAddClick}
onClick={() => openSegmentDialog(showNewSegmentModal)}
disabled={embedding}
>
<span aria-hidden className={cn('i-ri-add-line size-4', textColor)} />
@ -133,29 +119,25 @@ export function SegmentAdd({
{t('list.action.addButton', { ns: 'datasetDocuments' })}
</span>
</button>
<DropdownMenu open={isBatchMenuOpen} onOpenChange={setIsBatchMenuOpen}>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t('list.action.batchAdd', { ns: 'datasetDocuments' })}
disabled={embedding}
className={cn(
`rounded-l-none rounded-r-lg border-0 bg-transparent p-2 backdrop-blur-[5px]
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent`,
isBatchMenuOpen && 'bg-state-base-hover',
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent data-popup-open:bg-state-base-hover`,
)}
>
<div className="flex items-center justify-center">
<span aria-hidden className={cn('i-ri-arrow-down-s-line size-4', textColor)} />
</div>
<span aria-hidden className={cn('i-ri-arrow-down-s-line size-4', textColor)} />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
placement="bottom-end"
sideOffset={4}
positionerProps={{ anchor: batchMenuAnchorRef }}
popupClassName="w-[var(--anchor-width)]"
popupClassName="min-w-[120px]"
>
<DropdownMenuItem
className="system-md-regular"
onClick={handleBatchAddClick}
onClick={() => openSegmentDialog(showBatchModal)}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</DropdownMenuItem>

View File

@ -17,7 +17,7 @@
@apply !p-2 !border-[0.5px] !border-components-button-secondary-border !bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 hover:!border-components-button-secondary-border-hover hover:!bg-components-button-secondary-bg-hover;
}
.actionItem {
@apply h-9 py-2 px-3 mx-1 flex items-center gap-2 rounded-lg border-none bg-transparent text-left hover:bg-state-base-hover cursor-pointer;
@apply h-9 w-[calc(100%-8px)] py-2 px-3 mx-1 flex items-center gap-2 rounded-lg border-none bg-transparent text-left hover:bg-state-base-hover cursor-pointer;
}
.deleteActionItem {
@apply hover:!bg-state-destructive-hover;

View File

@ -42,7 +42,7 @@ const OperationsDropdown = ({
'border-components-actionbar-border bg-components-button-secondary-bg p-0 shadow-lg ring-2 shadow-shadow-shadow-5 ring-components-button-secondary-bg ring-inset',
'transition-colors hover:border-components-actionbar-border hover:bg-state-base-hover',
'focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden focus-visible:ring-inset',
open && 'bg-state-base-hover',
'data-popup-open:bg-state-base-hover',
)}
aria-label="Dataset operations"
>

View File

@ -101,7 +101,7 @@ const PermissionSelector = ({
<div className="relative">
<PopoverTrigger
render={(
<div className={cn('flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
<div className={cn('group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt', disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
{
isOnlyMe && (
<>
@ -169,8 +169,7 @@ const PermissionSelector = ({
}
<RiArrowDownSLine
className={cn(
'size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
'size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary',
disabled && 'text-components-input-text-placeholder!',
)}
/>

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ItemOperation from '../index'
@ -13,11 +13,21 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
}
return {
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenu: ({
children,
modal,
}: {
children: React.ReactNode
modal?: boolean
}) => {
const [isOpen, setIsOpen] = React.useState(false)
return (
<DropdownMenuContext value={{ isOpen, setOpen: setIsOpen }}>
<div data-modal={modal} data-open={isOpen} data-testid="dropdown-menu">{children}</div>
</DropdownMenuContext>
)
},
DropdownMenuTrigger: ({
children,
onClick,
@ -158,37 +168,41 @@ describe('ItemOperation', () => {
})
describe('Edge Cases', () => {
it('should close the menu when mouse leaves the panel and item is not hovering', async () => {
renderComponent()
it('should keep the menu open when item hover leaves', async () => {
const { props, rerender } = renderComponent({ isItemHovering: true })
fireEvent.click(screen.getByTestId('item-operation-trigger'))
await screen.findByText('explore.sidebar.action.pin')
const menu = screen.getByTestId('dropdown-content')
fireEvent.mouseEnter(menu)
fireEvent.mouseLeave(menu)
rerender(<ItemOperation {...props} isItemHovering={false} />)
await waitFor(() => {
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
})
expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument()
})
it('should stop propagation when clicking inside the dropdown content', async () => {
it('should render a non-modal menu', () => {
renderComponent()
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-modal', 'false')
})
it('should stop propagation when clicking menu actions', async () => {
const onParentClick = vi.fn()
const togglePin = vi.fn()
render(
<div onClick={onParentClick}>
<ItemOperation
isPinned={false}
isShowDelete
togglePin={vi.fn()}
togglePin={togglePin}
onDelete={vi.fn()}
/>
</div>,
)
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByTestId('dropdown-content'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
expect(togglePin).toHaveBeenCalledTimes(1)
expect(onParentClick).not.toHaveBeenCalled()
})
})

View File

@ -7,13 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
RiDeleteBinLine,
RiEditLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Pin02 } from '../../base/icons/src/vender/line/general'
import s from './style.module.css'
@ -41,20 +35,17 @@ const ItemOperation: FC<IItemOperationProps> = ({
}) => {
const { t } = useTranslation('explore')
const { t: tCommon } = useTranslation('common')
const [open, setOpen] = useState(false)
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
useEffect(() => {
if (!isItemHovering && !isHovering)
setOpen(false)
}, [isItemHovering, isHovering])
return (
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
data-testid="item-operation-trigger"
className={cn(className, s.btn, 'size-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} bg-components-actionbar-bg! shadow-none!`)}
className={cn(
s.btn,
'size-6 rounded-md border-none py-1 data-popup-open:bg-components-actionbar-bg! data-popup-open:shadow-none!',
isItemHovering && `${s.open} bg-components-actionbar-bg! shadow-none!`,
className,
)}
onClick={(e) => {
e.stopPropagation()
}}
@ -65,11 +56,6 @@ const ItemOperation: FC<IItemOperationProps> = ({
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[120px]"
popupProps={{
onMouseEnter: setIsHovering,
onMouseLeave: setNotHovering,
onClick: e => e.stopPropagation(),
}}
>
<DropdownMenuItem
className={cn(s.actionItem, 'gap-2 px-3')}
@ -89,7 +75,7 @@ const ItemOperation: FC<IItemOperationProps> = ({
onRenameConversation?.()
}}
>
<RiEditLine className="size-4 shrink-0 text-text-secondary" />
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{t('sidebar.action.rename')}</span>
</DropdownMenuItem>
)}
@ -101,7 +87,7 @@ const ItemOperation: FC<IItemOperationProps> = ({
onDelete()
}}
>
<RiDeleteBinLine className={cn(s.deleteActionItemChild, 'size-4 shrink-0 stroke-current stroke-2 text-inherit')} />
<span aria-hidden className={cn(s.deleteActionItemChild, 'i-ri-delete-bin-line size-4 shrink-0 text-inherit')} />
<span className={cn(s.actionName, s.deleteActionItemChild, 'text-inherit')}>{t('sidebar.action.delete')}</span>
</DropdownMenuItem>
)}

View File

@ -20,6 +20,7 @@
mask-image: url(~@/assets/action.svg);
}
body .btn[data-popup-open],
body .btn.open,
body .btn:hover {
background: url(~@/assets/action.svg) center center no-repeat transparent;

View File

@ -42,8 +42,6 @@ vi.mock('@/config', async (importOriginal) => {
})
describe('Support', () => {
const mockCloseAccountDropdown = vi.fn()
const baseAppContextValue: AppContextValue = {
userProfile: {
id: '1',
@ -105,7 +103,7 @@ describe('Support', () => {
<DropdownMenu open={true} onOpenChange={() => { }}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<Support closeAccountDropdown={mockCloseAccountDropdown} />
<Support />
</DropdownMenuContent>
</DropdownMenu>,
)
@ -189,7 +187,7 @@ describe('Support', () => {
})
describe('Interactions and Links', () => {
it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => {
it('should call toggleZendeskWindow when "Contact Us" is clicked', () => {
// Act
renderSupport()
fireEvent.click(screen.getByText('common.userProfile.support'))
@ -197,7 +195,6 @@ describe('Support', () => {
// Assert
expect(window.zE).toHaveBeenCalledWith('messenger', 'open')
expect(mockCloseAccountDropdown).toHaveBeenCalled()
})
it('should have correct forum and community links', () => {

View File

@ -2,7 +2,6 @@
import type { MouseEventHandler, ReactNode } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
@ -110,7 +109,6 @@ function AccountMenuSection({ children }: AccountMenuSectionProps) {
export default function AppSelector() {
const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { t } = useTranslation()
@ -136,10 +134,10 @@ export default function AppSelector() {
return (
<div>
<DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t('account.account', { ns: 'common' })}
className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}
className="inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge data-popup-open:bg-background-default-dodge"
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
</DropdownMenuTrigger>
@ -185,7 +183,7 @@ export default function AppSelector() {
label={t('userProfile.helpCenter', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
<Support closeAccountDropdown={() => setIsAccountMenuOpen(false)} />
<Support />
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</AccountMenuSection>
<DropdownMenuSeparator className="my-0! bg-divider-subtle" />
@ -214,7 +212,6 @@ export default function AppSelector() {
label={t('userProfile.about', { ns: 'common' })}
onClick={() => {
setAboutVisible(true)
setIsAccountMenuOpen(false)
}}
trailing={(
<div className="flex shrink-0 items-center">

View File

@ -8,12 +8,8 @@ import { useProviderContext } from '@/context/provider-context'
import { mailToSupport } from '../utils/util'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
type SupportProps = {
closeAccountDropdown: () => void
}
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
export default function Support({ closeAccountDropdown }: SupportProps) {
export default function Support() {
const { t } = useTranslation()
const { plan } = useProviderContext()
const { userProfile, langGeniusVersionInfo } = useAppContext()
@ -37,7 +33,6 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
className="justify-between"
onClick={() => {
toggleZendeskWindow(true)
closeAccountDropdown()
}}
>
<MenuItemContent

View File

@ -1,7 +1,6 @@
import type {
DataSourceCredential,
} from './types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -12,7 +11,6 @@ import {
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
@ -29,12 +27,10 @@ const Operator = ({
onRename,
}: OperatorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const {
type,
} = credentialItem
const handleAction = useCallback((action: string) => {
setOpen(false)
queueMicrotask(() => {
if (action === 'rename') {
onRename?.()
@ -45,12 +41,12 @@ const Operator = ({
}, [credentialItem, onAction, onRename])
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger
render={(
<ActionButton
size="l"
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
className="focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
aria-label={t('operation.more', { ns: 'common' })}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />

View File

@ -1,6 +1,5 @@
'use client'
import type { Member } from '@/models/common'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -9,7 +8,7 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { memo, useMemo, useState } from 'react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useProviderContext } from '@/context/provider-context'
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
@ -30,7 +29,6 @@ const nonOwnerRoles = ['admin', 'editor', 'normal'] as const
const isNonOwnerRole = (role: Member['role']) => role !== 'owner'
const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const { datasetOperatorEnabled } = useProviderContext()
const RoleMap = {
@ -59,7 +57,6 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
}, [operatorRole, datasetOperatorEnabled])
const canRemoveMember = operatorRole === 'owner' || (operatorRole === 'admin' && isNonOwnerRole(member.role))
const handleDeleteMemberOrCancelInvitation = async () => {
setOpen(false)
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
onOperate()
@ -69,7 +66,6 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
}
}
const handleUpdateMemberRole = async (role: string) => {
setOpen(false)
try {
await updateMemberRole({
url: `/workspaces/current/members/${member.id}/update-role`,
@ -82,12 +78,12 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger
render={<button type="button" className={cn('group flex size-full cursor-pointer items-center justify-between border-none bg-transparent px-3 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')} />}
className="group flex size-full cursor-pointer items-center justify-between border-none bg-transparent px-3 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover data-popup-open:bg-state-base-hover"
>
{RoleMap[member.role] || RoleMap.normal}
<span aria-hidden className={cn('i-ri-arrow-down-s-line size-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
<span aria-hidden className="i-ri-arrow-down-s-line hidden size-4 shrink-0 group-hover:block group-data-popup-open:block" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"

View File

@ -117,7 +117,8 @@ describe('MemberSelector', () => {
const trigger = screen.getByTestId('member-selector-trigger')
await user.click(trigger)
expect(trigger).toHaveClass('bg-state-base-hover-alt')
expect(trigger).toHaveAttribute('data-popup-open')
expect(trigger).toHaveClass('data-popup-open:bg-state-base-hover-alt')
})
it('should not match account when neither name nor email contains search value', async () => {

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
@ -56,7 +55,7 @@ const MemberSelector: FC<Props> = ({
render={(
<div
data-testid="member-selector-trigger"
className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}
className="group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt"
>
{!currentValue && (
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
@ -68,7 +67,7 @@ const MemberSelector: FC<Props> = ({
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
</>
)}
<div className={cn('i-ri-arrow-down-s-line size-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
<div className="i-ri-arrow-down-s-line size-4 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary" />
</div>
)}
/>

View File

@ -39,16 +39,15 @@ const OperationDropdown: FC<Props> = ({
popupClassName,
}) => {
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.enable_marketplace,
})
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m', open && 'bg-state-base-hover')}
className="action-btn action-btn-m data-popup-open:bg-state-base-hover"
>
<span className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>

View File

@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import InstallPluginDropdown from '../install-plugin-dropdown'
let portalOpen = false
const {
mockSystemFeatures,
} = vi.hoisted(() => ({
@ -63,15 +62,22 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
modal,
children,
}: {
open: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
modal?: boolean
children: React.ReactNode
}) => {
portalOpen = open
const [internalOpen, setInternalOpen] = React.useState(open ?? false)
const isOpen = open ?? internalOpen
const setOpen = (nextOpen: boolean) => {
if (open === undefined)
setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open} data-modal={modal}>{children}</div>
<DropdownMenuContext value={{ isOpen, setOpen }}>
<div data-testid="dropdown-menu" data-open={isOpen} data-modal={modal}>{children}</div>
</DropdownMenuContext>
)
},
@ -99,7 +105,10 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
children,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="dropdown-content">{children}</div> : null,
}) => {
const { isOpen } = useDropdownMenuContext()
return isOpen ? <div data-testid="dropdown-content">{children}</div> : null
},
DropdownMenuItem: ({
children,
onClick,
@ -150,7 +159,6 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', ()
describe('InstallPluginDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
mockSystemFeatures.enable_marketplace = true
mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = false
})

View File

@ -1,7 +1,6 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -11,7 +10,7 @@ import {
import { RiAddLine, RiArrowDownSLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useEffect, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FileZip } from '@/app/components/base/icons/src/vender/solid/files'
import { Github } from '@/app/components/base/icons/src/vender/solid/general'
@ -36,7 +35,6 @@ const InstallPluginDropdown = ({
}: Props) => {
const { t } = useTranslation()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [selectedAction, setSelectedAction] = useState<string | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const { data: enable_marketplace } = useSuspenseQuery({
@ -54,7 +52,6 @@ const InstallPluginDropdown = ({
if (file) {
setSelectedFile(file)
setSelectedAction('local')
setIsMenuOpen(false)
}
}
@ -78,20 +75,17 @@ const InstallPluginDropdown = ({
// console.log(res)
// }
const [installMethods, setInstallMethods] = useState<InstallMethod[]>([])
useEffect(() => {
const methods = []
const installMethods = useMemo<InstallMethod[]>(() => {
const methods: InstallMethod[] = []
if (enable_marketplace)
methods.push({ icon: MagicBox, text: t('source.marketplace', { ns: 'plugin' }), action: 'marketplace' })
if (plugin_installation_permission.restrict_to_marketplace_only) {
setInstallMethods(methods)
}
else {
methods.push({ icon: Github, text: t('source.github', { ns: 'plugin' }), action: 'github' })
methods.push({ icon: FileZip, text: t('source.local', { ns: 'plugin' }), action: 'local' })
setInstallMethods(methods)
}
if (plugin_installation_permission.restrict_to_marketplace_only)
return methods
methods.push({ icon: Github, text: t('source.github', { ns: 'plugin' }), action: 'github' })
methods.push({ icon: FileZip, text: t('source.local', { ns: 'plugin' }), action: 'local' })
return methods
}, [plugin_installation_permission, enable_marketplace, t])
const handleInstallMethodSelect = (action: string) => {
@ -111,7 +105,7 @@ const InstallPluginDropdown = ({
}
return (
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen} modal={false}>
<DropdownMenu modal={false}>
<div className="relative">
<input
type="file"
@ -123,7 +117,7 @@ const InstallPluginDropdown = ({
<DropdownMenuTrigger
render={(
<Button
className={cn('size-full p-2 text-components-button-secondary-text', isMenuOpen && 'bg-state-base-hover')}
className="size-full p-2 text-components-button-secondary-text data-popup-open:bg-state-base-hover"
/>
)}
>

View File

@ -233,25 +233,6 @@ describe('MenuDropdown', () => {
})
})
describe('forceClose prop', () => {
it('should close dropdown when forceClose changes to true', async () => {
const { rerender } = render(<MenuDropdown data={baseSiteInfo} forceClose={false} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('common.theme.theme')).toBeInTheDocument()
})
rerender(<MenuDropdown data={baseSiteInfo} forceClose={true} />)
await waitFor(() => {
expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument()
})
})
})
describe('placement prop', () => {
it('should accept custom placement', () => {
render(<MenuDropdown data={baseSiteInfo} placement="top-start" />)

View File

@ -2,7 +2,6 @@
import type { Placement } from '@langgenius/dify-ui/dropdown-menu'
import type { FC } from 'react'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -12,7 +11,7 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
@ -26,50 +25,37 @@ type Props = {
data?: SiteInfo
placement?: Placement
hideLogout?: boolean
forceClose?: boolean
}
const MenuDropdown: FC<Props> = ({
data,
placement,
hideLogout,
forceClose,
}) => {
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const router = useRouter()
const pathname = usePathname()
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const shareCode = useWebAppStore(s => s.shareCode)
const handleLogout = useCallback(async () => {
setOpen(false)
const handleLogout = async () => {
await webAppLogout(shareCode!)
router.replace(`/webapp-signin?redirect_url=${pathname}`)
}, [pathname, router, setOpen, shareCode])
}
const [show, setShow] = useState(false)
const handleOpenInfoModal = useCallback(() => {
setOpen(false)
const handleOpenInfoModal = () => {
queueMicrotask(() => {
setShow(true)
})
}, [])
useEffect(() => {
if (forceClose)
setOpen(false)
}, [forceClose, setOpen])
}
return (
<>
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenu>
<DropdownMenuTrigger
render={(
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<ActionButton size="l" className="data-popup-open:bg-state-base-hover">
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
</ActionButton>
)}

View File

@ -14,11 +14,21 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
}
return {
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => {
const [internalOpen, setInternalOpen] = React.useState(open ?? false)
const isOpen = open ?? internalOpen
const setOpen = (nextOpen: boolean) => {
if (open === undefined)
setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<DropdownMenuContext value={{ isOpen, setOpen }}>
<div data-testid="dropdown-menu" data-open={isOpen}>{children}</div>
</DropdownMenuContext>
)
},
DropdownMenuTrigger: ({
children,
render,

View File

@ -13,7 +13,6 @@ import {
RiMoreFill,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
@ -31,16 +30,11 @@ const OperationDropdown: FC<Props> = ({
onRemove,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
onOpenChange?.(nextOpen)
}, [onOpenChange])
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger
render={<ActionButton size={inCard ? 'l' : 'm'} className={cn(open && 'bg-state-base-hover')} />}
render={<ActionButton size={inCard ? 'l' : 'm'} className="data-popup-open:bg-state-base-hover" />}
>
<RiMoreFill className={cn('size-4', inCard && 'size-5')} />
</DropdownMenuTrigger>

View File

@ -3,11 +3,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDownloadPlugin } from '@/service/use-plugins'
import OperationDropdown from '../action'
const mockDownloadBlob = vi.fn()
const mockRemoveQueries = vi.fn()
const mockDownloadPlugin = vi.fn()
vi.mock('next-themes', () => ({
useTheme: () => ({
@ -15,8 +14,15 @@ vi.mock('next-themes', () => ({
}),
}))
vi.mock('@/service/use-plugins', () => ({
useDownloadPlugin: vi.fn(),
vi.mock('@/service/client', () => ({
marketplaceQuery: {
downloadPlugin: {
mutationOptions: (options = {}) => ({
mutationFn: (input: unknown) => mockDownloadPlugin(input),
...options,
}),
},
},
}))
vi.mock('@/utils/download', () => ({
@ -37,9 +43,6 @@ const createQueryClient = () => new QueryClient({
const renderComponent = (props?: Partial<ComponentProps<typeof OperationDropdown>>) => {
const queryClient = createQueryClient()
vi.spyOn(queryClient, 'removeQueries').mockImplementation(((...args) => {
return mockRemoveQueries(...args)
}) as typeof queryClient.removeQueries)
return render(
<QueryClientProvider client={queryClient}>
@ -58,10 +61,7 @@ const renderComponent = (props?: Partial<ComponentProps<typeof OperationDropdown
describe('OperationDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({
data: enabled ? null : null,
isLoading: false,
}) as unknown as ReturnType<typeof useDownloadPlugin>)
mockDownloadPlugin.mockResolvedValue(new Blob(['plugin zip'], { type: 'application/zip' }))
})
it('should render download and view details actions when opened', async () => {
@ -78,28 +78,35 @@ describe('OperationDropdown', () => {
await userEvent.setup().click(screen.getByText('common.operation.download'))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(mockRemoveQueries).toHaveBeenCalled()
await waitFor(() => {
expect(mockDownloadPlugin).toHaveBeenCalledWith({
params: {
organization: 'langgenius',
pluginName: 'test-plugin',
version: '1.0.0',
},
})
})
})
it('should skip download when already loading', async () => {
vi.mocked(useDownloadPlugin).mockReturnValue({
data: null,
isLoading: true,
} as unknown as ReturnType<typeof useDownloadPlugin>)
it('should skip duplicate downloads while pending', async () => {
mockDownloadPlugin.mockReturnValue(new Promise(() => {}))
renderComponent({ open: true })
await userEvent.setup().click(screen.getByText('common.operation.download'))
const user = userEvent.setup()
await user.click(screen.getByText('common.operation.download'))
expect(mockRemoveQueries).not.toHaveBeenCalled()
await waitFor(() => {
expect(mockDownloadPlugin).toHaveBeenCalledTimes(1)
})
await user.click(screen.getByText('common.operation.download'))
expect(mockDownloadPlugin).toHaveBeenCalledTimes(1)
})
it('should download the blob when the hook returns data', async () => {
vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({
data: enabled ? new Blob(['plugin zip'], { type: 'application/zip' }) : null,
isLoading: false,
}) as unknown as ReturnType<typeof useDownloadPlugin>)
it('should download the blob when the request returns data', async () => {
renderComponent({ open: true })
await userEvent.setup().click(screen.getByText('common.operation.download'))
@ -110,7 +117,6 @@ describe('OperationDropdown', () => {
fileName: 'langgenius-test-plugin_1.0.0.zip',
})
})
expect(mockRemoveQueries).toHaveBeenCalled()
})
it('should link to the marketplace detail page', () => {

View File

@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -8,13 +7,12 @@ import {
DropdownMenuLinkItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQueryClient } from '@tanstack/react-query'
import { useMutation } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { useDownloadPlugin } from '@/service/use-plugins'
import { marketplaceQuery } from '@/service/client'
import { downloadBlob } from '@/utils/download'
import { getMarketplaceUrl } from '@/utils/var'
@ -35,49 +33,36 @@ const OperationDropdown: FC<Props> = ({
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const queryClient = useQueryClient()
const setOpen = useCallback((value: boolean) => {
onOpenChange(value)
}, [onOpenChange])
const [needDownload, setNeedDownload] = useState(false)
const downloadInfo = useMemo(() => ({
organization: author,
pluginName: name,
version,
}), [author, name, version])
const { data: blob, isLoading } = useDownloadPlugin(downloadInfo, needDownload)
const handleDownload = useCallback(() => {
if (isLoading)
return
setOpen(false)
queryClient.removeQueries({
queryKey: ['plugins', 'downloadPlugin', downloadInfo],
exact: true,
})
setNeedDownload(true)
}, [downloadInfo, isLoading, queryClient, setOpen])
const downloadMutation = useMutation(marketplaceQuery.downloadPlugin.mutationOptions({
onSuccess: (blob) => {
downloadBlob({ data: blob, fileName: `${author}-${name}_${version}.zip` })
},
}))
useEffect(() => {
if (!needDownload || !blob)
const handleDownload = () => {
if (downloadMutation.isPending)
return
const fileName = `${author}-${name}_${version}.zip`
downloadBlob({ data: blob, fileName })
setNeedDownload(false)
queryClient.removeQueries({
queryKey: ['plugins', 'downloadPlugin', downloadInfo],
exact: true,
onOpenChange(false)
downloadMutation.mutate({
params: {
organization: author,
pluginName: name,
version,
},
})
}, [author, blob, downloadInfo, name, needDownload, queryClient, version])
}
return (
<DropdownMenu
open={open}
onOpenChange={setOpen}
onOpenChange={onOpenChange}
>
<DropdownMenuTrigger
render={(
<ActionButton
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
className="focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
aria-label={t('operation.more', { ns: 'common' })}
>
<span aria-hidden className="i-ri-more-fill size-4 text-components-button-secondary-accent-text" />

View File

@ -54,7 +54,7 @@ const OperationSelector: FC<OperationSelectorProps> = ({
>
<DropdownMenuTrigger
disabled={disabled}
className={cn('flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1', disabled ? 'cursor-not-allowed bg-components-input-bg-disabled!' : 'cursor-pointer hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', className)}
className={cn('group flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 data-popup-open:bg-state-base-hover-alt', disabled ? 'cursor-not-allowed bg-components-input-bg-disabled!' : 'cursor-pointer hover:bg-state-base-hover-alt', className)}
>
<div className="flex items-center p-1">
<span
@ -64,7 +64,7 @@ const OperationSelector: FC<OperationSelectorProps> = ({
{selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })}
</span>
</div>
<span aria-hidden className={cn('i-ri-arrow-down-s-line size-4 text-text-quaternary', disabled && 'text-components-input-text-placeholder', open && 'text-text-secondary')} />
<span aria-hidden className={cn('i-ri-arrow-down-s-line size-4 text-text-quaternary group-data-popup-open:text-text-secondary', disabled && 'text-components-input-text-placeholder')} />
</DropdownMenuTrigger>
<DropdownMenuContent

View File

@ -69,7 +69,7 @@ const MethodSelector: FC<MethodSelectorProps> = ({
render={(
<ActionButton
aria-label={t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}
className={cn(open && 'bg-state-base-hover')}
className="data-popup-open:bg-state-base-hover"
>
<RiAddLine className="size-4" />
</ActionButton>

View File

@ -1,60 +1,8 @@
import type { Member } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import MemberSelector from '../member-selector'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: import('react').ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: import('react').ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({ children }: { children: import('react').ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="popover-content">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
const mockMemberList = vi.hoisted(() => vi.fn())
vi.mock('../member-list', () => ({
@ -87,7 +35,9 @@ describe('human-input/delivery-method/recipient/member-selector', () => {
vi.clearAllMocks()
})
it('should toggle the member list and forward selection props', () => {
it('should toggle the member list and forward selection props', async () => {
const user = userEvent.setup()
render(
<MemberSelector
value={[{ type: 'member', user_id: 'member-1' }]}
@ -103,16 +53,17 @@ describe('human-input/delivery-method/recipient/member-selector', () => {
expect(screen.queryByTestId('member-list')).not.toBeInTheDocument()
fireEvent.click(trigger)
await user.click(trigger)
expect(screen.getByTestId('member-list')).toBeInTheDocument()
expect(trigger).toHaveClass('bg-state-accent-hover')
expect(trigger).toHaveAttribute('data-popup-open')
expect(trigger).toHaveClass('data-popup-open:bg-state-accent-hover')
expect(mockMemberList).toHaveBeenCalledWith(expect.objectContaining({
searchValue: '',
list: members,
email: 'owner@example.com',
}))
fireEvent.click(trigger)
await user.click(trigger)
expect(screen.queryByTestId('member-list')).not.toBeInTheDocument()
})
})

View File

@ -3,7 +3,6 @@ import type { FC } from 'react'
import type { Recipient } from '@/app/components/workflow/nodes/human-input/types'
import type { Member } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
@ -45,7 +44,7 @@ const MemberSelector: FC<Props> = ({
<PopoverTrigger
render={(
<Button
className={cn('w-full justify-between', open && 'bg-state-accent-hover')}
className="w-full justify-between data-popup-open:bg-state-accent-hover"
variant="ghost-accent"
>
<RiContactsBookLine className="mr-1 size-4" />

View File

@ -82,8 +82,18 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
return (
<button
type="button"
aria-label={ariaLabel}
className={className}
onClick={() => setOpen(!open)}
onMouseDown={(event) => {
const baseUiEvent = event as MouseEvent<HTMLButtonElement> & { preventBaseUIHandler?: () => void }
baseUiEvent.preventBaseUIHandler = vi.fn()
onMouseDown?.(baseUiEvent as unknown as MouseEvent<HTMLDivElement>)
}}
onClick={(event) => {
onClick?.(event as unknown as MouseEvent<HTMLDivElement>)
if (!onMouseDown)
setOpen(!open)
}}
>
{children}
</button>

View File

@ -37,12 +37,10 @@ const Operator = ({
onOpenChange={setOpen}
>
<DropdownMenuTrigger
nativeButton={false}
render={<div />}
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-base-hover text-text-secondary',
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
)}
onMouseDown={(event) => {
event.preventDefault()

View File

@ -1,5 +1,4 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -11,7 +10,6 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import {
Fragment,
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
@ -26,17 +24,19 @@ import {
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
import TipPopup from './tip-popup'
enum ZoomType {
zoomToFit = 'zoomToFit',
zoomTo25 = 'zoomTo25',
zoomTo50 = 'zoomTo50',
zoomTo75 = 'zoomTo75',
zoomTo100 = 'zoomTo100',
zoomTo200 = 'zoomTo200',
toggleUserComments = 'toggleUserComments',
toggleUserCursors = 'toggleUserCursors',
toggleMiniMap = 'toggleMiniMap',
}
const ZoomType = {
zoomToFit: 'zoomToFit',
zoomTo25: 'zoomTo25',
zoomTo50: 'zoomTo50',
zoomTo75: 'zoomTo75',
zoomTo100: 'zoomTo100',
zoomTo200: 'zoomTo200',
toggleUserComments: 'toggleUserComments',
toggleUserCursors: 'toggleUserCursors',
toggleMiniMap: 'toggleMiniMap',
} as const
type ZoomType = typeof ZoomType[keyof typeof ZoomType]
type ZoomInOutProps = {
showMiniMap?: boolean
@ -66,7 +66,6 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
} = useReactFlow()
const { zoom } = useViewport()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const [open, setOpen] = useState(false)
const {
workflowReadOnly,
getWorkflowReadOnly,
@ -126,12 +125,10 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
],
]
const handleZoom = (type: string) => {
const handleZoom = (type: ZoomType) => {
if (workflowReadOnly)
return
setOpen(false)
if (type === ZoomType.zoomToFit)
fitView()
@ -199,16 +196,10 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
<span aria-hidden className="i-ri-zoom-out-line size-4 text-text-tertiary hover:text-text-secondary" />
</button>
</TipPopup>
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenu>
<DropdownMenuTrigger
disabled={getWorkflowReadOnly()}
className={cn(
'flex h-8 w-[34px] items-center justify-center rounded-lg system-sm-medium text-text-tertiary hover:bg-black/5 hover:text-text-secondary',
open && 'bg-black/5 text-text-secondary',
)}
className="flex h-8 w-[34px] items-center justify-center rounded-lg system-sm-medium text-text-tertiary hover:bg-black/5 hover:text-text-secondary data-popup-open:bg-black/5 data-popup-open:text-text-secondary"
>
{Number.parseFloat(`${zoom * 100}`).toFixed(0)}
%

View File

@ -10,7 +10,6 @@ import {
import {
Fragment,
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
@ -20,14 +19,16 @@ import {
import TipPopup from '@/app/components/workflow/operator/tip-popup'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
enum ZoomType {
zoomToFit = 'zoomToFit',
zoomTo25 = 'zoomTo25',
zoomTo50 = 'zoomTo50',
zoomTo75 = 'zoomTo75',
zoomTo100 = 'zoomTo100',
zoomTo200 = 'zoomTo200',
}
const ZoomType = {
zoomToFit: 'zoomToFit',
zoomTo25: 'zoomTo25',
zoomTo50: 'zoomTo50',
zoomTo75: 'zoomTo75',
zoomTo100: 'zoomTo100',
zoomTo200: 'zoomTo200',
} as const
type ZoomType = typeof ZoomType[keyof typeof ZoomType]
const ZoomInOut: FC = () => {
const { t } = useTranslation()
@ -38,7 +39,6 @@ const ZoomInOut: FC = () => {
fitView,
} = useReactFlow()
const { zoom } = useViewport()
const [open, setOpen] = useState(false)
const zoomOptions = [
[
@ -71,9 +71,7 @@ const ZoomInOut: FC = () => {
],
]
const handleZoom = (type: string) => {
setOpen(false)
const handleZoom = (type: ZoomType) => {
if (type === ZoomType.zoomToFit)
fitView()
@ -122,11 +120,8 @@ const ZoomInOut: FC = () => {
<span aria-hidden className="i-ri-zoom-out-line size-4 text-text-tertiary hover:text-text-secondary" />
</button>
</TipPopup>
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenuTrigger className={cn('flex h-8 w-[34px] items-center justify-center rounded-lg system-sm-medium text-text-tertiary hover:bg-black/5 hover:text-text-secondary', open && 'bg-black/5 text-text-secondary')}>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-[34px] items-center justify-center rounded-lg system-sm-medium text-text-tertiary hover:bg-black/5 hover:text-text-secondary data-popup-open:bg-black/5 data-popup-open:text-text-secondary">
{Number.parseFloat(`${zoom * 100}`).toFixed(0)}
%
</DropdownMenuTrigger>

View File

@ -67,3 +67,17 @@ export const templateDetailContract = base
}
}>())
.output(type<{ data: MarketplaceTemplate }>())
export const downloadPluginContract = base
.route({
path: '/plugins/{organization}/{pluginName}/{version}/download',
method: 'GET',
})
.input(type<{
params: {
organization: string
pluginName: string
version: string
}
}>())
.output(type<Blob>())

View File

@ -52,13 +52,14 @@ import {
workflowDraftUpdateFeaturesContract,
} from './console/workflow'
import { workflowCommentContracts } from './console/workflow-comment'
import { collectionPluginsContract, collectionsContract, searchAdvancedContract, templateDetailContract } from './marketplace'
import { collectionPluginsContract, collectionsContract, downloadPluginContract, searchAdvancedContract, templateDetailContract } from './marketplace'
export const marketplaceRouterContract = {
collections: collectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
templateDetail: templateDetailContract,
downloadPlugin: downloadPluginContract,
}
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>

View File

@ -569,15 +569,6 @@ export const usePluginManifestInfo = (pluginUID: string) => {
})
}
export const useDownloadPlugin = (info: { organization: string, pluginName: string, version: string }, needDownload: boolean) => {
return useQuery({
queryKey: [NAME_SPACE, 'downloadPlugin', info],
queryFn: () => getMarketplace<Blob>(`/plugins/${info.organization}/${info.pluginName}/${info.version}/download`),
enabled: needDownload,
retry: 0,
})
}
export const useMutationCheckDependencies = () => {
return useMutation({
mutationFn: (appId: string) => {