mirror of
https://mirror.skon.top/github.com/langgenius/dify.git
synced 2026-04-20 23:40:16 +08:00
refactor(web): migrate overlay imports to @langgenius/dify-ui
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
## Overlay Components (Mandatory)
|
||||
|
||||
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
|
||||
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
|
||||
- In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`.
|
||||
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
|
||||
|
||||
## Query & Mutation (Mandatory)
|
||||
|
||||
119
web/__mocks__/base-ui-popover.tsx
Normal file
119
web/__mocks__/base-ui-popover.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
onOpenChange: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
type PopoverProps = {
|
||||
children?: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & {
|
||||
children?: ReactNode
|
||||
render?: React.ReactElement
|
||||
}
|
||||
|
||||
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
children?: ReactNode
|
||||
placement?: string
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
positionerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const Popover = ({
|
||||
children,
|
||||
open = false,
|
||||
onOpenChange,
|
||||
}: PopoverProps) => {
|
||||
return (
|
||||
<PopoverContext.Provider value={{
|
||||
open,
|
||||
onOpenChange: onOpenChange ?? (() => {}),
|
||||
}}
|
||||
>
|
||||
<div data-testid="popover" data-open={String(open)}>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverTrigger = ({
|
||||
children,
|
||||
render,
|
||||
onClick,
|
||||
...props
|
||||
}: PopoverTriggerProps) => {
|
||||
const { open, onOpenChange } = React.useContext(PopoverContext)
|
||||
const node = render ?? children
|
||||
|
||||
if (React.isValidElement(node)) {
|
||||
const triggerElement = node as React.ReactElement<React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }>
|
||||
const childProps = triggerElement.props ?? {}
|
||||
|
||||
return React.cloneElement(triggerElement, {
|
||||
...props,
|
||||
...childProps,
|
||||
'data-testid': childProps['data-testid'] ?? 'popover-trigger',
|
||||
'onClick': (event: React.MouseEvent<HTMLElement>) => {
|
||||
childProps.onClick?.(event)
|
||||
onClick?.(event)
|
||||
onOpenChange(!open)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-trigger"
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
onOpenChange(!open)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverContent = ({
|
||||
children,
|
||||
className,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
...props
|
||||
}: PopoverContentProps) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
data-placement={placement}
|
||||
data-side-offset={sideOffset}
|
||||
data-align-offset={alignOffset}
|
||||
className={className}
|
||||
{...positionerProps}
|
||||
{...popupProps}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
@@ -3,13 +3,13 @@ import type { FC } from 'react'
|
||||
import type { PopupProps } from './config-popup'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import ConfigPopup from './config-popup'
|
||||
|
||||
type Props = {
|
||||
@@ -25,36 +25,31 @@ const ConfigBtn: FC<Props> = ({
|
||||
children,
|
||||
...popupProps
|
||||
}) => {
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (popupProps.readOnly && !hasConfigured)
|
||||
return null
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={12}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div className={cn('select-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={cn('select-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={12}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<ConfigPopup {...popupProps} />
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigBtn)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiChatSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
@@ -6,30 +7,29 @@ import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
const ViewFormDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs">
|
||||
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
|
||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||
@@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type Props = {
|
||||
iconColor?: string
|
||||
@@ -17,25 +17,27 @@ const ViewFormDropdown = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-99">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
data-testid="view-form-dropdown-content"
|
||||
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs"
|
||||
@@ -48,8 +50,8 @@ const ViewFormDropdown = ({
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { memo } from 'react'
|
||||
import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type FileUploadSettingsProps = {
|
||||
open: boolean
|
||||
onOpen: (state: any) => void
|
||||
onOpen: (state: boolean) => void
|
||||
onChange?: OnFeaturesChange
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
@@ -25,18 +25,27 @@ const FileUploadSettings = ({
|
||||
imageUpload,
|
||||
}: FileUploadSettingsProps) => {
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={onOpen}
|
||||
placement="left"
|
||||
offset={{
|
||||
mainAxis: 32,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
onOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className="flex">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="left"
|
||||
sideOffset={32}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="max-h-[calc(100vh-20px)] w-[360px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
||||
<SettingContent
|
||||
imageUpload={imageUpload}
|
||||
@@ -47,8 +56,8 @@ const FileUploadSettings = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default memo(FileUploadSettings)
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Features } from '../../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../../context'
|
||||
import VoiceSettings from '../voice-settings'
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
placement,
|
||||
offset,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
placement?: string
|
||||
offset?: { mainAxis?: number }
|
||||
}) => (
|
||||
<div
|
||||
data-testid="voice-settings-portal"
|
||||
data-placement={placement}
|
||||
data-main-axis={offset?.mainAxis}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="voice-settings-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -46,6 +25,25 @@ vi.mock('@/service/use-apps', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/switch', () => ({
|
||||
Switch: ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
...props
|
||||
}: {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="switch"
|
||||
data-checked={String(checked)}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
@@ -58,7 +56,7 @@ const defaultFeatures: Features = {
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (ui: React.ReactNode) => {
|
||||
const renderWithProvider = (ui: ReactNode) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
{ui}
|
||||
@@ -101,12 +99,7 @@ describe('VoiceSettings', () => {
|
||||
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
// The toggle function should flip the open state
|
||||
const toggleFn = onOpen.mock.calls[0]![0]
|
||||
expect(typeof toggleFn).toBe('function')
|
||||
expect(toggleFn(false)).toBe(true)
|
||||
expect(toggleFn(true)).toBe(false)
|
||||
expect(onOpen).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should not call onOpen when disabled and trigger is clicked', () => {
|
||||
@@ -137,16 +130,13 @@ describe('VoiceSettings', () => {
|
||||
|
||||
it('should use top placement and mainAxis 4 when placementLeft is false', () => {
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={vi.fn()} placementLeft={false}>
|
||||
<VoiceSettings open={true} onOpen={vi.fn()} placementLeft={false}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
const portal = screen.getAllByTestId('voice-settings-portal')
|
||||
.find(item => item.hasAttribute('data-main-axis'))
|
||||
|
||||
expect(portal).toBeDefined()
|
||||
expect(portal)!.toHaveAttribute('data-placement', 'top')
|
||||
expect(portal)!.toHaveAttribute('data-main-axis', '4')
|
||||
const content = screen.getByTestId('popover-content')
|
||||
expect(content).toHaveAttribute('data-placement', 'top')
|
||||
expect(content).toHaveAttribute('data-side-offset', '4')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { memo } from 'react'
|
||||
import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type VoiceSettingsProps = {
|
||||
open: boolean
|
||||
onOpen: (state: any) => void
|
||||
onOpen: (state: boolean) => void
|
||||
onChange?: OnFeaturesChange
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
@@ -25,23 +25,32 @@ const VoiceSettings = ({
|
||||
placementLeft = true,
|
||||
}: VoiceSettingsProps) => {
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={onOpen}
|
||||
placement={placementLeft ? 'left' : 'top'}
|
||||
offset={{
|
||||
mainAxis: placementLeft ? 32 : 4,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
onOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className="flex">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placementLeft ? 'left' : 'top'}
|
||||
sideOffset={placementLeft ? 32 : 4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
||||
<ParamConfigContent onClose={() => onOpen(false)} onChange={onChange} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default memo(VoiceSettings)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { FILE_URL_REGEX } from '../constants'
|
||||
import FileInput from '../file-input'
|
||||
import { useFile } from '../hooks'
|
||||
@@ -54,16 +54,16 @@ const FileFromLinkOrLocal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="top"
|
||||
offset={4}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
|
||||
{trigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1001">
|
||||
<PopoverTrigger render={trigger(open) as React.ReactElement} />
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg">
|
||||
{
|
||||
showFromLink && (
|
||||
@@ -126,8 +126,8 @@ const FileFromLinkOrLocal = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ImageLinkInput from './image-link-input'
|
||||
import Uploader from './uploader'
|
||||
@@ -63,29 +63,31 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
|
||||
const closePopover = () => setOpen(false)
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
|
||||
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
|
||||
{!!hasUploadFromLocal && (
|
||||
@@ -115,8 +117,8 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
@@ -8,11 +13,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link03 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useImageFiles } from './hooks'
|
||||
import ImageLinkInput from './image-link-input'
|
||||
@@ -35,35 +35,38 @@ const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({
|
||||
onUpload(imageFile)
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<div className={`
|
||||
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
||||
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<Link03 className="mr-2 h-4 w-4" />
|
||||
{t('imageUploader.pasteImageLink', { ns: 'common' })}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div
|
||||
className={`
|
||||
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
||||
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<Link03 className="mr-2 h-4 w-4" />
|
||||
{t('imageUploader.pasteImageLink', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg">
|
||||
<ImageLinkInput onUpload={handleUpload} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
/**
|
||||
* @deprecated Use semantic overlay primitives from `@/app/components/base/ui/` instead.
|
||||
* @deprecated Use semantic overlay primitives from `@langgenius/dify-ui/` instead.
|
||||
* This component will be removed after migration is complete.
|
||||
* See: https://github.com/langgenius/dify/issues/32767
|
||||
*
|
||||
@@ -148,14 +148,17 @@ export const PortalToFollowElemTrigger = (
|
||||
}: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement | null>, asChild?: boolean },
|
||||
) => {
|
||||
const context = usePortalToFollowElemContext()
|
||||
const childrenRef = (children as any).props?.ref
|
||||
const childElement = React.isValidElement<{ ref?: React.Ref<HTMLElement | null> }>(children)
|
||||
? children
|
||||
: null
|
||||
const childrenRef = childElement?.props.ref
|
||||
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
|
||||
|
||||
// `asChild` allows the user to pass any element as the anchor
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const childProps = (children.props ?? {}) as Record<string, unknown>
|
||||
if (asChild && childElement) {
|
||||
const childProps = (childElement.props ?? {}) as Record<string, unknown>
|
||||
return React.cloneElement(
|
||||
children,
|
||||
childElement,
|
||||
context.getReferenceProps({
|
||||
ref,
|
||||
...props,
|
||||
|
||||
@@ -5,34 +5,7 @@ import * as React from 'react'
|
||||
import { ChunkingMode, DataSourceType } from '@/models/datasets'
|
||||
import DocumentPicker from '../index'
|
||||
|
||||
// Mock portal-to-follow-elem - always render content for testing
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
}) => (
|
||||
<div data-testid="portal-elem" data-open={String(open || false)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
// Always render content to allow testing document selection
|
||||
PortalToFollowElemContent: ({ children, className }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="portal-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
// Mock useDocumentList hook with controllable return value
|
||||
let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined
|
||||
@@ -152,6 +125,10 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof DocumentPick
|
||||
}
|
||||
}
|
||||
|
||||
const openPopover = () => {
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
}
|
||||
|
||||
describe('DocumentPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -165,7 +142,7 @@ describe('DocumentPicker', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document name when provided', () => {
|
||||
@@ -273,7 +250,7 @@ describe('DocumentPicker', () => {
|
||||
onChange,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle value with all fields', () => {
|
||||
@@ -318,13 +295,13 @@ describe('DocumentPicker', () => {
|
||||
it('should initialize with popup closed', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
|
||||
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
|
||||
it('should open popup when trigger is clicked', () => {
|
||||
renderComponent()
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Verify click handler is called
|
||||
@@ -430,7 +407,7 @@ describe('DocumentPicker', () => {
|
||||
)
|
||||
|
||||
// The component should use the new callback
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should memoize handleChange callback with useCallback', () => {
|
||||
@@ -440,7 +417,7 @@ describe('DocumentPicker', () => {
|
||||
renderComponent({ onChange })
|
||||
|
||||
// Verify component renders correctly, callback memoization is internal
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -518,7 +495,7 @@ describe('DocumentPicker', () => {
|
||||
it('should toggle popup when trigger is clicked', () => {
|
||||
renderComponent()
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Trigger click should be handled
|
||||
@@ -591,7 +568,7 @@ describe('DocumentPicker', () => {
|
||||
renderComponent()
|
||||
|
||||
// When loading, component should still render without crashing
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fetch documents on mount', () => {
|
||||
@@ -611,7 +588,7 @@ describe('DocumentPicker', () => {
|
||||
renderComponent()
|
||||
|
||||
// Component should render without crashing
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined data response', () => {
|
||||
@@ -620,7 +597,7 @@ describe('DocumentPicker', () => {
|
||||
renderComponent()
|
||||
|
||||
// Should not crash
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -732,13 +709,13 @@ describe('DocumentPicker', () => {
|
||||
renderComponent()
|
||||
|
||||
// Should not crash
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid toggle clicks', () => {
|
||||
renderComponent()
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
// Rapid clicks
|
||||
fireEvent.click(trigger)
|
||||
@@ -795,7 +772,7 @@ describe('DocumentPicker', () => {
|
||||
renderComponent()
|
||||
|
||||
// Should not crash
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle document list mapping with various data_source_detail_dict states', () => {
|
||||
@@ -819,7 +796,7 @@ describe('DocumentPicker', () => {
|
||||
renderComponent()
|
||||
|
||||
// Should not crash during mapping
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -829,13 +806,13 @@ describe('DocumentPicker', () => {
|
||||
it('should handle empty datasetId', () => {
|
||||
renderComponent({ datasetId: '' })
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle UUID format datasetId', () => {
|
||||
renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' })
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -926,6 +903,7 @@ describe('DocumentPicker', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderComponent({ onChange })
|
||||
openPopover()
|
||||
|
||||
fireEvent.click(screen.getByText('Document 2'))
|
||||
|
||||
@@ -939,6 +917,7 @@ describe('DocumentPicker', () => {
|
||||
mockDocumentListData = { data: docs }
|
||||
|
||||
renderComponent()
|
||||
openPopover()
|
||||
|
||||
// Documents should be rendered in the list
|
||||
expect(screen.getByText('Document 1')).toBeInTheDocument()
|
||||
@@ -978,14 +957,14 @@ describe('DocumentPicker', () => {
|
||||
|
||||
// The mapping: d.data_source_detail_dict?.upload_file?.extension || ''
|
||||
// Should extract 'pdf' from the document
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger with SearchInput integration', () => {
|
||||
renderComponent()
|
||||
|
||||
// The trigger is always rendered
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should integrate FileIcon component', () => {
|
||||
@@ -1001,7 +980,7 @@ describe('DocumentPicker', () => {
|
||||
})
|
||||
|
||||
// FileIcon should render an SVG icon for the file extension
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1010,9 +989,10 @@ describe('DocumentPicker', () => {
|
||||
describe('Visual States', () => {
|
||||
it('should render portal content for document selection', () => {
|
||||
renderComponent()
|
||||
openPopover()
|
||||
|
||||
// Portal content is rendered in our mock for testing
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
// Popover content is rendered after opening the trigger in our mock
|
||||
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,34 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PreviewDocumentPicker from '../preview-document-picker'
|
||||
|
||||
// Mock portal-to-follow-elem - always render content for testing
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
}) => (
|
||||
<div data-testid="portal-elem" data-open={String(open || false)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
// Always render content to allow testing document selection
|
||||
PortalToFollowElemContent: ({ children, className }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="portal-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
// Factory function to create mock DocumentItem
|
||||
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
|
||||
@@ -67,6 +40,10 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof PreviewDocum
|
||||
}
|
||||
}
|
||||
|
||||
const openPopover = () => {
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
}
|
||||
|
||||
describe('PreviewDocumentPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -77,7 +54,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document name from value prop', () => {
|
||||
@@ -110,7 +87,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: [], // Use empty files to avoid duplicate icons
|
||||
})
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -120,7 +97,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: [], // Use empty files to avoid duplicate icons
|
||||
})
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -131,22 +108,21 @@ describe('PreviewDocumentPicker', () => {
|
||||
const props = createDefaultProps()
|
||||
render(<PreviewDocumentPicker {...props} />)
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply className to trigger element', () => {
|
||||
renderComponent({ className: 'custom-class' })
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const innerDiv = trigger.querySelector('.custom-class')
|
||||
expect(innerDiv).toBeInTheDocument()
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should handle empty files array', () => {
|
||||
// Component should render without crashing with empty files
|
||||
renderComponent({ files: [] })
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle single file', () => {
|
||||
@@ -155,7 +131,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple files', () => {
|
||||
@@ -164,7 +140,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: createMockDocumentList(5),
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use value.extension for file icon', () => {
|
||||
@@ -172,7 +148,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
|
||||
})
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -182,13 +158,13 @@ describe('PreviewDocumentPicker', () => {
|
||||
it('should initialize with popup closed', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
|
||||
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
|
||||
it('should toggle popup when trigger is clicked', () => {
|
||||
renderComponent()
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(trigger).toBeInTheDocument()
|
||||
@@ -196,9 +172,10 @@ describe('PreviewDocumentPicker', () => {
|
||||
|
||||
it('should render portal content for document selection', () => {
|
||||
renderComponent()
|
||||
openPopover()
|
||||
|
||||
// Portal content is always rendered in our mock for testing
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
// Popover content is rendered after opening the trigger in our mock
|
||||
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -242,7 +219,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,7 +242,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -274,7 +251,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
it('should toggle popup when trigger is clicked', () => {
|
||||
renderComponent()
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(trigger).toBeInTheDocument()
|
||||
@@ -283,6 +260,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
it('should render document list with files', () => {
|
||||
const files = createMockDocumentList(3)
|
||||
renderComponent({ files })
|
||||
openPopover()
|
||||
|
||||
// Documents should be visible in the list
|
||||
expect(screen.getByText('Document 1')).toBeInTheDocument()
|
||||
@@ -295,6 +273,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
const files = createMockDocumentList(3)
|
||||
|
||||
renderComponent({ files, onChange })
|
||||
openPopover()
|
||||
|
||||
fireEvent.click(screen.getByText('Document 2'))
|
||||
|
||||
@@ -306,7 +285,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
it('should handle rapid toggle clicks', () => {
|
||||
renderComponent()
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
// Rapid clicks
|
||||
fireEvent.click(trigger)
|
||||
@@ -337,14 +316,14 @@ describe('PreviewDocumentPicker', () => {
|
||||
// Renders placeholder for missing name
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
// Portal wrapper renders
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty files array', () => {
|
||||
renderComponent({ files: [] })
|
||||
|
||||
// Component should render without crashing
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long document names', () => {
|
||||
@@ -374,7 +353,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
render(<PreviewDocumentPicker {...props} />)
|
||||
|
||||
// Component should render without crashing
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large number of files', () => {
|
||||
@@ -382,7 +361,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
renderComponent({ files: manyFiles })
|
||||
|
||||
// Component should accept large files array
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle files with same name but different extensions', () => {
|
||||
@@ -393,7 +372,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
renderComponent({ files })
|
||||
|
||||
// Component should handle duplicate names
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -427,7 +406,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: [createMockDocumentItem({ name: 'Single' })],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle two files', () => {
|
||||
@@ -435,7 +414,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: createMockDocumentList(2),
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle many files', () => {
|
||||
@@ -443,7 +422,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: createMockDocumentList(50),
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -451,23 +430,22 @@ describe('PreviewDocumentPicker', () => {
|
||||
it('should apply custom className', () => {
|
||||
renderComponent({ className: 'my-custom-class' })
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(trigger.querySelector('.my-custom-class')).toBeInTheDocument()
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger).toHaveClass('my-custom-class')
|
||||
})
|
||||
|
||||
it('should work without className', () => {
|
||||
renderComponent({ className: undefined })
|
||||
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple class names', () => {
|
||||
renderComponent({ className: 'class-one class-two' })
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const element = trigger.querySelector('.class-one')
|
||||
expect(element).toBeInTheDocument()
|
||||
expect(element).toHaveClass('class-two')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger).toHaveClass('class-one')
|
||||
expect(trigger).toHaveClass('class-two')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -480,7 +458,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: [], // Use empty files to avoid duplicate icons
|
||||
})
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -491,6 +469,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
it('should render all documents in the list', () => {
|
||||
const files = createMockDocumentList(5)
|
||||
renderComponent({ files })
|
||||
openPopover()
|
||||
|
||||
// All documents should be visible
|
||||
files.forEach((file) => {
|
||||
@@ -503,6 +482,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
const files = createMockDocumentList(3)
|
||||
|
||||
renderComponent({ files, onChange })
|
||||
openPopover()
|
||||
|
||||
fireEvent.click(screen.getByText('Document 1'))
|
||||
|
||||
@@ -528,6 +508,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
openPopover()
|
||||
expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -537,9 +518,8 @@ describe('PreviewDocumentPicker', () => {
|
||||
it('should apply hover styles on trigger', () => {
|
||||
renderComponent()
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const innerDiv = trigger.querySelector('.hover\\:bg-state-base-hover')
|
||||
expect(innerDiv).toBeInTheDocument()
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
expect(trigger).toHaveClass('hover:bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should have truncate class for long names', () => {
|
||||
@@ -568,6 +548,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
const files = createMockDocumentList(3)
|
||||
|
||||
renderComponent({ files, onChange })
|
||||
openPopover()
|
||||
|
||||
fireEvent.click(screen.getByText('Document 1'))
|
||||
|
||||
@@ -582,10 +563,12 @@ describe('PreviewDocumentPicker', () => {
|
||||
]
|
||||
|
||||
renderComponent({ files: customFiles, onChange })
|
||||
openPopover()
|
||||
|
||||
fireEvent.click(screen.getByText('Custom File 1'))
|
||||
expect(onChange).toHaveBeenCalledWith(customFiles[0])
|
||||
|
||||
openPopover()
|
||||
fireEvent.click(screen.getByText('Custom File 2'))
|
||||
expect(onChange).toHaveBeenCalledWith(customFiles[1])
|
||||
})
|
||||
@@ -597,8 +580,11 @@ describe('PreviewDocumentPicker', () => {
|
||||
renderComponent({ files, onChange })
|
||||
|
||||
// Select multiple documents sequentially
|
||||
openPopover()
|
||||
fireEvent.click(screen.getByText('Document 1'))
|
||||
openPopover()
|
||||
fireEvent.click(screen.getByText('Document 3'))
|
||||
openPopover()
|
||||
fireEvent.click(screen.getByText('Document 2'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(3)
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
@@ -9,11 +14,6 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { useDocumentList } from '@/service/knowledge/use-document'
|
||||
@@ -61,7 +61,6 @@ const DocumentPicker: FC<Props> = ({
|
||||
|
||||
const [open, {
|
||||
set: setOpen,
|
||||
toggle: togglePopup,
|
||||
}] = useBoolean(false)
|
||||
const ArrowIcon = RiArrowDownSLine
|
||||
|
||||
@@ -77,34 +76,40 @@ const DocumentPicker: FC<Props> = ({
|
||||
}, [parentMode, t])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={togglePopup}>
|
||||
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<FileIcon name={name} extension={extension} size="xl" />
|
||||
<div className="mr-0.5 ml-1 flex flex-col items-start">
|
||||
<div className="flex items-center space-x-0.5">
|
||||
<span className={cn('system-md-semibold text-text-primary')}>
|
||||
{' '}
|
||||
{name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="h-4 w-4 text-text-primary" />
|
||||
</div>
|
||||
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
|
||||
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
|
||||
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
|
||||
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
|
||||
</span>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<FileIcon name={name} extension={extension} size="xl" />
|
||||
<div className="mr-0.5 ml-1 flex flex-col items-start">
|
||||
<div className="flex items-center space-x-0.5">
|
||||
<span className={cn('system-md-semibold text-text-primary')}>
|
||||
{' '}
|
||||
{name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="h-4 w-4 text-text-primary" />
|
||||
</div>
|
||||
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
|
||||
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
|
||||
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
|
||||
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pt-2 shadow-lg backdrop-blur-[5px]">
|
||||
<SearchInput value={query} onChange={setQuery} className="mx-1" />
|
||||
{documentsList
|
||||
@@ -125,9 +130,8 @@ const DocumentPicker: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(DocumentPicker)
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DocumentItem } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import FileIcon from '../document-file-icon'
|
||||
import DocumentList from './document-list'
|
||||
|
||||
@@ -35,7 +35,6 @@ const PreviewDocumentPicker: FC<Props> = ({
|
||||
|
||||
const [open, {
|
||||
set: setOpen,
|
||||
toggle: togglePopup,
|
||||
}] = useBoolean(false)
|
||||
const ArrowIcon = RiArrowDownSLine
|
||||
|
||||
@@ -45,27 +44,32 @@ const PreviewDocumentPicker: FC<Props> = ({
|
||||
}, [onChange, setOpen])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={togglePopup}>
|
||||
<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)}>
|
||||
<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">
|
||||
<span className={cn('max-w-[200px] truncate system-md-semibold text-text-primary')}>
|
||||
{' '}
|
||||
{name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="h-[18px] w-[18px] text-text-primary" />
|
||||
<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)}>
|
||||
<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">
|
||||
<span className={cn('max-w-[200px] truncate system-md-semibold text-text-primary')}>
|
||||
{' '}
|
||||
{name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="h-[18px] w-[18px] text-text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[392px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
|
||||
{files?.length > 1 && <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary">{t('preprocessDocument', { ns: 'dataset', num: files.length })}</div>}
|
||||
{files?.length > 0
|
||||
@@ -81,9 +85,8 @@ const PreviewDocumentPicker: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(PreviewDocumentPicker)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
import MemberItem from './member-item'
|
||||
@@ -90,93 +90,98 @@ const PermissionSelector = ({
|
||||
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => !disabled && setOpen(v => !v)}
|
||||
className="block"
|
||||
>
|
||||
<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!')}>
|
||||
{
|
||||
isOnlyMe && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAllTeamMembers && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<RiGroup2Line className="size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t('form.permissionsAllMember', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isPartialMembers && (
|
||||
<>
|
||||
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
||||
{
|
||||
selectedMembers.length === 1 && (
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedMembers.length >= 2 && (
|
||||
<>
|
||||
<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!')}>
|
||||
{
|
||||
isOnlyMe && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAllTeamMembers && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<RiGroup2Line className="size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t('form.permissionsAllMember', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isPartialMembers && (
|
||||
<>
|
||||
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
||||
{
|
||||
selectedMembers.length === 1 && (
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
className="absolute top-0 left-0 z-0"
|
||||
size="xxs"
|
||||
size="xs"
|
||||
/>
|
||||
<Avatar
|
||||
avatar={selectedMembers[1]!.avatar_url}
|
||||
name={selectedMembers[1]!.name}
|
||||
className="absolute right-0 bottom-0 z-10"
|
||||
size="xxs"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
title={selectedMemberNames}
|
||||
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
||||
>
|
||||
{selectedMemberNames}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
disabled && 'text-components-input-text-placeholder!',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedMembers.length >= 2 && (
|
||||
<>
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
className="absolute top-0 left-0 z-0"
|
||||
size="xxs"
|
||||
/>
|
||||
<Avatar
|
||||
avatar={selectedMembers[1]!.avatar_url}
|
||||
name={selectedMembers[1]!.name}
|
||||
className="absolute right-0 bottom-0 z-10"
|
||||
size="xxs"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
title={selectedMemberNames}
|
||||
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
||||
>
|
||||
{selectedMemberNames}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
disabled && 'text-components-input-text-placeholder!',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
|
||||
<div className="p-1">
|
||||
{/* Only me */}
|
||||
@@ -236,6 +241,7 @@ const PermissionSelector = ({
|
||||
)}
|
||||
{filteredMemberList.map(member => (
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
leftIcon={
|
||||
<Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" />
|
||||
}
|
||||
@@ -256,9 +262,9 @@ const PermissionSelector = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import type { DataSourceAuth } from '../types'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types'
|
||||
@@ -6,6 +7,15 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
|
||||
import Configure from '../configure'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
|
||||
<button {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Configure Component Tests
|
||||
* Using Unit approach to ensure 100% coverage and stable tests.
|
||||
|
||||
@@ -5,6 +5,11 @@ import type {
|
||||
PluginPayload,
|
||||
} from '@/app/components/plugins/plugin-auth/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
@@ -15,11 +20,6 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
AddApiKeyButton,
|
||||
AddOAuthButton,
|
||||
@@ -56,10 +56,6 @@ const Configure = ({
|
||||
}
|
||||
}, [pluginPayload, t])
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setOpen(v => !v)
|
||||
}, [])
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
setOpen(false)
|
||||
onUpdate?.()
|
||||
@@ -67,24 +63,26 @@ const Configure = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={-4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
|
||||
{
|
||||
!!canOAuth && (
|
||||
@@ -122,8 +120,8 @@ const Configure = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TagsFilter from '../tags-filter'
|
||||
|
||||
const { mockTranslate } = vi.hoisted(() => ({
|
||||
mockTranslate: vi.fn((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key),
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
t: mockTranslate,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -46,20 +55,7 @@ vi.mock('@/app/components/base/input', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => <button data-testid="portal-trigger" onClick={onClick}>{children}</button>,
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
|
||||
}
|
||||
})
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
vi.mock('../trigger/marketplace', () => ({
|
||||
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
|
||||
@@ -80,8 +76,16 @@ vi.mock('../trigger/tool-selector', () => ({
|
||||
}))
|
||||
|
||||
describe('TagsFilter', () => {
|
||||
const ensurePopoverOpen = () => {
|
||||
if (!screen.queryByTestId('popover-content'))
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
return screen.getByTestId('popover-content')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslate.mockImplementation((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key)
|
||||
})
|
||||
|
||||
it('renders marketplace trigger when used in marketplace', () => {
|
||||
@@ -100,6 +104,7 @@ describe('TagsFilter', () => {
|
||||
|
||||
it('filters tag options by search text', () => {
|
||||
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('RAG')).toBeInTheDocument()
|
||||
@@ -116,11 +121,20 @@ describe('TagsFilter', () => {
|
||||
const onTagsChange = vi.fn()
|
||||
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Agent'))
|
||||
fireEvent.click(within(ensurePopoverOpen()).getByText('Agent'))
|
||||
expect(onTagsChange).toHaveBeenCalledWith([])
|
||||
|
||||
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
||||
fireEvent.click(screen.getByText('RAG'))
|
||||
fireEvent.click(within(ensurePopoverOpen()).getByText('RAG'))
|
||||
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
|
||||
})
|
||||
|
||||
it('falls back to an empty placeholder when translation is missing', () => {
|
||||
mockTranslate.mockImplementation(() => undefined as unknown as string)
|
||||
|
||||
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import MarketplaceTrigger from './trigger/marketplace'
|
||||
import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
@@ -37,43 +37,45 @@ const TagsFilter = ({
|
||||
const selectedTagsLength = tags.length
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className="shrink-0">
|
||||
{
|
||||
usedInMarketplace && (
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!usedInMarketplace && (
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
alignOffset={-6}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
{
|
||||
usedInMarketplace && (
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!usedInMarketplace && (
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@@ -103,8 +105,8 @@ const TagsFilter = ({
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<div data-testid="portal" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
@@ -67,7 +57,7 @@ describe('CategoriesFilter', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
const clearSvg = trigger.querySelector('svg')
|
||||
fireEvent.click(clearSvg!)
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
@@ -75,6 +65,7 @@ describe('CategoriesFilter', () => {
|
||||
|
||||
it('should render category options in dropdown', () => {
|
||||
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByText('Tool'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Model'))!.toBeInTheDocument()
|
||||
@@ -85,6 +76,7 @@ describe('CategoriesFilter', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<CategoriesFilter value={[]} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
fireEvent.click(screen.getByText('Tool'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['tool'])
|
||||
})
|
||||
@@ -93,8 +85,20 @@ describe('CategoriesFilter', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
const toolElements = screen.getAllByText('Tool')
|
||||
fireEvent.click(toolElements[toolElements.length - 1]!)
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should filter categories by search text', () => {
|
||||
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { target: { value: 'mod' } })
|
||||
|
||||
expect(screen.queryByText('Tool')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Model')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Extension')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,6 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TagFilter from '../tag-filter'
|
||||
|
||||
let portalOpen = false
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useTags: () => ({
|
||||
tags: [
|
||||
@@ -19,35 +17,17 @@ vi.mock('../../../hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
open,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
}) => {
|
||||
portalOpen = open
|
||||
return <div>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => <button data-testid="trigger" onClick={onClick}>{children}</button>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
|
||||
}))
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
describe('TagFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
portalOpen = false
|
||||
})
|
||||
|
||||
it('renders the all tags placeholder when nothing is selected', () => {
|
||||
render(<TagFilter value={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders selected tag labels and the overflow counter', () => {
|
||||
@@ -61,8 +41,8 @@ describe('TagFilter', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
const portal = screen.getByTestId('portal-content')
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
const portal = screen.getByTestId('popover-content')
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } })
|
||||
|
||||
@@ -73,4 +53,24 @@ describe('TagFilter', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['agent', 'rag'])
|
||||
})
|
||||
|
||||
it('clears all selected tags when the clear icon is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
|
||||
const trigger = screen.getByTestId('popover-trigger')
|
||||
fireEvent.click(trigger.querySelector('svg')!)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('removes a selected tag when clicking the same option again', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
fireEvent.click(within(screen.getByTestId('popover-content')).getByText('Agent'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCloseCircleFill,
|
||||
@@ -9,11 +14,6 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useCategories } from '../../hooks'
|
||||
|
||||
type CategoriesFilterProps = {
|
||||
@@ -38,61 +38,64 @@ const CategoriesFilter = ({
|
||||
const selectedTagsLength = value.length
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(
|
||||
'flex items-center p-1 system-sm-medium',
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center p-1 system-sm-medium',
|
||||
)}
|
||||
>
|
||||
{
|
||||
!selectedTagsLength && t('allCategories', { ns: 'plugin' })
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',')
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!selectedTagsLength && t('allCategories', { ns: 'plugin' })
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
onClick={
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',')
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
!selectedTagsLength && (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
onClick={
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!selectedTagsLength && (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@@ -122,8 +125,8 @@ const CategoriesFilter = ({
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCloseCircleFill,
|
||||
@@ -9,11 +14,6 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useTags } from '../../hooks'
|
||||
|
||||
type TagsFilterProps = {
|
||||
@@ -38,56 +38,62 @@ const TagsFilter = ({
|
||||
const selectedTagsLength = value.length
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(
|
||||
'flex items-center p-1 system-sm-medium',
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center p-1 system-sm-medium',
|
||||
)}
|
||||
>
|
||||
{
|
||||
!selectedTagsLength && t('allTags', { ns: 'pluginTags' })
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',')
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!selectedTagsLength && t('allTags', { ns: 'pluginTags' })
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',')
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
!selectedTagsLength && (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
onClick={() => onChange([])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!selectedTagsLength && (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@@ -117,8 +123,8 @@ const TagsFilter = ({
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type {
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
} from 'react'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
@@ -12,6 +13,12 @@ import type {
|
||||
OnSelectBlock,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -23,11 +30,6 @@ import {
|
||||
Plus02,
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum, isTriggerNode } from '../types'
|
||||
@@ -121,6 +123,9 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setLocalOpen(newOpen)
|
||||
|
||||
if (!newOpen)
|
||||
@@ -128,13 +133,10 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
|
||||
if (onOpenChange)
|
||||
onOpenChange(newOpen)
|
||||
}, [onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
||||
if (disabled)
|
||||
return
|
||||
}, [disabled, onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLElement>>((e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!open)
|
||||
}, [handleOpenChange, open, disabled])
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleOpenChange(false)
|
||||
@@ -174,36 +176,58 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
return ''
|
||||
}, [activeTab, t])
|
||||
|
||||
const defaultTriggerElement = (
|
||||
<div
|
||||
className={`
|
||||
z-10 flex h-4
|
||||
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
||||
${triggerClassName?.(open)}
|
||||
`}
|
||||
style={triggerStyle}
|
||||
>
|
||||
<Plus02 className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)
|
||||
const triggerElement = trigger ? trigger(open) : defaultTriggerElement
|
||||
const triggerElementProps = React.isValidElement(triggerElement)
|
||||
? (triggerElement.props as {
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
})
|
||||
: null
|
||||
const resolvedTriggerElement = asChild && React.isValidElement(triggerElement)
|
||||
? React.cloneElement(
|
||||
triggerElement as React.ReactElement<{
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
}>,
|
||||
{
|
||||
onClick: (e: ReactMouseEvent<HTMLElement>) => {
|
||||
handleTrigger(e)
|
||||
if (typeof triggerElementProps?.onClick === 'function')
|
||||
triggerElementProps.onClick(e)
|
||||
},
|
||||
},
|
||||
)
|
||||
: (
|
||||
<div className={triggerInnerClassName} onClick={handleTrigger}>
|
||||
{triggerElement}
|
||||
</div>
|
||||
)
|
||||
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
|
||||
const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0)
|
||||
const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
asChild={asChild}
|
||||
onClick={handleTrigger}
|
||||
className={triggerInnerClassName}
|
||||
<PopoverTrigger render={resolvedTriggerElement as React.ReactElement} />
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
{
|
||||
trigger
|
||||
? trigger(open)
|
||||
: (
|
||||
<div
|
||||
className={`
|
||||
z-10 flex h-4
|
||||
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
||||
${triggerClassName?.(open)}
|
||||
`}
|
||||
style={triggerStyle}
|
||||
>
|
||||
<Plus02 className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<div className={`rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg ${popupClassName}`}>
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
@@ -270,8 +294,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
forceShowStartContent={forceShowStartContent}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,13 @@ const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
|
||||
const mockCloseAllInputFieldPanels = vi.fn()
|
||||
const mockHandleNodesCancelSelected = vi.fn()
|
||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||
const mockHandleBackupDraft = vi.fn()
|
||||
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
|
||||
|
||||
let mockIsChatMode = false
|
||||
|
||||
vi.mock('../../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
|
||||
vi.mock('../../hooks', () => {
|
||||
return {
|
||||
...actual,
|
||||
useIsChatMode: () => mockIsChatMode,
|
||||
useNodesInteractions: () => ({
|
||||
handleNodesCancelSelected: mockHandleNodesCancelSelected,
|
||||
@@ -25,6 +24,9 @@ vi.mock('../../hooks', async () => {
|
||||
useWorkflowInteractions: () => ({
|
||||
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
||||
}),
|
||||
useWorkflowRun: () => ({
|
||||
handleBackupDraft: mockHandleBackupDraft,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,38 +50,46 @@ vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div data-testid="loading" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
const PortalContext = React.createContext({ open: false })
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
open,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
open: boolean
|
||||
}) => <PortalContext.Provider value={{ open }}>{children}</PortalContext.Provider>,
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => <div data-testid="portal-trigger" onClick={onClick}>{children}</div>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
}) => {
|
||||
const { open } = React.useContext(PortalContext)
|
||||
return open ? <div data-testid="portal-content">{children}</div> : null
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@langgenius/dify-ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({
|
||||
children,
|
||||
render,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
render?: React.ReactElement
|
||||
}) => {
|
||||
if (render && React.isValidElement(render)) {
|
||||
const renderElement = render as React.ReactElement<{ children?: React.ReactNode }>
|
||||
return React.cloneElement(renderElement, renderElement.props, children)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
},
|
||||
TooltipContent: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
vi.mock('../../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
|
||||
@@ -130,7 +140,7 @@ describe('ViewHistory', () => {
|
||||
})
|
||||
|
||||
expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
||||
|
||||
@@ -165,7 +175,6 @@ describe('ViewHistory', () => {
|
||||
})
|
||||
|
||||
it('renders workflow run history items and updates the workflow store when one is selected', () => {
|
||||
const handleBackupDraft = vi.fn()
|
||||
const pausedRun = createHistoryItem({
|
||||
id: 'run-paused',
|
||||
status: WorkflowRunningStatus.Paused,
|
||||
@@ -199,9 +208,6 @@ describe('ViewHistory', () => {
|
||||
showEnvPanel: true,
|
||||
controlMode: ControlMode.Pointer,
|
||||
},
|
||||
hooksStoreProps: {
|
||||
handleBackupDraft,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
||||
@@ -217,7 +223,7 @@ describe('ViewHistory', () => {
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
expect(store.getState().controlMode).toBe(ControlMode.Hand)
|
||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||
expect(handleBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -271,6 +277,6 @@ describe('ViewHistory', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
|
||||
import {
|
||||
useStore,
|
||||
@@ -61,52 +65,60 @@ const ViewHistory = ({
|
||||
|
||||
return (
|
||||
(
|
||||
<PortalToFollowElem
|
||||
placement={withText ? 'bottom-start' : 'bottom-end'}
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: withText ? -8 : 10,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
{
|
||||
withText && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
||||
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
{withText
|
||||
? (
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
||||
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 i-custom-vender-line-time-clock-play h-4 w-4" />
|
||||
{t('common.showRunHistory', { ns: 'workflow' })}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 i-custom-vender-line-time-clock-play h-4 w-4" />
|
||||
{t('common.showRunHistory', { ns: 'workflow' })}
|
||||
</button>
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!withText && (
|
||||
<Tooltip
|
||||
popupContent={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
onClick={() => {
|
||||
onClearLogAndMessageModal?.()
|
||||
}}
|
||||
: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<div className="flex" />}
|
||||
>
|
||||
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
|
||||
</button>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
onClick={() => {
|
||||
onClearLogAndMessageModal?.()
|
||||
}}
|
||||
>
|
||||
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-12">
|
||||
)}
|
||||
<PopoverContent
|
||||
placement={withText ? 'bottom-start' : 'bottom-end'}
|
||||
sideOffset={4}
|
||||
alignOffset={withText ? -8 : 10}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
className="ml-2 flex w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
|
||||
style={{
|
||||
@@ -207,8 +219,8 @@ const ViewHistory = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiHistoryLine,
|
||||
@@ -13,11 +18,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Divider from '../../base/divider'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import {
|
||||
@@ -91,12 +91,20 @@ const ViewWorkflowHistory = () => {
|
||||
}, [t])
|
||||
|
||||
const calculateChangeList: ChangeHistoryList = useMemo(() => {
|
||||
const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial<WorkflowHistoryState>, index: number) => {
|
||||
const nodes = (state.nodes || store.getState().nodes) || []
|
||||
const nodeId = state?.workflowHistoryEventMeta?.nodeId
|
||||
const filterList = (
|
||||
list: Array<Partial<WorkflowHistoryState> | undefined>,
|
||||
startIndex = 0,
|
||||
reverse = false,
|
||||
) => list.flatMap((state, index) => {
|
||||
if (!state)
|
||||
return []
|
||||
|
||||
const nodes = state.nodes || store.getState().nodes || []
|
||||
const nodeId = state.workflowHistoryEventMeta?.nodeId
|
||||
const targetTitle = nodes.find(n => n.id === nodeId)?.data?.title ?? ''
|
||||
return {
|
||||
label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent),
|
||||
|
||||
return [{
|
||||
label: state.workflowHistoryEvent ? getHistoryLabel(state.workflowHistoryEvent) : '',
|
||||
index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
|
||||
state: {
|
||||
...state,
|
||||
@@ -107,8 +115,8 @@ const ViewWorkflowHistory = () => {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}]
|
||||
})
|
||||
|
||||
const historyData = {
|
||||
pastStates: filterList(pastStates, pastStates.length).reverse(),
|
||||
@@ -132,35 +140,42 @@ const ViewWorkflowHistory = () => {
|
||||
|
||||
return (
|
||||
(
|
||||
<PortalToFollowElem
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 131,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !nodesReadOnly && setOpen(v => !v)}>
|
||||
<TipPopup
|
||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
>
|
||||
<RiHistoryLine className="h-4 w-4" />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-12">
|
||||
<TipPopup
|
||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className={
|
||||
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
>
|
||||
<RiHistoryLine className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TipPopup>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={131}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
className="ml-2 flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
|
||||
>
|
||||
@@ -293,8 +308,8 @@ const ViewWorkflowHistory = () => {
|
||||
<div className="mb-1 leading-[18px] text-text-tertiary">{t('changeHistory.hintText', { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,42 +21,7 @@ vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
const OpenContext = React.createContext(false)
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
children?: React.ReactNode
|
||||
}) => (
|
||||
<OpenContext value={open}>
|
||||
<div data-testid="portal" data-open={String(open)}>{children}</div>
|
||||
</OpenContext>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
}) => {
|
||||
const open = React.use(OpenContext)
|
||||
return open ? <div data-testid="portal-content">{children}</div> : null
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
describe('ButtonStyleDropdown', () => {
|
||||
const onChange = vi.fn()
|
||||
@@ -80,10 +45,10 @@ describe('ButtonStyleDropdown', () => {
|
||||
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variant: 'ghost',
|
||||
}))
|
||||
expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'false')
|
||||
expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false')
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'true')
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'true')
|
||||
expect(screen.getByText('nodes.humanInput.userActions.chooseStyle'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement)
|
||||
@@ -111,10 +76,10 @@ describe('ButtonStyleDropdown', () => {
|
||||
variant: 'secondary',
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'false')
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false')
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiFontSize,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { UserActionButtonType } from '../types'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput'
|
||||
@@ -45,23 +45,29 @@ const ButtonStyleDropdown: FC<Props> = ({
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open && !readonly}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 44,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (readonly)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)}>
|
||||
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
|
||||
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
|
||||
<RiFontSize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
|
||||
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
|
||||
<RiFontSize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={44}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-xs">
|
||||
<div className="system-md-medium text-text-primary">{t(`${i18nPrefix}.userActions.chooseStyle`, { ns: 'workflow' })}</div>
|
||||
<div className="mt-2 flex w-[324px] flex-wrap gap-1">
|
||||
@@ -103,8 +109,8 @@ const ButtonStyleDropdown: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiFilter3Line } from '@remixicon/react'
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import MetadataPanel from './metadata-panel'
|
||||
|
||||
const MetadataTrigger = ({
|
||||
@@ -40,25 +40,29 @@ const MetadataTrigger = ({
|
||||
}, [metadataFilteringConditions, metadataList, handleRemoveCondition, selectedDatasetsLoaded])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="left"
|
||||
offset={4}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
>
|
||||
<RiFilter3Line className="mr-1 h-3.5 w-3.5" />
|
||||
{t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })}
|
||||
<div className="ml-1 flex items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{metadataFilteringConditions?.conditions.length || 0}
|
||||
</div>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
>
|
||||
<RiFilter3Line className="mr-1 h-3.5 w-3.5" />
|
||||
{t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })}
|
||||
<div className="ml-1 flex items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{metadataFilteringConditions?.conditions.length || 0}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="left"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<MetadataPanel
|
||||
metadataFilteringConditions={metadataFilteringConditions}
|
||||
onCancel={() => setOpen(false)}
|
||||
@@ -66,8 +70,8 @@ const MetadataTrigger = ({
|
||||
handleRemoveCondition={handleRemoveCondition}
|
||||
{...restProps}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ import type { DataSet } from '@/models/datasets'
|
||||
import type { DatasetConfigs } from '@/models/debug'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ConfigRetrievalContent from '@/app/components/app/configuration/dataset-config/params-config/config-content'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { DATASET_DEFAULT } from '@/config'
|
||||
import { RETRIEVE_TYPE } from '@/types/app'
|
||||
|
||||
@@ -114,32 +114,33 @@ const RetrievalConfig: FC<Props> = ({
|
||||
}, [onMultipleRetrievalConfigChange, retrieval_mode, onRetrievalModeChange])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={rerankModalOpen}
|
||||
onOpenChange={handleOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
crossAxis: -2,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (readonly)
|
||||
return
|
||||
handleOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => {
|
||||
if (readonly)
|
||||
return
|
||||
handleOpen(!rerankModalOpen)
|
||||
}}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={readonly}
|
||||
className={cn(rerankModalOpen && 'bg-components-button-ghost-bg-hover')}
|
||||
>
|
||||
<RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />
|
||||
{t('retrievalSettings', { ns: 'dataset' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={0}
|
||||
alignOffset={-2}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={readonly}
|
||||
className={cn(rerankModalOpen && 'bg-components-button-ghost-bg-hover')}
|
||||
>
|
||||
<RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />
|
||||
{t('retrievalSettings', { ns: 'dataset' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
|
||||
<div className="w-[404px] rounded-2xl border border-components-panel-border bg-components-panel-bg px-4 pt-3 pb-4 shadow-xl">
|
||||
<ConfigRetrievalContent
|
||||
datasetConfigs={datasetConfigs}
|
||||
@@ -151,8 +152,8 @@ const RetrievalConfig: FC<Props> = ({
|
||||
onSingleRetrievalModelParamsChange={onSingleRetrievalModelParamsChange}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(RetrievalConfig)
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { SchemaRoot } from '../../../types'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CompletionParams, Model } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
@@ -27,61 +27,68 @@ type JsonSchemaGeneratorProps = {
|
||||
crossAxisOffset?: number
|
||||
}
|
||||
|
||||
enum GeneratorView {
|
||||
promptEditor = 'promptEditor',
|
||||
result = 'result',
|
||||
const GENERATOR_VIEWS = {
|
||||
promptEditor: 'promptEditor',
|
||||
result: 'result',
|
||||
} as const
|
||||
|
||||
type GeneratorView = typeof GENERATOR_VIEWS[keyof typeof GENERATOR_VIEWS]
|
||||
|
||||
const createEmptyModel = (): Model => ({
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: ModelModeType.completion,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
|
||||
const getStoredModel = (): Model | null => {
|
||||
if (typeof window === 'undefined')
|
||||
return null
|
||||
|
||||
const savedModel = window.localStorage.getItem('auto-gen-model')
|
||||
|
||||
if (!savedModel)
|
||||
return null
|
||||
|
||||
return JSON.parse(savedModel) as Model
|
||||
}
|
||||
|
||||
const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
onApply,
|
||||
crossAxisOffset,
|
||||
}) => {
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
|
||||
: null
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState(GeneratorView.promptEditor)
|
||||
const [model, setModel] = useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: ModelModeType.completion,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const [view, setView] = useState<GeneratorView>(GENERATOR_VIEWS.promptEditor)
|
||||
const [model, setModel] = useState<Model | null>(() => getStoredModel())
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [schema, setSchema] = useState<SchemaRoot | null>(null)
|
||||
const { theme } = useTheme()
|
||||
const {
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const resolvedModel = React.useMemo<Model>(() => {
|
||||
if (model)
|
||||
return model
|
||||
|
||||
if (!defaultModel)
|
||||
return createEmptyModel()
|
||||
|
||||
return {
|
||||
...createEmptyModel(),
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
}
|
||||
}, [defaultModel, model])
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const { emit } = useMittContext()
|
||||
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) {
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
|
||||
: null
|
||||
if (localModel) {
|
||||
setModel(localModel)
|
||||
}
|
||||
else {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [defaultModel])
|
||||
|
||||
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
if (advancedEditing || isAddingNewField)
|
||||
emit('quitEditing', {})
|
||||
setOpen(!open)
|
||||
}, [open, advancedEditing, isAddingNewField, emit])
|
||||
}, [advancedEditing, isAddingNewField, emit])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
@@ -89,39 +96,39 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
...resolvedModel,
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: newValue.mode as ModelModeType,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
window.localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [resolvedModel])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
...resolvedModel,
|
||||
completion_params: newParams as CompletionParams,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
window.localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [resolvedModel])
|
||||
|
||||
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()
|
||||
|
||||
const generateSchema = useCallback(async () => {
|
||||
const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! })
|
||||
const { output, error } = await generateStructuredOutputRules({ instruction, model_config: resolvedModel })
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setSchema(null)
|
||||
setView(GeneratorView.promptEditor)
|
||||
setView(GENERATOR_VIEWS.promptEditor)
|
||||
return
|
||||
}
|
||||
return output
|
||||
}, [instruction, model, generateStructuredOutputRules])
|
||||
}, [generateStructuredOutputRules, instruction, resolvedModel])
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setView(GeneratorView.result)
|
||||
setView(GENERATOR_VIEWS.result)
|
||||
const output = await generateSchema()
|
||||
if (output === undefined)
|
||||
return
|
||||
@@ -129,7 +136,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
}, [generateSchema])
|
||||
|
||||
const goBackToPromptEditor = () => {
|
||||
setView(GeneratorView.promptEditor)
|
||||
setView(GENERATOR_VIEWS.promptEditor)
|
||||
}
|
||||
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
@@ -145,31 +152,34 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: crossAxisOffset ?? 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-md p-0.5 hover:bg-state-accent-hover',
|
||||
open && 'bg-state-accent-active',
|
||||
)}
|
||||
>
|
||||
<SchemaGenerator />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-100">
|
||||
{view === GeneratorView.promptEditor && (
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTrigger}
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-md p-0.5 hover:bg-state-accent-hover',
|
||||
open && 'bg-state-accent-active',
|
||||
)}
|
||||
>
|
||||
<SchemaGenerator />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={crossAxisOffset ?? 0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
{view === GENERATOR_VIEWS.promptEditor && (
|
||||
<PromptEditor
|
||||
instruction={instruction}
|
||||
model={model}
|
||||
model={resolvedModel}
|
||||
onInstructionChange={setInstruction}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
onGenerate={handleGenerate}
|
||||
@@ -177,7 +187,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
)}
|
||||
{view === GeneratorView.result && (
|
||||
{view === GENERATOR_VIEWS.result && (
|
||||
<GeneratedResult
|
||||
schema={schema!}
|
||||
isGenerating={isGenerating}
|
||||
@@ -187,8 +197,8 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NoteTheme } from '../../../types'
|
||||
import ColorPicker, { COLOR_LIST } from '../color-picker'
|
||||
import ColorPicker from '../color-picker'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
describe('NoteEditor ColorPicker', () => {
|
||||
it('should open the palette and apply the selected theme', async () => {
|
||||
const onThemeChange = vi.fn()
|
||||
const { container } = render(
|
||||
render(
|
||||
<ColorPicker theme={NoteTheme.blue} onThemeChange={onThemeChange} />,
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const popup = document.body.querySelector('[role="tooltip"]')
|
||||
const popup = screen.getByTestId('popover-content')
|
||||
|
||||
expect(popup).toBeInTheDocument()
|
||||
|
||||
const options = popup?.querySelectorAll('.group.relative')
|
||||
const options = popup.querySelectorAll('.group.relative')
|
||||
|
||||
expect(options).toHaveLength(COLOR_LIST.length)
|
||||
expect(options).toHaveLength(6)
|
||||
|
||||
fireEvent.click(options?.[COLOR_LIST.length - 1] as Element)
|
||||
fireEvent.click(options[5] as Element)
|
||||
|
||||
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import FontSizeSelector from '../font-size-selector'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
const {
|
||||
mockHandleFontSize,
|
||||
mockHandleOpenFontSizeSelector,
|
||||
@@ -52,4 +54,12 @@ describe('NoteEditor FontSizeSelector', () => {
|
||||
expect(mockHandleFontSize).toHaveBeenCalledWith('16px')
|
||||
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should fall back to the small label when current font size is unknown', () => {
|
||||
mockFontSize = '18px'
|
||||
|
||||
render(<FontSizeSelector />)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { THEME_MAP } from '../../constants'
|
||||
import { NoteTheme } from '../../types'
|
||||
|
||||
export const COLOR_LIST = [
|
||||
const COLOR_LIST = [
|
||||
{
|
||||
key: NoteTheme.blue,
|
||||
inner: THEME_MAP[NoteTheme.blue]!.title,
|
||||
@@ -55,28 +55,35 @@ const ColorPicker = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<div className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md hover:bg-black/5',
|
||||
open && 'bg-black/5',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-4 w-4 rounded-full border border-black/5',
|
||||
THEME_MAP[theme]!.title,
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md hover:bg-black/5',
|
||||
open && 'bg-black/5',
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div
|
||||
className={cn(
|
||||
'h-4 w-4 rounded-full border border-black/5',
|
||||
THEME_MAP[theme]!.title,
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="grid grid-cols-3 grid-rows-2 gap-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg">
|
||||
{
|
||||
COLOR_LIST.map(color => (
|
||||
@@ -107,8 +114,8 @@ const ColorPicker = ({
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiFontSize } from '@remixicon/react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useFontSize } from './hooks'
|
||||
|
||||
const FontSizeSelector = () => {
|
||||
@@ -34,23 +34,30 @@ const FontSizeSelector = () => {
|
||||
} = useFontSize()
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={fontSizeSelectorShow}
|
||||
onOpenChange={handleOpenFontSizeSelector}
|
||||
placement="bottom-start"
|
||||
offset={2}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => handleOpenFontSizeSelector(!fontSizeSelectorShow)}>
|
||||
<div className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-md pr-1.5 pl-2 text-[13px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
fontSizeSelectorShow && 'bg-state-base-hover text-text-secondary',
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-md pr-1.5 pl-2 text-[13px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
fontSizeSelectorShow && 'bg-state-base-hover text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<RiFontSize className="mr-1 h-4 w-4" />
|
||||
{FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('nodes.note.editor.small', { ns: 'workflow' })}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<RiFontSize className="mr-1 h-4 w-4" />
|
||||
{FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('nodes.note.editor.small', { ns: 'workflow' })}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={2}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[120px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 text-text-secondary shadow-xl">
|
||||
{
|
||||
FONT_SIZE_LIST.map(font => (
|
||||
@@ -77,8 +84,8 @@ const FontSizeSelector = () => {
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiFilter3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { WorkflowVersionFilterOptions } from '../../../types'
|
||||
import FilterItem from './filter-item'
|
||||
import FilterSwitch from './filter-switch'
|
||||
@@ -37,26 +37,28 @@ const Filter: FC<FilterProps> = ({
|
||||
const isFiltering = filterValue !== WorkflowVersionFilterOptions.all || isOnlyShowNamedVersions
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 55,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0.5',
|
||||
isFiltering ? 'bg-state-accent-active-alt' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<RiFilter3Line className={cn('h-4 w-4', isFiltering ? 'text-text-accent' : 'text-text-tertiary')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-12">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0.5',
|
||||
isFiltering ? 'bg-state-accent-active-alt' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<RiFilter3Line className={cn('h-4 w-4', isFiltering ? 'text-text-accent' : 'text-text-tertiary')} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={55}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="flex w-[248px] 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">
|
||||
{
|
||||
@@ -75,8 +77,8 @@ const Filter: FC<FilterProps> = ({
|
||||
<Divider type="horizontal" className="my-0 h-px bg-divider-subtle" />
|
||||
<FilterSwitch enabled={isOnlyShowNamedVersions} handleSwitch={handleSwitch} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ This document tracks the migration away from legacy overlay APIs.
|
||||
## Migration phases
|
||||
|
||||
1. Business/UI features outside `app/components/base/**`
|
||||
- Migrate old calls to semantic primitives from `@/app/components/base/ui/**`.
|
||||
- Migrate old calls to semantic primitives from `@langgenius/dify-ui/**`.
|
||||
- Keep deprecated imports out of newly touched files.
|
||||
1. Legacy base components in allowlist
|
||||
- Migrate allowlisted base callers gradually.
|
||||
@@ -53,7 +53,7 @@ pnpm -C web lint:fix --prune-suppressions <changed-files>
|
||||
|
||||
## z-index strategy
|
||||
|
||||
All new overlay primitives in `base/ui/` share a single z-index value:
|
||||
All new overlay primitives in `@langgenius/dify-ui/` share a single z-index value:
|
||||
**`z-1002`**, except Toast which stays one layer above at **`z-1003`**.
|
||||
|
||||
### Why z-[1002]?
|
||||
@@ -61,13 +61,13 @@ All new overlay primitives in `base/ui/` share a single z-index value:
|
||||
During the migration period, legacy and new overlays coexist. Legacy overlays
|
||||
portal to `document.body` with explicit z-index values:
|
||||
|
||||
| Layer | z-index | Components |
|
||||
| --------------------------------- | -------------- | -------------------------------------------- |
|
||||
| Legacy Drawer | `z-30` | `base/drawer` |
|
||||
| Legacy Modal | `z-60` | `base/modal` (default) |
|
||||
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
|
||||
| **New UI primitives** | **`z-1002`** | `base/ui/*` (Popover, Dialog, Tooltip, etc.) |
|
||||
| Toast | `z-1003` | `base/ui/toast` |
|
||||
| Layer | z-index | Components |
|
||||
| --------------------------------- | -------------- | -------------------------------------------------------- |
|
||||
| Legacy Drawer | `z-30` | `base/drawer` |
|
||||
| Legacy Modal | `z-60` | `base/modal` (default) |
|
||||
| Legacy PortalToFollowElem callers | up to `z-1001` | various business components |
|
||||
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Tooltip, etc.) |
|
||||
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
|
||||
|
||||
`z-1002` sits above all common legacy overlays, so new primitives always
|
||||
render on top without needing per-call-site z-index hacks. Among themselves,
|
||||
@@ -81,7 +81,7 @@ back to `z-9999`.
|
||||
### Rules
|
||||
|
||||
- **Do NOT add z-index overrides** (e.g. `className="z-1003"`) on new
|
||||
`base/ui/*` components. If you find yourself needing one, the parent legacy
|
||||
`@langgenius/dify-ui/*` components. If you find yourself needing one, the parent legacy
|
||||
overlay should be migrated instead.
|
||||
- When migrating a legacy overlay that has a high z-index, remove the z-index
|
||||
entirely — the new primitive's default `z-1002` handles it.
|
||||
@@ -92,12 +92,12 @@ back to `z-9999`.
|
||||
|
||||
Once all legacy overlays are removed:
|
||||
|
||||
1. Reduce `z-1002` back to `z-50` across all `base/ui/` primitives.
|
||||
1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui/` primitives.
|
||||
1. Reduce Toast from `z-1003` to `z-51`.
|
||||
1. Remove this section from the migration guide.
|
||||
|
||||
## React Refresh policy for base UI primitives
|
||||
## React Refresh policy for dify-ui primitives
|
||||
|
||||
- We keep primitive aliases (for example `DropdownMenu = Menu.Root`) in the same module.
|
||||
- For `app/components/base/ui/**/*.tsx`, `react-refresh/only-export-components` is currently set to `off` in ESLint to avoid false positives and IDE noise during migration.
|
||||
- For `../packages/dify-ui/src/**/*.tsx`, `react-refresh/only-export-components` is currently set to `off` in ESLint to avoid false positives and IDE noise during migration.
|
||||
- Do not use file-level `eslint-disable` comments for this policy; keep control in the scoped ESLint override.
|
||||
|
||||
@@ -161,8 +161,8 @@ export default antfu(
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dify/base-ui-primitives',
|
||||
files: ['app/components/base/ui/**/*.tsx'],
|
||||
name: 'dify/dify-ui-primitives',
|
||||
files: ['../packages/dify-ui/src/**/*.tsx'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
|
||||
'**/portal-to-follow-elem',
|
||||
'**/portal-to-follow-elem/index',
|
||||
],
|
||||
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
|
||||
message: 'Deprecated: use semantic overlay primitives from @langgenius/dify-ui instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
@@ -64,20 +64,13 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
|
||||
export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
|
||||
'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx',
|
||||
'app/components/base/chat/chat-with-history/header/operation.tsx',
|
||||
'app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx',
|
||||
'app/components/base/chat/chat-with-history/sidebar/operation.tsx',
|
||||
'app/components/base/chat/chat/citation/popup.tsx',
|
||||
'app/components/base/chat/chat/citation/progress-tooltip.tsx',
|
||||
'app/components/base/chat/chat/citation/tooltip.tsx',
|
||||
'app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx',
|
||||
'app/components/base/chip/index.tsx',
|
||||
'app/components/base/date-and-time-picker/date-picker/index.tsx',
|
||||
'app/components/base/date-and-time-picker/time-picker/index.tsx',
|
||||
'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx',
|
||||
'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx',
|
||||
'app/components/base/file-uploader/file-from-link-or-local/index.tsx',
|
||||
'app/components/base/image-uploader/chat-image-uploader.tsx',
|
||||
'app/components/base/image-uploader/text-generation-image-uploader.tsx',
|
||||
'app/components/base/modal/modal.tsx',
|
||||
'app/components/base/prompt-editor/plugins/context-block/component.tsx',
|
||||
'app/components/base/prompt-editor/plugins/history-block/component.tsx',
|
||||
|
||||
Reference in New Issue
Block a user