From eb199d989584874af4e094f7dde2659d68b0dc68 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Wed, 17 Jun 2020 08:59:30 +0200 Subject: [PATCH 1/6] feat: draft copy to clipboard --- src/components/DomEvents.js | 22 ++++++++++++++++++---- src/components/icons/CopyIcon.js | 14 ++++++++++++++ src/components/icons/TrashcanIcon.js | 14 ++++++++++++++ src/lib/copy.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 src/components/icons/CopyIcon.js create mode 100644 src/components/icons/TrashcanIcon.js create mode 100644 src/lib/copy.js diff --git a/src/components/DomEvents.js b/src/components/DomEvents.js index 35b70316..cf8ebe2d 100644 --- a/src/components/DomEvents.js +++ b/src/components/DomEvents.js @@ -10,8 +10,10 @@ import { FixedSizeList as List } from 'react-window'; import throttle from 'lodash.throttle'; import AutoSizer from 'react-virtualized-auto-sizer'; import IconButton from './IconButton'; -import TrashcanIcon from './TrashcanIcon'; +import TrashcanIcon from './icons/TrashcanIcon'; +import CopyIcon from './icons/CopyIcon'; import EmptyStreetImg from '../images/EmptyStreetImg'; +import copyToClipboard from '../lib/copy'; function onStateChange({ markup, query, result }) { state.save({ markup, query }); @@ -120,6 +122,13 @@ function DomEvents() { setEventCount(0); }; + const copy = () => { + const logString = buffer.current + .map((log) => `${log.target.toString()} - ${log.event.EventType}`) + .join('\n'); + copyToClipboard(logString); + }; + const flush = useCallback( throttle(() => setEventCount(buffer.current.length), 16, { leading: false, @@ -171,9 +180,14 @@ function DomEvents() {
element
selector - - - +
+ + + + + + +
diff --git a/src/components/icons/CopyIcon.js b/src/components/icons/CopyIcon.js new file mode 100644 index 00000000..6e3ec60a --- /dev/null +++ b/src/components/icons/CopyIcon.js @@ -0,0 +1,14 @@ +import React from 'react'; + +function CopyIcon() { + return ( + + + + ); +} + +export default CopyIcon; diff --git a/src/components/icons/TrashcanIcon.js b/src/components/icons/TrashcanIcon.js new file mode 100644 index 00000000..93cbb357 --- /dev/null +++ b/src/components/icons/TrashcanIcon.js @@ -0,0 +1,14 @@ +import React from 'react'; + +function TrashcanIcon() { + return ( + + + + ); +} + +export default TrashcanIcon; diff --git a/src/lib/copy.js b/src/lib/copy.js new file mode 100644 index 00000000..1b58451b --- /dev/null +++ b/src/lib/copy.js @@ -0,0 +1,28 @@ +function copyToClipboard(text) { + if (!navigator.clipboard) { + return new Promise((resolve, reject) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.position = 'fixed'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + const result = document.execCommand('copy'); + if (result) resolve(); + reject(); + } catch (err) { + reject(); + } + document.body.removeChild(textArea); + }); + } + + return navigator.clipboard.writeText(text); +} + +export default copyToClipboard; From cf67c5eafb340b4b76ea8cc4057ca4e67567ac9a Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 18 Jun 2020 10:57:28 +0200 Subject: [PATCH 2/6] refactor: remove old code --- src/lib/copy.js | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 src/lib/copy.js diff --git a/src/lib/copy.js b/src/lib/copy.js deleted file mode 100644 index 1b58451b..00000000 --- a/src/lib/copy.js +++ /dev/null @@ -1,28 +0,0 @@ -function copyToClipboard(text) { - if (!navigator.clipboard) { - return new Promise((resolve, reject) => { - const textArea = document.createElement('textarea'); - textArea.value = text; - - textArea.style.top = '0'; - textArea.style.left = '0'; - textArea.style.position = 'fixed'; - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - try { - const result = document.execCommand('copy'); - if (result) resolve(); - reject(); - } catch (err) { - reject(); - } - document.body.removeChild(textArea); - }); - } - - return navigator.clipboard.writeText(text); -} - -export default copyToClipboard; From 5ec625c3da7fc769a3119186f06b393e58638c28 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 18 Jun 2020 10:58:16 +0200 Subject: [PATCH 3/6] feat: rename ResultCopyButton to CopyButton and use in DomEvents --- src/components/CopyButton.js | 83 +++++++++++++++++++ src/components/CopyButton.test.js | 98 ++++++++++++++++++++++ src/components/DomEvents.js | 16 ++-- src/components/IconButton.js | 11 ++- src/components/ResultCopyButton.js | 103 ------------------------ src/components/ResultCopyButton.test.js | 75 ----------------- src/components/ResultSuggestion.js | 8 +- src/components/TrashcanIcon.js | 14 ---- src/components/icons/CopyIcon.js | 17 +++- src/components/icons/SuccessIcon.js | 17 ++++ 10 files changed, 233 insertions(+), 209 deletions(-) create mode 100644 src/components/CopyButton.js create mode 100644 src/components/CopyButton.test.js delete mode 100644 src/components/ResultCopyButton.js delete mode 100644 src/components/ResultCopyButton.test.js delete mode 100644 src/components/TrashcanIcon.js create mode 100644 src/components/icons/SuccessIcon.js diff --git a/src/components/CopyButton.js b/src/components/CopyButton.js new file mode 100644 index 00000000..08e199da --- /dev/null +++ b/src/components/CopyButton.js @@ -0,0 +1,83 @@ +/* global chrome */ +import React, { useState, useEffect } from 'react'; +import IconButton from './IconButton'; +import SuccessIcon from './icons/SuccessIcon'; +import CopyIcon from './icons/CopyIcon'; + +const IS_DEVTOOL = !!(window.chrome && chrome.runtime && chrome.runtime.id); + +/** + * + * @param {string} suggestion + */ +async function attemptCopyToClipboard(suggestion) { + try { + if (!IS_DEVTOOL && 'clipboard' in navigator) { + await navigator.clipboard.writeText(suggestion); + return true; + } + + const input = Object.assign(document.createElement('input'), { + type: 'text', + value: suggestion, + }); + + document.body.append(input); + input.select(); + document.execCommand('copy'); + input.remove(); + + return true; + } catch (error) { + console.error(error); + return false; + } +} + +/** + * + * @param {{ + * text: string | function; + * title: string; + * className: string; + * variant: string; + * }} props + */ +function CopyButton({ text, title, className, variant }) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (copied) { + const timeout = setTimeout(() => { + setCopied(false); + }, 1500); + + return () => clearTimeout(timeout); + } + }, [copied]); + + async function handleClick() { + let textToCopy = text; + if (typeof text === 'function') { + textToCopy = text(); + } + const wasSuccessfullyCopied = await attemptCopyToClipboard(textToCopy); + + if (wasSuccessfullyCopied) { + setCopied(true); + } + } + + return ( + + {copied ? : } + + ); +} + +export default CopyButton; diff --git a/src/components/CopyButton.test.js b/src/components/CopyButton.test.js new file mode 100644 index 00000000..a29ec430 --- /dev/null +++ b/src/components/CopyButton.test.js @@ -0,0 +1,98 @@ +import React from 'react'; +import CopyButton from './CopyButton'; +import { render, fireEvent, act, waitFor } from '@testing-library/react'; + +const defaultProps = { + text: 'string', + title: 'title', +}; + +beforeEach(() => { + delete window.navigator.clipboard; + delete document.execCommand; +}); + +it('renders without crashing given default props', () => { + render(); +}); + +it('attempts to copy to clipboard through navigator.clipboard', async () => { + const clipboardSpy = jest.fn(); + + window.navigator.clipboard = { + writeText: clipboardSpy, + }; + + const { getByRole } = render(); + + await act(async () => { + fireEvent.click(getByRole('button')); + }); + + expect(clipboardSpy).toHaveBeenCalledWith(defaultProps.text); + expect(clipboardSpy).toHaveBeenCalledTimes(1); +}); + +it('attempts to copy with legacy methods if navigator.clipboard is unavailable', async () => { + const execCommandSpy = jest.fn(); + + document.execCommand = execCommandSpy; + + const { getByRole } = render(); + + await act(async () => { + fireEvent.click(getByRole('button')); + }); + + expect(execCommandSpy).toHaveBeenCalledWith('copy'); + expect(execCommandSpy).toHaveBeenCalledTimes(1); +}); + +it('temporarily shows a different icon after copying', async () => { + jest.useFakeTimers(); + const execCommandSpy = jest.fn(); + + document.execCommand = execCommandSpy; + + const { getByRole } = render(); + + const button = getByRole('button'); + + const initialIcon = button.innerHTML; + + // act due to useEffect state change + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => { + expect(button.innerHTML).not.toBe(initialIcon); + }); + + // same here + await act(async () => { + jest.runAllTimers(); + }); + + await waitFor(() => { + expect(button.innerHTML).toBe(initialIcon); + }); +}); + +it('should accept funcition to get text to copy', async () => { + const execCommandSpy = jest.fn(); + const getTextToCopy = () => 'copy'; + + document.execCommand = execCommandSpy; + + const { getByRole } = render( + , + ); + + await act(async () => { + fireEvent.click(getByRole('button')); + }); + + expect(execCommandSpy).toHaveBeenCalledWith('copy'); + expect(execCommandSpy).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/DomEvents.js b/src/components/DomEvents.js index 5da93c14..0c7a8bcf 100644 --- a/src/components/DomEvents.js +++ b/src/components/DomEvents.js @@ -11,9 +11,8 @@ import throttle from 'lodash.throttle'; import AutoSizer from 'react-virtualized-auto-sizer'; import IconButton from './IconButton'; import TrashcanIcon from './icons/TrashcanIcon'; -import CopyIcon from './icons/CopyIcon'; +import CopyButton from './CopyButton'; import EmptyStreetImg from '../images/EmptyStreetImg'; -import copyToClipboard from '../lib/copy'; function onStateChange({ markup, query, result }) { state.save({ markup, query }); @@ -128,11 +127,10 @@ function DomEvents() { setEventCount(0); }; - const copy = () => { - const logString = buffer.current + const getTextToCopy = () => { + return buffer.current .map((log) => `${log.target.toString()} - ${log.event.EventType}`) .join('\n'); - copyToClipboard(logString); }; const flush = useCallback( @@ -192,9 +190,11 @@ function DomEvents() {
selector
- - - + diff --git a/src/components/IconButton.js b/src/components/IconButton.js index 1588a7b2..f4a57f84 100644 --- a/src/components/IconButton.js +++ b/src/components/IconButton.js @@ -1,13 +1,18 @@ import React from 'react'; +const variants = { + dark: 'text-gray-600 hover:text-gray-400', + light: 'text-gray-400 hover:text-gray-600', + white: 'text-white hover:text-white', +}; + function IconButton({ children, title, variant, onClick, className }) { + const cssVariant = variants[variant] ?? variants['light']; return ( - ); -} - -export default ResultCopyButton; diff --git a/src/components/ResultCopyButton.test.js b/src/components/ResultCopyButton.test.js deleted file mode 100644 index f9b7263f..00000000 --- a/src/components/ResultCopyButton.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import ResultCopyButton from './ResultCopyButton'; -import { render, fireEvent, act, waitFor } from '@testing-library/react'; - -const defaultProps = { - expression: 'string', -}; - -describe('', () => { - it('renders without crashing given default props', () => { - render(); - }); - - it('attempts to copy to clipboard through navigator.clipboard', async () => { - const clipboardSpy = jest.fn(); - - window.navigator.clipboard = { - writeText: clipboardSpy, - }; - - const { getByRole } = render(); - - await act(async () => { - fireEvent.click(getByRole('button')); - }); - - expect(clipboardSpy).toHaveBeenCalledWith(defaultProps.expression); - expect(clipboardSpy).toHaveBeenCalledTimes(1); - - delete window.navigator.clipboard; - }); - - it('attempts to copy with legacy methods if navigator.clipboard is unavailable', async () => { - const execCommandSpy = jest.fn(); - - document.execCommand = execCommandSpy; - - const { getByRole } = render(); - - await act(async () => { - fireEvent.click(getByRole('button')); - }); - - expect(execCommandSpy).toHaveBeenCalledWith('copy'); - expect(execCommandSpy).toHaveBeenCalledTimes(1); - }); - - it('temporarily shows a different icon after copying', async () => { - jest.useFakeTimers(); - - const { getByRole } = render(); - - const button = getByRole('button'); - - const initialIcon = button.innerHTML; - - // act due to useEffect state change - await act(async () => { - fireEvent.click(button); - }); - - await waitFor(() => { - expect(button.innerHTML).not.toBe(initialIcon); - }); - - // same here - await act(async () => { - jest.runAllTimers(); - }); - - await waitFor(() => { - expect(button.innerHTML).toBe(initialIcon); - }); - }); -}); diff --git a/src/components/ResultSuggestion.js b/src/components/ResultSuggestion.js index e65cc38f..fc03b49f 100644 --- a/src/components/ResultSuggestion.js +++ b/src/components/ResultSuggestion.js @@ -1,6 +1,6 @@ import React from 'react'; import { messages } from '../constants'; -import ResultCopyButton from './ResultCopyButton'; +import CopyButton from './CopyButton'; const colors = ['bg-blue-600', 'bg-yellow-600', 'bg-orange-600', 'bg-red-600']; @@ -97,7 +97,11 @@ function ResultSuggestion({ data, suggestion, result, dispatch }) { > {suggestion.expression}
- +
)} diff --git a/src/components/TrashcanIcon.js b/src/components/TrashcanIcon.js deleted file mode 100644 index 93cbb357..00000000 --- a/src/components/TrashcanIcon.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -function TrashcanIcon() { - return ( - - - - ); -} - -export default TrashcanIcon; diff --git a/src/components/icons/CopyIcon.js b/src/components/icons/CopyIcon.js index 6e3ec60a..04cb6c89 100644 --- a/src/components/icons/CopyIcon.js +++ b/src/components/icons/CopyIcon.js @@ -2,11 +2,20 @@ import React from 'react'; function CopyIcon() { return ( - + + + fill="currentColor" + d=" +M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3a2 2 0 0 0 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2 +2v10a2 2 0 0 0 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z +" + > ); } diff --git a/src/components/icons/SuccessIcon.js b/src/components/icons/SuccessIcon.js new file mode 100644 index 00000000..631cf2a9 --- /dev/null +++ b/src/components/icons/SuccessIcon.js @@ -0,0 +1,17 @@ +import React from 'react'; + +function SuccessIcon() { + return ( + + + + ); +} + +export default SuccessIcon; From adfb7e334a14d1a20e6d1e70935b04d675e41591 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 18 Jun 2020 17:06:12 +0200 Subject: [PATCH 4/6] fix: after review --- src/components/CopyButton.js | 3 +-- src/components/DomEvents.js | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/CopyButton.js b/src/components/CopyButton.js index 08e199da..291b2364 100644 --- a/src/components/CopyButton.js +++ b/src/components/CopyButton.js @@ -1,10 +1,9 @@ -/* global chrome */ import React, { useState, useEffect } from 'react'; import IconButton from './IconButton'; import SuccessIcon from './icons/SuccessIcon'; import CopyIcon from './icons/CopyIcon'; -const IS_DEVTOOL = !!(window.chrome && chrome.runtime && chrome.runtime.id); +const IS_DEVTOOL = Boolean(window?.chrome?.runtime?.id); /** * diff --git a/src/components/DomEvents.js b/src/components/DomEvents.js index 001c5acd..fee839dc 100644 --- a/src/components/DomEvents.js +++ b/src/components/DomEvents.js @@ -127,11 +127,10 @@ function DomEvents() { setEventCount(0); }; - const getTextToCopy = () => { - return buffer.current + const getTextToCopy = () => + buffer.current .map((log) => `${log.target.toString()} - ${log.event.EventType}`) .join('\n'); - }; const flush = useCallback( throttle(() => setEventCount(buffer.current.length), 16, { From af13a7f5837154629b64c4e13cd75a7fa6ec2264 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 18 Jun 2020 20:37:45 +0200 Subject: [PATCH 5/6] refactor: revert update to IS_DEVTOOL --- src/components/CopyButton.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/CopyButton.js b/src/components/CopyButton.js index 291b2364..08e199da 100644 --- a/src/components/CopyButton.js +++ b/src/components/CopyButton.js @@ -1,9 +1,10 @@ +/* global chrome */ import React, { useState, useEffect } from 'react'; import IconButton from './IconButton'; import SuccessIcon from './icons/SuccessIcon'; import CopyIcon from './icons/CopyIcon'; -const IS_DEVTOOL = Boolean(window?.chrome?.runtime?.id); +const IS_DEVTOOL = !!(window.chrome && chrome.runtime && chrome.runtime.id); /** * From 20f54292a7538600efd65175360ff825a96398be Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Fri, 19 Jun 2020 10:29:58 +0200 Subject: [PATCH 6/6] change fallback style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michaƫl De Boey --- src/components/IconButton.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/IconButton.js b/src/components/IconButton.js index f4a57f84..c6bd8f52 100644 --- a/src/components/IconButton.js +++ b/src/components/IconButton.js @@ -7,7 +7,7 @@ const variants = { }; function IconButton({ children, title, variant, onClick, className }) { - const cssVariant = variants[variant] ?? variants['light']; + const cssVariant = variants[variant] || variants['light']; return (