refactor(web): update type definitions and improve test mocks for Popover components

This commit is contained in:
CodingOnStar
2026-04-20 18:58:54 +08:00
parent ce0a51ce86
commit 5de4868f4f
15 changed files with 131 additions and 105 deletions

View File

@@ -5,7 +5,12 @@ import DatePicker from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, disabled, className }: Record<string, unknown>) => (
Button: ({ children, onClick, disabled, className }: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
{children}
</button>

View File

@@ -5,7 +5,12 @@ import TimePicker from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, disabled, className }: Record<string, unknown>) => (
Button: ({ children, onClick, disabled, className }: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
{children}
</button>

View File

@@ -28,7 +28,7 @@ export type DatePickerProps = {
onChange: (date: Dayjs | undefined) => void
onClear: () => void
triggerWrapClassName?: string
renderTrigger?: (props: TriggerProps) => React.ReactNode
renderTrigger?: (props: TriggerProps) => React.ReactElement
minuteFilter?: (minutes: string[]) => string[]
popupZIndexClassname?: string
noConfirm?: boolean
@@ -62,7 +62,7 @@ export type TimePickerProps = {
placeholder?: string
onChange: (date: Dayjs | undefined) => void
onClear: () => void
renderTrigger?: (props: TriggerParams) => React.ReactNode
renderTrigger?: (props: TriggerParams) => React.ReactElement
title?: string
minuteFilter?: (minutes: string[]) => string[]
popupClassName?: string

View File

@@ -61,7 +61,7 @@ describe('FileUploadSettings (setting-modal)', () => {
})
})
it('should call onOpen with toggle function when trigger is clicked', () => {
it('should call onOpen with true when trigger is clicked', () => {
const onOpen = vi.fn()
renderWithProvider(
<FileUploadSettings open={false} onOpen={onOpen}>
@@ -71,12 +71,7 @@ describe('FileUploadSettings (setting-modal)', () => {
fireEvent.click(screen.getByText('Upload 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', () => {

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react'
import type { Dataset } from './index'
import type { EventEmitterValue } from '@/context/event-emitter'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
@@ -16,11 +17,6 @@ type ContextBlockComponentProps = {
canNotAddContext?: boolean
}
type DatasetsEventPayload = {
type?: string
payload?: Dataset[]
}
const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
nodeKey,
datasets = [],
@@ -33,9 +29,12 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
const { eventEmitter } = useEventEmitterContextContext()
const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets)
eventEmitter?.useSubscription((event?: DatasetsEventPayload) => {
if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && event.payload)
setLocalDatasets(event.payload)
eventEmitter?.useSubscription((event?: EventEmitterValue) => {
if (typeof event === 'string')
return
if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && Array.isArray(event.payload))
setLocalDatasets(event.payload as Dataset[])
})
return (
@@ -56,12 +55,14 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
>
<PopoverTrigger
nativeButton={false}
ref={triggerRef}
render={(
<div className={`
<div
className={`
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded text-[11px] font-semibold
${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
`}
ref={triggerRef}
onClick={e => e.preventDefault()}
>
{localDatasets.length}
</div>

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react'
import type { RoleName } from './index'
import type { EventEmitterValue } from '@/context/event-emitter'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiMoreFill,
@@ -18,11 +19,6 @@ type HistoryBlockComponentProps = {
onEditRole: () => void
}
type HistoryEventPayload = {
type?: string
payload?: RoleName
}
const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
nodeKey,
roleName = { user: '', assistant: '' },
@@ -34,9 +30,12 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
const { eventEmitter } = useEventEmitterContextContext()
const [localRoleName, setLocalRoleName] = useState<RoleName>(roleName)
eventEmitter?.useSubscription((event?: HistoryEventPayload) => {
if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload)
setLocalRoleName(event.payload)
eventEmitter?.useSubscription((event?: EventEmitterValue) => {
if (typeof event === 'string')
return
if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload && typeof event.payload === 'object')
setLocalRoleName(event.payload as RoleName)
})
return (
@@ -56,12 +55,14 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
>
<PopoverTrigger
nativeButton={false}
ref={triggerRef}
render={(
<div className={`
<div
className={`
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded
${open ? 'bg-[#DD2590] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
`}
ref={triggerRef}
onClick={e => e.preventDefault()}
>
<RiMoreFill className="h-3 w-3" />
</div>

View File

@@ -231,8 +231,9 @@ describe('StepTwoPreview', () => {
describe('Props Passing', () => {
it('should render preview button when isIdle is true', () => {
render(<StepTwoPreview {...defaultProps} isIdle={true} />)
// ChunkPreview shows a preview button when idle
const previewButton = screen.queryByRole('button')
const previewButton = screen.getByRole('button', {
name: 'datasetPipeline.addDocuments.stepTwo.previewChunks',
})
expect(previewButton).toBeInTheDocument()
})
@@ -240,13 +241,13 @@ describe('StepTwoPreview', () => {
const onPreview = vi.fn()
render(<StepTwoPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
// Find and click the preview button
const buttons = screen.getAllByRole('button')
const previewButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('preview'))
if (previewButton) {
previewButton.click()
expect(onPreview).toHaveBeenCalled()
}
const previewButton = screen.getByRole('button', {
name: 'datasetPipeline.addDocuments.stepTwo.previewChunks',
})
previewButton.click()
expect(onPreview).toHaveBeenCalled()
})
})

View File

@@ -62,31 +62,38 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
// Mock portal-to-follow-elem with shared open state
// Mock popover with shared open state
let mockPortalOpenState = false
let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: {
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({ children, open, onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpenState = open
mockPopoverOnOpenChange = onOpenChange
return (
<div data-testid="portal-elem" data-open={open}>
{children}
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick, className }: {
children: React.ReactNode
onClick: () => void
PopoverTrigger: ({ children, render, className }: {
children?: React.ReactNode
render?: React.ReactNode
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
<div
data-testid="portal-trigger"
onClick={() => mockPopoverOnOpenChange?.(!mockPortalOpenState)}
className={className}
>
{render ?? children}
</div>
),
PortalToFollowElemContent: ({ children, className }: {
PopoverContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => {

View File

@@ -1,6 +1,7 @@
import type { Category, Tag } from '../constant'
import type { FilterState } from '../index'
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import { createContext, useContext } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ==================== Imports (after mocks) ====================
@@ -68,19 +69,47 @@ vi.mock('../../../hooks', () => ({
}),
}))
// Track portal open state for testing
let mockPortalOpenState = false
type MockPopoverContextValue = {
open: boolean
onOpenChange?: (open: boolean) => void
}
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
mockPortalOpenState = open
return <div data-testid="portal-container" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
const MockPopoverContext = createContext<MockPopoverContextValue>({
open: false,
})
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({ children, open, onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => (
<MockPopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="portal-container" data-open={open}>{children}</div>
</MockPopoverContext.Provider>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
if (!mockPortalOpenState)
PopoverTrigger: ({ children, render, className }: {
children?: React.ReactNode
render?: React.ReactNode
className?: string
}) => {
const { open, onOpenChange } = useContext(MockPopoverContext)
return (
<div
data-testid="portal-trigger"
onClick={() => onOpenChange?.(!open)}
className={className}
>
{render ?? children}
</div>
)
},
PopoverContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => {
const { open } = useContext(MockPopoverContext)
if (!open)
return null
return <div data-testid="portal-content" className={className}>{children}</div>
},
@@ -457,7 +486,6 @@ describe('SearchBox Component', () => {
describe('CategoriesFilter Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
})
describe('Rendering', () => {
@@ -694,7 +722,6 @@ describe('CategoriesFilter Component', () => {
describe('TagFilter Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
})
describe('Rendering', () => {
@@ -857,7 +884,6 @@ describe('FilterManagement Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInitFilters = createFilterState()
mockPortalOpenState = false
})
describe('Rendering', () => {

View File

@@ -192,27 +192,6 @@ vi.mock('ahooks', () => ({
useKeyPress: vi.fn(),
}))
let portalOpenState = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: PropsWithChildren<{
open: boolean
onOpenChange: (open: boolean) => void
placement?: string
offset?: unknown
}>) => {
portalOpenState = open
return <div data-testid="portal-elem" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: PropsWithChildren<{ onClick?: () => void }>) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: PropsWithChildren) => {
if (!portalOpenState)
return null
return <div data-testid="portal-content">{children}</div>
},
}))
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
default: ({ onConfirm, onCancel }: {
onConfirm: (name: string, icon: unknown, description?: string) => void
@@ -229,7 +208,6 @@ vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
describe('RagPipelineHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpenState = false
mockStoreState = {
pipelineId: 'test-pipeline-id',
showDebugAndPreviewPanel: false,
@@ -351,7 +329,6 @@ describe('InputFieldButton', () => {
describe('Publisher', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpenState = false
})
describe('Rendering', () => {
@@ -367,9 +344,9 @@ describe('Publisher', () => {
expect(button)!.toHaveClass('px-2')
})
it('should render portal trigger element', () => {
it('should render publish trigger button', () => {
render(<Publisher />)
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.common\.publish/i }))!.toBeInTheDocument()
})
})
@@ -377,7 +354,7 @@ describe('Publisher', () => {
it('should call handleSyncWorkflowDraft when opening', () => {
render(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
@@ -385,12 +362,14 @@ describe('Publisher', () => {
it('should toggle open state when trigger clicked', () => {
render(<Publisher />)
const portal = screen.getByTestId('portal-elem')
expect(portal)!.toHaveAttribute('data-open', 'false')
const trigger = screen.getByRole('button', { name: /workflow\.common\.publish/i })
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(trigger)
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText(/workflow\.common\.publishUpdate/i))!.toBeInTheDocument()
})
})
})
@@ -978,7 +957,6 @@ describe('RunMode', () => {
describe('Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpenState = false
mockStoreState = {
pipelineId: 'test-pipeline-id',
showDebugAndPreviewPanel: false,

View File

@@ -16,12 +16,16 @@ vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
)
: null
),
AlertDialogActions: ({ children }: { children: unknown }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: unknown }) => <button>{children}</button>,
AlertDialogConfirmButton: ({ children, onClick, disabled }: Record<string, unknown>) => <button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined}>{children}</button>,
AlertDialogContent: ({ children }: { children: unknown }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: unknown }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: unknown }) => <div>{children}</div>,
AlertDialogActions: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children?: React.ReactNode }) => <button>{children}</button>,
AlertDialogConfirmButton: ({ children, onClick, disabled }: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
}) => <button onClick={onClick} disabled={disabled}>{children}</button>,
AlertDialogContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}))
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })

View File

@@ -27,7 +27,7 @@ const Publisher = () => {
onOpenChange={handleOpenChange}
>
<PopoverTrigger
nativeButton={false}
nativeButton
render={(
<Button
className="px-2"

View File

@@ -76,11 +76,14 @@ describe('NoteEditor Toolbar', () => {
expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument()
const triggers = container.querySelectorAll('[data-state="closed"]')
const buttons = container.querySelectorAll('button[type="button"]')
fireEvent.click(buttons[0] as HTMLElement)
fireEvent.click(triggers[0] as HTMLElement)
await waitFor(() => {
expect(document.body.querySelectorAll('.group.relative').length).toBeGreaterThan(0)
})
const colorOptions = document.body.querySelectorAll('[role="tooltip"] .group.relative')
const colorOptions = document.body.querySelectorAll('.group.relative')
fireEvent.click(colorOptions[colorOptions.length - 1] as Element)

View File

@@ -60,7 +60,7 @@ const ColorPicker = ({
onOpenChange={setOpen}
>
<PopoverTrigger
nativeButton={false}
nativeButton
render={(
<button
type="button"

View File

@@ -39,7 +39,7 @@ const FontSizeSelector = () => {
onOpenChange={handleOpenFontSizeSelector}
>
<PopoverTrigger
nativeButton={false}
nativeButton
render={(
<button
type="button"