dify
This commit is contained in:
150
dify/web/app/components/base/input-with-copy/index.spec.tsx
Normal file
150
dify/web/app/components/base/input-with-copy/index.spec.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import InputWithCopy from './index'
|
||||
|
||||
// Mock the copy-to-clipboard library
|
||||
jest.mock('copy-to-clipboard', () => jest.fn(() => true))
|
||||
|
||||
// Mock the i18n hook
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'common.operation.copy': 'Copy',
|
||||
'common.operation.copied': 'Copied',
|
||||
'appOverview.overview.appInfo.embedded.copy': 'Copy',
|
||||
'appOverview.overview.appInfo.embedded.copied': 'Copied',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock lodash-es debounce
|
||||
jest.mock('lodash-es', () => ({
|
||||
debounce: (fn: any) => fn,
|
||||
}))
|
||||
|
||||
describe('InputWithCopy component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders correctly with default props', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
const input = screen.getByDisplayValue('test value')
|
||||
const copyButton = screen.getByRole('button')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides copy button when showCopyButton is false', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} showCopyButton={false} />)
|
||||
const input = screen.getByDisplayValue('test value')
|
||||
const copyButton = screen.queryByRole('button')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(copyButton).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies input value when copy button is clicked', async () => {
|
||||
const copyToClipboard = require('copy-to-clipboard')
|
||||
const mockOnChange = jest.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
|
||||
it('copies custom value when copyValue prop is provided', async () => {
|
||||
const copyToClipboard = require('copy-to-clipboard')
|
||||
const mockOnChange = jest.fn()
|
||||
render(<InputWithCopy value="display value" onChange={mockOnChange} copyValue="custom copy value" />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('custom copy value')
|
||||
})
|
||||
|
||||
it('calls onCopy callback when copy button is clicked', async () => {
|
||||
const onCopyMock = jest.fn()
|
||||
const mockOnChange = jest.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} onCopy={onCopyMock} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(onCopyMock).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
|
||||
it('shows copied state after successful copy', async () => {
|
||||
const mockOnChange = jest.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
// Hover over the button to trigger tooltip
|
||||
fireEvent.mouseEnter(copyButton)
|
||||
|
||||
// Check if the tooltip shows "Copied" state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied')).toBeInTheDocument()
|
||||
}, { timeout: 2000 })
|
||||
})
|
||||
|
||||
it('passes through all input props correctly', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
render(
|
||||
<InputWithCopy
|
||||
value="test value"
|
||||
onChange={mockOnChange}
|
||||
placeholder="Custom placeholder"
|
||||
disabled
|
||||
readOnly
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('test value')
|
||||
expect(input).toHaveAttribute('placeholder', 'Custom placeholder')
|
||||
expect(input).toBeDisabled()
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
expect(input).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('handles empty value correctly', () => {
|
||||
const copyToClipboard = require('copy-to-clipboard')
|
||||
const mockOnChange = jest.fn()
|
||||
render(<InputWithCopy value="" onChange={mockOnChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const copyButton = screen.getByRole('button')
|
||||
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(copyButton)
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('maintains focus on input after copy', async () => {
|
||||
const mockOnChange = jest.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
const input = screen.getByDisplayValue('test value')
|
||||
const copyButton = screen.getByRole('button')
|
||||
|
||||
input.focus()
|
||||
expect(input).toHaveFocus()
|
||||
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
// Input should maintain focus after copy
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
})
|
||||
104
dify/web/app/components/base/input-with-copy/index.tsx
Normal file
104
dify/web/app/components/base/input-with-copy/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiClipboardFill, RiClipboardLine } from '@remixicon/react'
|
||||
import { debounce } from 'lodash-es'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import type { InputProps } from '../input'
|
||||
import Tooltip from '../tooltip'
|
||||
import ActionButton from '../action-button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type InputWithCopyProps = {
|
||||
showCopyButton?: boolean
|
||||
copyValue?: string // Value to copy, defaults to input value
|
||||
onCopy?: (value: string) => void // Callback when copy is triggered
|
||||
} & Omit<InputProps, 'showClearIcon' | 'onCopy'> // Remove conflicting props
|
||||
|
||||
const prefixEmbedded = 'appOverview.overview.appInfo.embedded'
|
||||
|
||||
const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
|
||||
{
|
||||
showCopyButton = true,
|
||||
copyValue,
|
||||
onCopy,
|
||||
value,
|
||||
wrapperClassName,
|
||||
...inputProps
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false)
|
||||
// Determine what value to copy
|
||||
const valueToString = typeof value === 'string' ? value : String(value || '')
|
||||
const finalCopyValue = copyValue || valueToString
|
||||
|
||||
const onClickCopy = debounce(() => {
|
||||
copy(finalCopyValue)
|
||||
setIsCopied(true)
|
||||
onCopy?.(finalCopyValue)
|
||||
}, 100)
|
||||
|
||||
const onMouseLeave = debounce(() => {
|
||||
setIsCopied(false)
|
||||
}, 100)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 2000)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-full', wrapperClassName)}>
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
'radius-md system-sm-regular px-3',
|
||||
showCopyButton && 'pr-8',
|
||||
inputProps.disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
|
||||
inputProps.className,
|
||||
)}
|
||||
value={value}
|
||||
{...(({ size: _size, ...rest }) => rest)(inputProps)}
|
||||
/>
|
||||
{showCopyButton && (
|
||||
<div
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied
|
||||
? t(`${prefixEmbedded}.copied`)
|
||||
: t(`${prefixEmbedded}.copy`)) || ''
|
||||
}
|
||||
>
|
||||
<ActionButton
|
||||
size="xs"
|
||||
onClick={onClickCopy}
|
||||
className="hover:bg-components-button-ghost-bg-hover"
|
||||
>
|
||||
{isCopied ? (
|
||||
<RiClipboardFill className='h-3.5 w-3.5 text-text-tertiary' />
|
||||
) : (
|
||||
<RiClipboardLine className='h-3.5 w-3.5 text-text-tertiary' />
|
||||
)}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
InputWithCopy.displayName = 'InputWithCopy'
|
||||
|
||||
export default InputWithCopy
|
||||
Reference in New Issue
Block a user