Skip to content

Commit 325ab9e

Browse files
committed
feat: add functionality for notifying user of errors (with tests)
1 parent 4caa0a1 commit 325ab9e

File tree

2 files changed

+67
-19
lines changed

2 files changed

+67
-19
lines changed

site/src/hooks/useClipboard.test-setup.ts renamed to site/src/hooks/useClipboard.test-setup.tsx

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
useClipboard,
4848
} from "./useClipboard";
4949
import { act, renderHook } from "@testing-library/react";
50+
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
5051

5152
const initialExecCommand = global.document.execCommand;
5253
beforeAll(() => {
@@ -62,14 +63,28 @@ afterAll(() => {
6263
type MockClipboardEscapeHatches = Readonly<{
6364
getMockText: () => string;
6465
setMockText: (newText: string) => void;
66+
simulateFailure: boolean;
67+
setSimulateFailure: (failureMode: boolean) => void;
6568
}>;
6669

6770
type MockClipboard = Readonly<Clipboard & MockClipboardEscapeHatches>;
6871
function makeMockClipboard(isSecureContext: boolean): MockClipboard {
6972
let mockClipboardValue = "";
73+
let shouldFail = false;
7074

7175
return {
76+
get simulateFailure() {
77+
return shouldFail;
78+
},
79+
setSimulateFailure: (value) => {
80+
shouldFail = value;
81+
},
82+
7283
readText: async () => {
84+
if (shouldFail) {
85+
throw new Error("Clipboard deliberately failed");
86+
}
87+
7388
if (!isSecureContext) {
7489
throw new Error(
7590
"Trying to read from clipboard outside secure context!",
@@ -79,6 +94,10 @@ function makeMockClipboard(isSecureContext: boolean): MockClipboard {
7994
return mockClipboardValue;
8095
},
8196
writeText: async (newText) => {
97+
if (shouldFail) {
98+
throw new Error("Clipboard deliberately failed");
99+
}
100+
82101
if (!isSecureContext) {
83102
throw new Error("Trying to write to clipboard outside secure context!");
84103
}
@@ -99,10 +118,18 @@ function makeMockClipboard(isSecureContext: boolean): MockClipboard {
99118
};
100119
}
101120

102-
function renderUseClipboard(textToCopy: string, displayErrors: boolean) {
121+
function renderUseClipboard(inputs: UseClipboardInput) {
103122
return renderHook<UseClipboardResult, UseClipboardInput>(
104123
(props) => useClipboard(props),
105-
{ initialProps: { textToCopy, displayErrors } },
124+
{
125+
initialProps: inputs,
126+
wrapper: ({ children }) => (
127+
<>
128+
<>{children}</>
129+
<GlobalSnackbar />
130+
</>
131+
),
132+
},
106133
);
107134
}
108135

@@ -111,8 +138,8 @@ type ScheduleConfig = Readonly<{ isHttps: boolean }>;
111138
export function scheduleClipboardTests({ isHttps }: ScheduleConfig) {
112139
const mockClipboardInstance = makeMockClipboard(isHttps);
113140

141+
const originalNavigator = window.navigator;
114142
beforeAll(() => {
115-
const originalNavigator = window.navigator;
116143
jest.spyOn(window, "navigator", "get").mockImplementation(() => ({
117144
...originalNavigator,
118145
clipboard: mockClipboardInstance,
@@ -122,6 +149,10 @@ export function scheduleClipboardTests({ isHttps }: ScheduleConfig) {
122149
// Not the biggest fan of exposing implementation details like this, but
123150
// making any kind of mock for execCommand is really gnarly in general
124151
global.document.execCommand = jest.fn(() => {
152+
if (mockClipboardInstance.simulateFailure) {
153+
return false;
154+
}
155+
125156
const dummyInput = document.querySelector("input[data-testid=dummy]");
126157
const inputIsFocused =
127158
dummyInput instanceof HTMLInputElement &&
@@ -157,20 +188,35 @@ export function scheduleClipboardTests({ isHttps }: ScheduleConfig) {
157188
* Start of test cases
158189
*/
159190
it("Copies the current text to the user's clipboard", async () => {
160-
const hookText = "dogs";
161-
const { result } = renderUseClipboard(hookText, false);
162-
await assertClipboardTextUpdate(result, hookText);
191+
const textToCopy = "dogs";
192+
const { result } = renderUseClipboard({ textToCopy });
193+
await assertClipboardTextUpdate(result, textToCopy);
163194
});
164195

165196
it("Should indicate to components not to show successful copy after a set period of time", async () => {
166-
const hookText = "cats";
167-
const { result } = renderUseClipboard(hookText, false);
168-
await assertClipboardTextUpdate(result, hookText);
197+
const textToCopy = "cats";
198+
const { result } = renderUseClipboard({ textToCopy });
199+
await assertClipboardTextUpdate(result, textToCopy);
169200

170201
setTimeout(() => {
171202
expect(result.current.showCopiedSuccess).toBe(false);
172203
}, 10_000);
173204

174205
await jest.runAllTimersAsync();
175206
});
207+
208+
it("Should notify the user of an error using the provided callback", async () => {
209+
const textToCopy = "birds";
210+
const errorCallback = jest.fn();
211+
const { result } = renderUseClipboard({
212+
textToCopy,
213+
onError: errorCallback,
214+
});
215+
216+
mockClipboardInstance.setSimulateFailure(true);
217+
await act(() => result.current.copyToClipboard());
218+
mockClipboardInstance.setSimulateFailure(false);
219+
220+
expect(errorCallback).toBeCalled();
221+
});
176222
}

site/src/hooks/useClipboard.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ export type UseClipboardInput = Readonly<{
88
textToCopy: string;
99

1010
/**
11-
* Callback to call when an error happens. By default, this will use the
12-
* codebase's global displayError function.
11+
* Optional callback to call when an error happens. If not specified, the hook
12+
* will dispatch an error message to the GlobalSnackbar
1313
*/
14-
errorCallback?: (errorMessage: string) => void;
14+
onError?: (errorMessage: string) => void;
1515
}>;
1616

1717
export type UseClipboardResult = Readonly<{
1818
copyToClipboard: () => Promise<void>;
19+
error: Error | undefined;
1920

2021
/**
2122
* Indicates whether the UI should show a successfully-copied status to the
@@ -39,13 +40,14 @@ export type UseClipboardResult = Readonly<{
3940
}>;
4041

4142
export const useClipboard = (input: UseClipboardInput): UseClipboardResult => {
42-
const { textToCopy, errorCallback } = input;
43+
const { textToCopy, onError: errorCallback } = input;
4344
const [showCopiedSuccess, setShowCopiedSuccess] = useState(false);
45+
const [error, setError] = useState<Error>();
4446
const timeoutIdRef = useRef<number | undefined>();
4547

4648
useEffect(() => {
47-
const clearIdsOnUnmount = () => window.clearTimeout(timeoutIdRef.current);
48-
return clearIdsOnUnmount;
49+
const clearIdOnUnmount = () => window.clearTimeout(timeoutIdRef.current);
50+
return clearIdOnUnmount;
4951
}, []);
5052

5153
const handleSuccessfulCopy = () => {
@@ -61,7 +63,6 @@ export const useClipboard = (input: UseClipboardInput): UseClipboardResult => {
6163
handleSuccessfulCopy();
6264
} catch (err) {
6365
const fallbackCopySuccessful = simulateClipboardWrite(textToCopy);
64-
6566
if (fallbackCopySuccessful) {
6667
handleSuccessfulCopy();
6768
return;
@@ -73,13 +74,14 @@ export const useClipboard = (input: UseClipboardInput): UseClipboardResult => {
7374
}
7475

7576
console.error(wrappedErr);
77+
setError(wrappedErr);
7678

77-
const dispatchError = errorCallback ?? displayError;
78-
dispatchError(COPY_FAILED_MESSAGE);
79+
const notifyUser = errorCallback ?? displayError;
80+
notifyUser(COPY_FAILED_MESSAGE);
7981
}
8082
};
8183

82-
return { showCopiedSuccess, copyToClipboard };
84+
return { showCopiedSuccess, error, copyToClipboard };
8385
};
8486

8587
/**

0 commit comments

Comments
 (0)