mirror of https://github.com/langgenius/dify.git
refactor(web): useClipboard hook to reduce duplication (#31308)
Signed-off-by: SherlockShemol <shemol@163.com> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
This commit is contained in:
parent
aa68966b55
commit
1d778d532a
|
|
@ -3,10 +3,8 @@ import {
|
|||
RiClipboardFill,
|
||||
RiClipboardLine,
|
||||
} from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useClipboard } from 'foxact/use-clipboard'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
|
@ -21,32 +19,27 @@ const prefixEmbedded = 'overview.appInfo.embedded'
|
|||
|
||||
const CopyFeedback = ({ content }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false)
|
||||
const { copied, copy, reset } = useClipboard()
|
||||
|
||||
const onClickCopy = debounce(() => {
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(content)
|
||||
setIsCopied(true)
|
||||
}, 100)
|
||||
|
||||
const onMouseLeave = debounce(() => {
|
||||
setIsCopied(false)
|
||||
}, 100)
|
||||
}, [copy, content])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied
|
||||
(copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
>
|
||||
<ActionButton>
|
||||
<div
|
||||
onClick={onClickCopy}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={handleCopy}
|
||||
onMouseLeave={reset}
|
||||
>
|
||||
{isCopied && <RiClipboardFill className="h-4 w-4" />}
|
||||
{!isCopied && <RiClipboardLine className="h-4 w-4" />}
|
||||
{copied && <RiClipboardFill className="h-4 w-4" />}
|
||||
{!copied && <RiClipboardLine className="h-4 w-4" />}
|
||||
</div>
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
|
|
@ -57,21 +50,16 @@ export default CopyFeedback
|
|||
|
||||
export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' | 'content'>) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false)
|
||||
const { copied, copy, reset } = useClipboard()
|
||||
|
||||
const onClickCopy = debounce(() => {
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(content)
|
||||
setIsCopied(true)
|
||||
}, 100)
|
||||
|
||||
const onMouseLeave = debounce(() => {
|
||||
setIsCopied(false)
|
||||
}, 100)
|
||||
}, [copy, content])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied
|
||||
(copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
|
|
@ -81,9 +69,9 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className'
|
|||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={onClickCopy}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`h-full w-full ${copyStyle.copyIcon} ${isCopied ? copyStyle.copied : ''
|
||||
onClick={handleCopy}
|
||||
onMouseLeave={reset}
|
||||
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''
|
||||
}`}
|
||||
>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
'use client'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useClipboard } from 'foxact/use-clipboard'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Copy,
|
||||
|
|
@ -18,29 +16,24 @@ const prefixEmbedded = 'overview.appInfo.embedded'
|
|||
|
||||
const CopyIcon = ({ content }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false)
|
||||
const { copied, copy, reset } = useClipboard()
|
||||
|
||||
const onClickCopy = debounce(() => {
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(content)
|
||||
setIsCopied(true)
|
||||
}, 100)
|
||||
|
||||
const onMouseLeave = debounce(() => {
|
||||
setIsCopied(false)
|
||||
}, 100)
|
||||
}, [copy, content])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied
|
||||
(copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
>
|
||||
<div onMouseLeave={onMouseLeave}>
|
||||
{!isCopied
|
||||
<div onMouseLeave={reset}>
|
||||
{!copied
|
||||
? (
|
||||
<Copy className="mx-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={onClickCopy} />
|
||||
<Copy className="mx-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={handleCopy} />
|
||||
)
|
||||
: (
|
||||
<CopyCheck className="mx-1 h-3.5 w-3.5 text-text-tertiary" />
|
||||
|
|
|
|||
|
|
@ -3,13 +3,8 @@ import * as React from 'react'
|
|||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import InputWithCopy from './index'
|
||||
|
||||
// Create a mock function that we can track using vi.hoisted
|
||||
const mockCopyToClipboard = vi.hoisted(() => vi.fn(() => true))
|
||||
|
||||
// Mock the copy-to-clipboard library
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: mockCopyToClipboard,
|
||||
}))
|
||||
// Mock navigator.clipboard for foxact/use-clipboard
|
||||
const mockWriteText = vi.fn(() => Promise.resolve())
|
||||
|
||||
// Mock the i18n hook with custom translations for test assertions
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
|
|
@ -19,15 +14,16 @@ vi.mock('react-i18next', () => createReactI18nextMock({
|
|||
'overview.appInfo.embedded.copied': 'Copied',
|
||||
}))
|
||||
|
||||
// Mock es-toolkit/compat debounce
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: (fn: any) => fn,
|
||||
}))
|
||||
|
||||
describe('InputWithCopy component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCopyToClipboard.mockClear()
|
||||
mockWriteText.mockClear()
|
||||
// Setup navigator.clipboard mock
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders correctly with default props', () => {
|
||||
|
|
@ -55,7 +51,9 @@ describe('InputWithCopy component', () => {
|
|||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('test value')
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
})
|
||||
|
||||
it('copies custom value when copyValue prop is provided', async () => {
|
||||
|
|
@ -65,7 +63,9 @@ describe('InputWithCopy component', () => {
|
|||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('custom copy value')
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('custom copy value')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCopy callback when copy button is clicked', async () => {
|
||||
|
|
@ -76,7 +76,9 @@ describe('InputWithCopy component', () => {
|
|||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(onCopyMock).toHaveBeenCalledWith('test value')
|
||||
await waitFor(() => {
|
||||
expect(onCopyMock).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows copied state after successful copy', async () => {
|
||||
|
|
@ -115,17 +117,19 @@ describe('InputWithCopy component', () => {
|
|||
expect(input).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('handles empty value correctly', () => {
|
||||
it('handles empty value correctly', async () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="" onChange={mockOnChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByDisplayValue('')
|
||||
const copyButton = screen.getByRole('button')
|
||||
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(copyButton)
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('')
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
it('maintains focus on input after copy', async () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
'use client'
|
||||
import type { InputProps } from '../input'
|
||||
import { RiClipboardFill, RiClipboardLine } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { useClipboard } from 'foxact/use-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ActionButton from '../action-button'
|
||||
|
|
@ -30,31 +28,16 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
|
|||
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(() => {
|
||||
const { copied, copy, reset } = useClipboard()
|
||||
|
||||
const handleCopy = () => {
|
||||
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)}>
|
||||
|
|
@ -73,21 +56,21 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
|
|||
{showCopyButton && (
|
||||
<div
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseLeave={reset}
|
||||
>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied
|
||||
(copied
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
>
|
||||
<ActionButton
|
||||
size="xs"
|
||||
onClick={onClickCopy}
|
||||
onClick={handleCopy}
|
||||
className="hover:bg-components-button-ghost-bg-hover"
|
||||
>
|
||||
{isCopied
|
||||
{copied
|
||||
? (
|
||||
<RiClipboardFill className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1131,11 +1131,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/input-with-copy/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/input/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"emoji-mart": "5.6.0",
|
||||
"es-toolkit": "1.43.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"foxact": "0.2.52",
|
||||
"html-entities": "2.6.0",
|
||||
"html-to-image": "1.11.13",
|
||||
"i18next": "25.7.3",
|
||||
|
|
|
|||
|
|
@ -183,6 +183,9 @@ importers:
|
|||
fast-deep-equal:
|
||||
specifier: 3.1.3
|
||||
version: 3.1.3
|
||||
foxact:
|
||||
specifier: 0.2.52
|
||||
version: 0.2.52(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
html-entities:
|
||||
specifier: 2.6.0
|
||||
version: 2.6.0
|
||||
|
|
@ -5560,6 +5563,17 @@ packages:
|
|||
engines: {node: '>=18.3.0'}
|
||||
hasBin: true
|
||||
|
||||
foxact@0.2.52:
|
||||
resolution: {integrity: sha512-cc3ydJkM/mYkof1/ofI4VlVAiRyfsSDsHRC4UIAXQcnUXCuo0rXM66Zy1ggdxAXL03ikHnh3bPnQ7AYuI/Yzow==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-dom: '*'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fraction.js@4.3.7:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
|
||||
|
|
@ -7637,6 +7651,9 @@ packages:
|
|||
resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
serwist@9.5.0:
|
||||
resolution: {integrity: sha512-wjrsPWHI5ZM20jIsVKZGN/uAdS2aKOgmIOE4dqUaFhK6SVIzgoJZjTnZ3v29T+NmneuD753jlhGui9eYypsj0A==}
|
||||
peerDependencies:
|
||||
|
|
@ -14358,6 +14375,14 @@ snapshots:
|
|||
dependencies:
|
||||
fd-package-json: 2.0.0
|
||||
|
||||
foxact@0.2.52(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
server-only: 0.0.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
|
||||
fs-constants@1.0.0:
|
||||
|
|
@ -16936,6 +16961,8 @@ snapshots:
|
|||
|
||||
seroval@1.3.2: {}
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
serwist@9.5.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@serwist/utils': 9.5.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue