refactor(web): replace PortalToFollowElem with Popover components across various modules

This commit is contained in:
CodingOnStar
2026-04-17 20:05:16 +08:00
parent c7749e322c
commit 5373498dc6
15 changed files with 403 additions and 210 deletions

View File

@@ -14,6 +14,7 @@ type PopoverProps = {
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & {
children?: ReactNode
nativeButton?: boolean
render?: React.ReactElement
}
@@ -31,6 +32,32 @@ export const Popover = ({
open = false,
onOpenChange,
}: PopoverProps) => {
React.useEffect(() => {
if (!open)
return
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as Element | null
if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]'))
return
onOpenChange?.(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape')
onOpenChange?.(false)
}
document.addEventListener('mousedown', handleMouseDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleMouseDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open, onOpenChange])
return (
<PopoverContext.Provider value={{
open,
@@ -47,6 +74,7 @@ export const Popover = ({
export const PopoverTrigger = ({
children,
render,
nativeButton: _nativeButton,
onClick,
...props
}: PopoverTriggerProps) => {
@@ -61,9 +89,12 @@ export const PopoverTrigger = ({
...props,
...childProps,
'data-testid': childProps['data-testid'] ?? 'popover-trigger',
'data-popover-trigger': 'true',
'onClick': (event: React.MouseEvent<HTMLElement>) => {
childProps.onClick?.(event)
onClick?.(event)
if (event.defaultPrevented)
return
onOpenChange(!open)
},
})
@@ -72,8 +103,11 @@ export const PopoverTrigger = ({
return (
<div
data-testid="popover-trigger"
data-popover-trigger="true"
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented)
return
onOpenChange(!open)
}}
{...props}
@@ -101,6 +135,7 @@ export const PopoverContent = ({
return (
<div
data-testid="popover-content"
data-popover-content="true"
data-placement={placement}
data-side-offset={sideOffset}
data-align-offset={alignOffset}

View File

@@ -6,6 +6,8 @@ import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import Popup from '../popup'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDownload: vi.fn(),
}))

View File

@@ -1,13 +1,9 @@
import type { FC, MouseEvent } from 'react'
import type { Resources } from './index'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileIcon from '@/app/components/base/file-icon'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Link from '@/next/link'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
@@ -47,22 +43,25 @@ const Popup: FC<PopupProps> = ({
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 8,
crossAxis: -2,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<PopoverTrigger
nativeButton={false}
render={(
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
)}
/>
<PopoverContent
placement="top-start"
sideOffset={8}
alignOffset={-2}
popupClassName="border-none bg-transparent shadow-none"
>
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
<div className="px-4 pt-3 pb-2">
<div className="flex h-[18px] items-center">
@@ -156,8 +155,8 @@ const Popup: FC<PopupProps> = ({
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@@ -3,6 +3,15 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'
import dayjs from '../../utils/dayjs'
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 onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
{children}
</button>
),
}))
// Mock scrollIntoView
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@@ -113,14 +122,13 @@ describe('DatePicker', () => {
render(<DatePicker {...props} />)
openPicker()
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
// Simulate a mousedown event outside the container
act(() => {
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
// The picker should now be closed - input shows its value
// The picker should now be closed - input shows its value
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
})

View File

@@ -1,14 +1,10 @@
import type { Dayjs } from 'dayjs'
import type { DatePickerProps, Period } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Calendar from '../calendar'
import TimePickerHeader from '../time-picker/header'
import TimePickerOptions from '../time-picker/options'
@@ -35,15 +31,14 @@ const DatePicker = ({
needTimePicker = true,
renderTrigger,
triggerWrapClassName,
popupZIndexClassname = 'z-11',
popupZIndexClassname,
noConfirm,
getIsDateDisabled,
}: DatePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState(ViewType.date)
const containerRef = useRef<HTMLDivElement>(null)
const isInitial = useRef(true)
const isInitialRef = useRef(true)
// Normalize the value to ensure that all subsequent uses are Day.js objects.
const normalizedValue = useMemo(() => {
@@ -62,46 +57,41 @@ const DatePicker = ({
const [selectedYear, setSelectedYear] = useState(() => (inputValue || defaultValue).year())
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
setView(ViewType.date)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
if (isInitial.current) {
isInitial.current = false
if (isInitialRef.current) {
isInitialRef.current = false
return
}
clearMonthMapCache()
if (normalizedValue) {
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
setCurrentDate(newValue)
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
setSelectedDate(newValue)
onChange(newValue)
}
else {
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
}
// eslint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes.
}, [timezone])
const handleClickTrigger = (e: React.MouseEvent) => {
e.stopPropagation()
if (isOpen) {
setIsOpen(false)
return
}
const handleOpenChange = useCallback((nextOpen: boolean) => {
setIsOpen(nextOpen)
setView(ViewType.date)
setIsOpen(true)
if (normalizedValue) {
if (nextOpen && normalizedValue) {
setCurrentDate(normalizedValue)
setSelectedDate(normalizedValue)
}
}, [normalizedValue])
const handleClickTrigger = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
handleOpenChange(!isOpen)
}
const handleClear = (e: React.MouseEvent) => {
@@ -210,21 +200,21 @@ const DatePicker = ({
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('defaultPlaceholder', { ns: 'time' }))
return (
<PortalToFollowElem
<Popover
open={isOpen}
onOpenChange={setIsOpen}
placement="bottom-end"
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger className={triggerWrapClassName}>
{renderTrigger
? (
renderTrigger({
value: normalizedValue,
selectedDate,
isOpen,
handleClear,
handleClickTrigger,
}))
<PopoverTrigger
nativeButton={false}
className={triggerWrapClassName}
render={renderTrigger
? renderTrigger({
value: normalizedValue,
selectedDate,
isOpen,
handleClear,
handleClickTrigger,
})
: (
<div
className="group flex w-[252px] 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"
@@ -242,8 +232,13 @@ const DatePicker = ({
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary')} onClick={handleClear} data-testid="date-picker-clear-button" />
</div>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={popupZIndexClassname}>
/>
<PopoverContent
placement="bottom-end"
sideOffset={0}
className={popupZIndexClassname}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">
{/* Header */}
{view === ViewType.date
@@ -319,8 +314,8 @@ const DatePicker = ({
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@@ -3,6 +3,15 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
import dayjs, { isDayjsObject } from '../../utils/dayjs'
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 onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
{children}
</button>
),
}))
// Mock scrollIntoView since the test DOM runtime doesn't implement it
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@@ -106,7 +115,7 @@ describe('TimePicker', () => {
expect(input)!.toHaveValue('')
fireEvent.mouseDown(document.body)
expect(input)!.toHaveValue('')
expect(input)!.toHaveValue('10:00 AM')
})
it('should call onClear when clear is clicked while picker is closed', () => {

View File

@@ -1,14 +1,10 @@
import type { Dayjs } from 'dayjs'
import type { TimePickerProps } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import TimezoneLabel from '@/app/components/base/timezone-label'
import { Period } from '../types'
import dayjs, {
@@ -43,31 +39,20 @@ const TimePicker = ({
}: TimePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const isInitial = useRef(true)
const isInitialRef = useRef(true)
// Initialize selectedTime
const [selectedTime, setSelectedTime] = useState(() => {
return toDayjs(value, { timezone })
})
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
/* v8 ignore next 2 -- outside-click closing is handled by PortalToFollowElem; this local ref guard is a defensive fallback. */
if (containerRef.current && !containerRef.current.contains(event.target as Node))
setIsOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Track previous values to avoid unnecessary updates
const prevValueRef = useRef(value)
const prevTimezoneRef = useRef(timezone)
useEffect(() => {
if (isInitial.current) {
isInitial.current = false
if (isInitialRef.current) {
isInitialRef.current = false
// Save initial values on first render
prevValueRef.current = value
prevTimezoneRef.current = timezone
@@ -91,6 +76,7 @@ const TimePicker = ({
if (!dayjsValue)
return
// eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
setSelectedTime(dayjsValue)
if (timezoneChanged && !valueChanged)
@@ -98,6 +84,7 @@ const TimePicker = ({
return
}
// eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
setSelectedTime((prev) => {
if (!isDayjsObject(prev))
return undefined
@@ -105,24 +92,30 @@ const TimePicker = ({
})
}, [timezone, value, onChange])
const handleClickTrigger = (e: React.MouseEvent) => {
e.stopPropagation()
if (isOpen) {
setIsOpen(false)
const syncSelectedTimeFromValue = useCallback(() => {
if (!value)
return
}
setIsOpen(true)
if (value) {
const dayjsValue = toDayjs(value, { timezone })
const needsUpdate = dayjsValue && (
!selectedTime
|| !isDayjsObject(selectedTime)
|| !dayjsValue.isSame(selectedTime, 'minute')
)
if (needsUpdate)
setSelectedTime(dayjsValue)
}
const dayjsValue = toDayjs(value, { timezone })
const needsUpdate = dayjsValue && (
!selectedTime
|| !isDayjsObject(selectedTime)
|| !dayjsValue.isSame(selectedTime, 'minute')
)
if (needsUpdate)
setSelectedTime(dayjsValue)
}, [selectedTime, timezone, value])
const handleOpenChange = useCallback((nextOpen: boolean) => {
setIsOpen(nextOpen)
if (nextOpen)
syncSelectedTimeFromValue()
}, [syncSelectedTimeFromValue])
const handleClickTrigger = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
handleOpenChange(!isOpen)
}
const handleClear = (e: React.MouseEvent) => {
@@ -132,7 +125,7 @@ const TimePicker = ({
onClear()
}
const handleTimeSelect = (hour: string, minute: string, period: Period) => {
const handleTimeSelect = useCallback((hour: string, minute: string, period: Period) => {
const periodAdjustedHour = to24Hour(hour, period)
const nextMinute = Number.parseInt(minute, 10)
setSelectedTime((prev) => {
@@ -145,7 +138,7 @@ const TimePicker = ({
.set('second', 0)
.set('millisecond', 0)
})
}
}, [timezone])
const getSafeTimeObject = useCallback(() => {
if (isDayjsObject(selectedTime))
@@ -156,17 +149,17 @@ const TimePicker = ({
const handleSelectHour = useCallback((hour: string) => {
const time = getSafeTimeObject()
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
}, [getSafeTimeObject])
}, [getSafeTimeObject, handleTimeSelect])
const handleSelectMinute = useCallback((minute: string) => {
const time = getSafeTimeObject()
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
}, [getSafeTimeObject])
}, [getSafeTimeObject, handleTimeSelect])
const handleSelectPeriod = useCallback((period: Period) => {
const time = getSafeTimeObject()
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
}, [getSafeTimeObject])
}, [getSafeTimeObject, handleTimeSelect])
const handleSelectCurrentTime = useCallback(() => {
const newDate = getDateWithTimezone({ timezone })
@@ -207,18 +200,19 @@ const TimePicker = ({
/>
)
return (
<PortalToFollowElem
<Popover
open={isOpen}
onOpenChange={setIsOpen}
placement={placement}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger className={triggerFullWidth ? 'block! w-full' : undefined}>
{renderTrigger
? (renderTrigger({
<PopoverTrigger
nativeButton={false}
className={triggerFullWidth ? 'block! w-full' : undefined}
render={renderTrigger
? renderTrigger({
inputElem,
onClick: handleClickTrigger,
isOpen,
}))
})
: (
<div
className={cn(
@@ -236,8 +230,13 @@ const TimePicker = ({
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block hover:text-text-secondary')} role="button" aria-label={t('operation.clear', { ns: 'common' })} onClick={handleClear} />
</div>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={cn('z-50', popupClassName)}>
/>
<PopoverContent
placement={placement}
sideOffset={0}
className={popupClassName}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">
{/* Header */}
<Header title={title} />
@@ -258,8 +257,8 @@ const TimePicker = ({
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@@ -2,6 +2,9 @@ import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../../constants'
import ContextBlockComponent from '../component'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
// Mock the hooks used by ContextBlockComponent
const mockUseSelectOrDelete = vi.fn()
const mockUseTrigger = vi.fn()

View File

@@ -1,13 +1,9 @@
import type { FC } from 'react'
import type { Dataset } from './index'
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 { useEventEmitterContextContext } from '@/context/event-emitter'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants'
import { useSelectOrDelete, useTrigger } from '../../hooks'
@@ -20,6 +16,11 @@ type ContextBlockComponentProps = {
canNotAddContext?: boolean
}
type DatasetsEventPayload = {
type?: string
payload?: Dataset[]
}
const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
nodeKey,
datasets = [],
@@ -32,9 +33,9 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
const { eventEmitter } = useEventEmitterContextContext()
const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets)
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_DATASETS_EVENT_EMITTER)
setLocalDatasets(v.payload)
eventEmitter?.useSubscription((event?: DatasetsEventPayload) => {
if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && event.payload)
setLocalDatasets(event.payload)
})
return (
@@ -49,24 +50,29 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
<span className="mr-1 i-custom-vender-solid-files-file-05 h-[14px] w-[14px]" data-testid="file-icon" />
<div className="mr-1 text-xs font-medium">{t('promptEditor.context.item.title', { ns: 'common' })}</div>
{!canNotAddContext && (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 3,
alignmentAxis: -147,
}}
>
<PortalToFollowElemTrigger ref={triggerRef}>
<div className={`
<PopoverTrigger
nativeButton={false}
ref={triggerRef}
render={(
<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'}
`}>
{localDatasets.length}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 100 }}>
`}
>
{localDatasets.length}
</div>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={3}
alignOffset={-147}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="w-[360px] rounded-xl bg-white shadow-lg">
<div className="p-4">
<div className="mb-2 text-xs font-medium text-gray-500">
@@ -95,8 +101,8 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
{t('promptEditor.context.modal.footer', { ns: 'common' })}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)}
</div>

View File

@@ -6,6 +6,8 @@ import { UPDATE_HISTORY_EVENT_EMITTER } from '../../../constants'
import HistoryBlockComponent from '../component'
import { DELETE_HISTORY_BLOCK_COMMAND } from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
type HistoryEventPayload = {
type?: string
payload?: RoleName

View File

@@ -1,16 +1,12 @@
import type { FC } from 'react'
import type { RoleName } from './index'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiMoreFill,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
import { useSelectOrDelete, useTrigger } from '../../hooks'
@@ -22,6 +18,11 @@ type HistoryBlockComponentProps = {
onEditRole: () => void
}
type HistoryEventPayload = {
type?: string
payload?: RoleName
}
const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
nodeKey,
roleName = { user: '', assistant: '' },
@@ -33,9 +34,9 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
const { eventEmitter } = useEventEmitterContextContext()
const [localRoleName, setLocalRoleName] = useState<RoleName>(roleName)
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_HISTORY_EVENT_EMITTER)
setLocalRoleName(v.payload)
eventEmitter?.useSubscription((event?: HistoryEventPayload) => {
if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload)
setLocalRoleName(event.payload)
})
return (
@@ -49,25 +50,29 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
>
<MessageClockCircle className="mr-1 h-[14px] w-[14px]" />
<div className="mr-1 text-xs font-medium">{t('promptEditor.history.item.title', { ns: 'common' })}</div>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="top-end"
offset={{
mainAxis: 4,
alignmentAxis: -148,
}}
>
<PortalToFollowElemTrigger ref={triggerRef}>
<div className={`
<PopoverTrigger
nativeButton={false}
ref={triggerRef}
render={(
<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'}
`}
>
<RiMoreFill className="h-3 w-3" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 100 }}>
>
<RiMoreFill className="h-3 w-3" />
</div>
)}
/>
<PopoverContent
placement="top-end"
sideOffset={4}
alignOffset={-148}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="w-[360px] rounded-xl bg-white shadow-lg">
<div className="p-4">
<div className="mb-2 text-xs font-medium text-gray-500">{t('promptEditor.history.modal.title', { ns: 'common' })}</div>
@@ -87,8 +92,8 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
{t('promptEditor.history.modal.edit', { ns: 'common' })}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -6,6 +6,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import Publisher from '../index'
import Popup from '../popup'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, disabled, variant, className }: Record<string, unknown>) => (
<button
onClick={onClick as (() => void) | undefined}
disabled={disabled as boolean | undefined}
data-variant={variant as string | undefined}
className={className as string | undefined}
>
{children as React.ReactNode}
</button>
),
}))
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => (
open
? (
<div role="alertdialog">
{children}
<button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}>
Close
</button>
</div>
)
: null
),
AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
AlertDialogConfirmButton: ({ children, onClick, disabled }: Record<string, unknown>) => <button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined}>{children as React.ReactNode}</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 mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
@@ -60,7 +94,8 @@ vi.mock('@/context/dataset-detail', () => ({
const mockSetShowPricingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: () => mockSetShowPricingModal,
useModalContextSelector: <T,>(selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T): T =>
selector({ setShowPricingModal: mockSetShowPricingModal }),
}))
const mockIsAllowPublishAsCustomKnowledgePipelineTemplate = vi.fn(() => true)
@@ -200,8 +235,7 @@ describe('publisher', () => {
it('should render portal element in closed state by default', () => {
renderWithQueryClient(<Publisher />)
const trigger = screen.getByText('workflow.common.publish').closest('[data-state]')
expect(trigger).toHaveAttribute('data-state', 'closed')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
})
@@ -277,6 +311,25 @@ describe('publisher', () => {
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
})
it('should close the outer popover before opening publish-as follow-up flow', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
await waitFor(() => {
expect(screen.queryByText('pipeline.common.publishAs')).not.toBeInTheDocument()
})
expect(mockSetShowPricingModal).toHaveBeenCalled()
})
})
})
@@ -688,7 +741,7 @@ describe('publisher', () => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
fireEvent.click(screen.getByTestId('alert-dialog-close'))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()

View File

@@ -3,6 +3,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Popup from '../popup'
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => (
open
? (
<div role="alertdialog">
{children}
<button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}>
Close
</button>
</div>
)
: 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>,
}))
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
const toastMocks = vi.hoisted(() => ({
@@ -36,6 +57,8 @@ let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z'
let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z'
let mockPipelineId: string | undefined = 'pipeline-123'
let mockIsAllowPublishAsCustom = true
const mockUseBoolean = vi.hoisted(() => vi.fn())
const mockUseKeyPress = vi.hoisted(() => vi.fn())
vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'ds-123' }),
useRouter: () => ({ push: mockPush }),
@@ -48,14 +71,8 @@ vi.mock('@/next/link', () => ({
}))
vi.mock('ahooks', () => ({
useBoolean: (initial: boolean) => {
const state = { value: initial }
return [state.value, {
setFalse: vi.fn(),
setTrue: vi.fn(),
}]
},
useKeyPress: vi.fn(),
useBoolean: (initial: boolean) => mockUseBoolean(initial),
useKeyPress: (...args: unknown[]) => mockUseKeyPress(...args),
}))
vi.mock('@/app/components/workflow/store', () => ({
@@ -126,7 +143,8 @@ vi.mock('@/context/i18n', () => ({
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: () => mockSetShowPricingModal,
useModalContextSelector: <T,>(selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T) =>
selector({ setShowPricingModal: mockSetShowPricingModal }),
}))
vi.mock('@/context/provider-context', () => ({
@@ -194,6 +212,11 @@ describe('Popup', () => {
mockDraftUpdatedAt = '2024-06-01T00:00:00Z'
mockPipelineId = 'pipeline-123'
mockIsAllowPublishAsCustom = true
mockUseBoolean.mockImplementation((initial: boolean) => [initial, {
setFalse: vi.fn(),
setTrue: vi.fn(),
}])
mockUseKeyPress.mockImplementation(() => {})
})
afterEach(() => {
@@ -289,12 +312,61 @@ describe('Popup', () => {
describe('Publish As Knowledge Pipeline', () => {
it('should show pricing modal when not allowed', () => {
mockIsAllowPublishAsCustom = false
render(<Popup />)
const onRequestClose = vi.fn()
render(<Popup onRequestClose={onRequestClose} />)
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
expect(onRequestClose).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).toHaveBeenCalled()
})
it('should request closing the outer popover before opening publish-as modal', () => {
const onRequestClose = vi.fn()
render(<Popup onRequestClose={onRequestClose} />)
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
expect(onRequestClose).toHaveBeenCalledTimes(1)
})
})
describe('Overlay cleanup', () => {
it('should close confirm dialog when alert dialog requests close', () => {
const hideConfirm = vi.fn()
mockUseBoolean
.mockImplementationOnce(() => [true, { setFalse: hideConfirm, setTrue: vi.fn() }])
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
render(<Popup />)
fireEvent.click(screen.getByTestId('alert-dialog-close'))
expect(hideConfirm).toHaveBeenCalledTimes(1)
})
})
describe('Publish params', () => {
it('should publish as template with empty pipeline id fallback', async () => {
mockPipelineId = undefined
mockUseBoolean
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
.mockImplementationOnce(() => [true, { setFalse: vi.fn(), setTrue: vi.fn() }])
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
render(<Popup />)
fireEvent.click(screen.getByTestId('publish-as-confirm'))
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
pipelineId: '',
name: 'My Pipeline',
icon_info: { icon_type: 'emoji' },
description: 'desc',
})
})
})
describe('Time formatting', () => {

View File

@@ -1,4 +1,5 @@
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import {
memo,
@@ -6,11 +7,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import Popup from './popup'
@@ -26,28 +22,31 @@ const Publisher = () => {
}, [handleSyncWorkflowDraft])
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 40,
}}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger onClick={() => handleOpenChange(!open)}>
<Button
className="px-2"
variant="primary"
>
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
<RiArrowDownSLine className="h-4 w-4" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<Popup />
</PortalToFollowElemContent>
</PortalToFollowElem>
<PopoverTrigger
nativeButton={false}
render={(
<Button
className="px-2"
variant="primary"
>
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
<RiArrowDownSLine className="h-4 w-4" />
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={40}
popupClassName="border-none bg-transparent shadow-none"
>
<Popup onRequestClose={() => handleOpenChange(false)} />
</PopoverContent>
</Popover>
)
}

View File

@@ -39,7 +39,11 @@ import { usePublishWorkflow } from '@/service/use-workflow'
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
const Popup = () => {
type PopupProps = {
onRequestClose?: () => void
}
const Popup = ({ onRequestClose }: PopupProps) => {
const { t } = useTranslation()
const { datasetId } = useParams()
const { push } = useRouter()
@@ -70,6 +74,7 @@ const Popup = () => {
const checked = await handleCheckBeforePublish()
if (checked) {
if (!publishedAt && !confirmVisible) {
onRequestClose?.()
showConfirm()
return
}
@@ -114,7 +119,7 @@ const Popup = () => {
if (confirmVisible)
hideConfirm()
}
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm])
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm, onRequestClose])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
if (published)
@@ -155,13 +160,14 @@ const Popup = () => {
hidePublishingAsCustomizedPipeline()
hidePublishAsKnowledgePipelineModal()
}
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal])
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
onRequestClose?.()
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
setShowPricingModal()
else
setShowPublishAsKnowledgePipelineModal()
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
return (
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
<div className="p-4 pt-3">