diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 0c267a38d0d..96a3d6a15ba 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -70,7 +70,6 @@ "react-awesome-query-builder": "5.1.2", "react-codemirror2": "^7.2.1", "react-context-mutex": "^2.0.0", - "react-copy-to-clipboard": "^5.0.4", "react-dnd": "14.0.2", "react-dnd-html5-backend": "14.0.2", "react-dom": "^16.14.0", @@ -159,7 +158,6 @@ "@types/node": "^15.6.1", "@types/pako": "^2.0.0", "@types/react": "^17.0.8", - "@types/react-copy-to-clipboard": "^5.0.2", "@types/react-dom": "^17.0.11", "@types/react-lazylog": "^4.5.1", "@types/react-router-dom": "^5.1.7", diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/icon-copy.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/icon-copy.svg index b0c0dc9eff9..69ae7ff3acc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/assets/svg/icon-copy.svg +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/icon-copy.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/components/buttons/CopyToClipboardButton/CopyToClipboardButton.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/buttons/CopyToClipboardButton/CopyToClipboardButton.test.tsx index a7ddea958dd..ec3519d59fb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/buttons/CopyToClipboardButton/CopyToClipboardButton.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/buttons/CopyToClipboardButton/CopyToClipboardButton.test.tsx @@ -11,23 +11,83 @@ * limitations under the License. */ -import { getByTestId, render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; import CopyToClipboardButton from './CopyToClipboardButton'; -const mockProps = { - copyText: 'mock-copy', +const clipboardWriteTextMock = jest.fn(); +const clipboardMock = { + writeText: clipboardWriteTextMock, }; +const value = 'Test Value'; +const callBack = jest.fn(); + +Object.defineProperty(window.navigator, 'clipboard', { + value: clipboardMock, + writable: true, +}); + describe('Test CopyToClipboardButton Component', () => { - it('Should render all child elements', () => { - const { container } = render(, { - wrapper: MemoryRouter, + it('Should render all child elements', async () => { + render(); + + expect(screen.getByTestId('copy-icon')).toBeInTheDocument(); + expect(screen.getByTestId('copy-secret')).toBeInTheDocument(); + }); + + it('Should calls onCopy callback when clicked', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-secret')); }); - const copyIcon = getByTestId(container, 'copy-icon'); + expect(callBack).toHaveBeenCalled(); + }); - expect(copyIcon).toBeInTheDocument(); + it('Should show success message and hide after timeout', async () => { + jest.useFakeTimers(); + await act(async () => { + render(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-secret')); + }); + + expect(screen.getByTestId('copy-success')).toBeInTheDocument(); + + jest.advanceTimersByTime(1500); + + // success message should not be in the dom after timeout + expect(screen.queryByTestId('copy-success')).not.toBeInTheDocument(); + }); + + it('Should have copied text in clipboard', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-secret')); + }); + + // clipboard should have the copied text + expect(clipboardWriteTextMock).toHaveBeenCalledWith(value); + }); + + it('Should handles error when cannot access clipboard API', async () => { + Object.defineProperty(window.navigator, 'clipboard', { + value: undefined, + writable: true, + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-secret')); + }); + + // not show the success message if clipboard API has error + expect(screen.queryByTestId('copy-success')).not.toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/buttons/CopyToClipboardButton/CopyToClipboardButton.tsx b/openmetadata-ui/src/main/resources/ui/src/components/buttons/CopyToClipboardButton/CopyToClipboardButton.tsx index f2b7312f8cf..cbf198d4cb4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/buttons/CopyToClipboardButton/CopyToClipboardButton.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/buttons/CopyToClipboardButton/CopyToClipboardButton.tsx @@ -11,13 +11,11 @@ * limitations under the License. */ -import { Popover, PopoverProps } from 'antd'; -import React, { FunctionComponent, useState } from 'react'; -import CopyToClipboard from 'react-copy-to-clipboard'; +import { Button, Popover, PopoverProps } from 'antd'; +import { ReactComponent as CopyIcon } from 'assets/svg/icon-copy.svg'; +import { useClipboard } from 'hooks/useClipBoard'; +import React, { FunctionComponent } from 'react'; import { useTranslation } from 'react-i18next'; -import SVGIcons, { Icons } from '../../../utils/SvgUtils'; - -import { Button } from '../Button/Button'; interface Props { copyText: string; @@ -33,44 +31,33 @@ export const CopyToClipboardButton: FunctionComponent = ({ onCopy, }: Props) => { const { t } = useTranslation(); - const [copied, setCopied] = useState(false); - - const handleCopying = () => { - setCopied(true); - onCopy?.(); - setTimeout(() => { - setCopied(false); - }, copyTimer); - }; + const { hasCopied, onCopyToClipBoard } = useClipboard( + copyText, + copyTimer, + onCopy + ); return ( - + + {t('message.copied-to-clipboard')} + + } + open={hasCopied} + placement={position} + trigger="click"> - + icon={} + type="text" + onClick={onCopyToClipBoard} + /> + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.test.ts new file mode 100644 index 00000000000..b751f5ef203 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useClipboard } from './useClipBoard'; + +const clipboardWriteTextMock = jest.fn(); +const clipboardMock = { + writeText: clipboardWriteTextMock, +}; + +Object.defineProperty(window.navigator, 'clipboard', { + value: clipboardMock, + writable: true, +}); + +const value = 'Test Value'; +const callBack = jest.fn(); +const timeout = 1000; + +describe('useClipboard hook', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Should copy to clipboard', async () => { + clipboardWriteTextMock.mockResolvedValue(value); + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(value); + expect(result.current.hasCopied).toBe(true); + expect(callBack).toHaveBeenCalled(); + }); + + it('Should handle error while copying to clipboard', async () => { + clipboardWriteTextMock.mockRejectedValue('Error'); + const { result } = renderHook(() => useClipboard(value)); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(value); + expect(result.current.hasCopied).toBe(false); + }); + + it('Should reset hasCopied after the timeout', async () => { + clipboardWriteTextMock.mockResolvedValue(value); + + jest.useFakeTimers(); + + const { result, rerender } = renderHook( + ({ value, timeout, callBack }) => useClipboard(value, timeout, callBack), + { initialProps: { value, timeout, callBack } } + ); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(result.current.hasCopied).toBe(true); + + jest.advanceTimersByTime(timeout); + + rerender({ value, timeout, callBack }); + + expect(result.current.hasCopied).toBe(false); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.ts new file mode 100644 index 00000000000..972023c30fb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { toNumber } from 'lodash'; +import { useCallback, useEffect, useState } from 'react'; + +/** + * React hook to copy text to clipboard + * @param value the text to copy + * @param timeout delay (in ms) to switch back to initial state once copied. + * @param callBack execute when content is copied to clipboard + */ +export const useClipboard = ( + value: string, + timeout = 1500, + callBack?: () => void +) => { + // local state + const [hasCopied, setHasCopied] = useState(false); + const [valueState, setValueState] = useState(value); + + // handlers + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(valueState); + setHasCopied(true); + callBack && callBack(); + } catch (error) { + setHasCopied(false); + } + }, [valueState]); + + // side effects + useEffect(() => setValueState(value), [value]); + + useEffect(() => { + let timeoutId: number | null = null; + + if (hasCopied) { + timeoutId = toNumber( + setTimeout(() => { + setHasCopied(false); + }, timeout) + ); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [timeout, hasCopied]); + + return { + onCopyToClipBoard: handleCopy, + hasCopied, + }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index def82461223..e85b5baab56 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -3701,13 +3701,6 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-copy-to-clipboard@^5.0.2": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.2.tgz#c29690b472a54edff35916f0d1c6c797ad0fd34b" - integrity sha512-O29AThfxrkUFRsZXjfSWR2yaWo0ppB1yLEnHA+Oh24oNetjBAwTDu1PmolIqdJKzsZiO4J1jn6R6TmO96uBvGg== - dependencies: - "@types/react" "*" - "@types/react-dom@*", "@types/react-dom@>=16.9.0": version "17.0.9" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" @@ -5762,13 +5755,6 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -copy-to-clipboard@^3: - version "3.3.1" - resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" - integrity sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw== - dependencies: - toggle-selection "^1.0.6" - copy-to-clipboard@^3.2.0: version "3.3.3" resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" @@ -11399,7 +11385,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.0, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.0, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -12077,14 +12063,6 @@ react-context-mutex@^2.0.0: resolved "https://registry.yarnpkg.com/react-context-mutex/-/react-context-mutex-2.0.0.tgz#4060bc8fbced6d2680ab093139c727201d402b88" integrity sha512-Qss0YfJAKQ/+CYdVjHB3w7wRLdeetSrp2IMEEouXEpoTdohaCXIKZtFyNMMKDytLVWqHiiXg0RDA2kj/DQbeMQ== -react-copy-to-clipboard@^5.0.4: - version "5.0.4" - resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.4.tgz#42ec519b03eb9413b118af92d1780c403a5f19bf" - integrity sha512-IeVAiNVKjSPeGax/Gmkqfa/+PuMTBhutEvFUaMQLwE2tS0EXrAdgOpWDX26bWTXF3HrioorR7lr08NqeYUWQCQ== - dependencies: - copy-to-clipboard "^3" - prop-types "^15.5.8" - react-dnd-html5-backend@14.0.2: version "14.0.2" resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz#25019388f6abdeeda3a6fea835dff155abb2085c"