-
Notifications
You must be signed in to change notification settings - Fork 894
fix: improve click UX and styling for Auth Token page #11863
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 22 commits
7e4d964
ece3f6d
bf6e73a
697bdf0
9b881c0
ccdca08
a7c7cc7
163db92
38b80a8
92f94d7
e0e5e6f
0ca1558
88b96df
15feb14
e3feffc
3ec5196
cf2d179
98bd1af
400e07c
3307432
f10134f
c4469f3
d8b6727
6157049
89c74da
b95cfd6
4ab6326
778560f
cf53114
97fed6f
83febe2
fb0f4b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ import IconButton from "@mui/material/Button"; | |
import Tooltip from "@mui/material/Tooltip"; | ||
import Check from "@mui/icons-material/Check"; | ||
import { css, type Interpolation, type Theme } from "@emotion/react"; | ||
import { type FC, type ReactNode } from "react"; | ||
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"; | ||
import { useClipboard } from "hooks/useClipboard"; | ||
import { FileCopyIcon } from "../Icons/FileCopyIcon"; | ||
|
||
|
@@ -13,6 +13,7 @@ interface CopyButtonProps { | |
wrapperStyles?: Interpolation<Theme>; | ||
buttonStyles?: Interpolation<Theme>; | ||
tooltipTitle?: string; | ||
onClick?: MouseEventHandler; | ||
} | ||
|
||
export const Language = { | ||
|
@@ -23,36 +24,44 @@ export const Language = { | |
/** | ||
* Copy button used inside the CodeBlock component internally | ||
*/ | ||
export const CopyButton: FC<CopyButtonProps> = ({ | ||
text, | ||
ctaCopy, | ||
wrapperStyles, | ||
buttonStyles, | ||
tooltipTitle = Language.tooltipTitle, | ||
}) => { | ||
const { isCopied, copy: copyToClipboard } = useClipboard(text); | ||
export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>( | ||
(props, ref) => { | ||
const { | ||
text, | ||
ctaCopy, | ||
wrapperStyles, | ||
buttonStyles, | ||
onClick: outsideOnClick, | ||
tooltipTitle = Language.tooltipTitle, | ||
} = props; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Generally try to keep prop definitions inlined, but Prettier's auto-formatting made the code super ugly to look at |
||
const { isCopied, copyToClipboard } = useClipboard(text); | ||
|
||
return ( | ||
<Tooltip title={tooltipTitle} placement="top"> | ||
<div css={[{ display: "flex" }, wrapperStyles]}> | ||
<IconButton | ||
css={[styles.button, buttonStyles]} | ||
onClick={copyToClipboard} | ||
size="small" | ||
aria-label={Language.ariaLabel} | ||
variant="text" | ||
> | ||
{isCopied ? ( | ||
<Check css={styles.copyIcon} /> | ||
) : ( | ||
<FileCopyIcon css={styles.copyIcon} /> | ||
)} | ||
{ctaCopy && <div css={{ marginLeft: 8 }}>{ctaCopy}</div>} | ||
</IconButton> | ||
</div> | ||
</Tooltip> | ||
); | ||
}; | ||
return ( | ||
<Tooltip title={tooltipTitle} placement="top"> | ||
<div css={[{ display: "flex" }, wrapperStyles]}> | ||
<IconButton | ||
ref={ref} | ||
css={[styles.button, buttonStyles]} | ||
size="small" | ||
aria-label={Language.ariaLabel} | ||
variant="text" | ||
onClick={(event) => { | ||
void copyToClipboard(); | ||
outsideOnClick?.(event); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More just for flexibility. It might be because I've been in a Backstage mindset, where we need a lot more API flexibility, but my thinking was this would let someone attach an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the Coder app context + this component context, what would be a common use case for this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this context, it is over-engineering, yeah. I'll remove it |
||
}} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the only major change for the component. The rest of the diff is just from the |
||
> | ||
{isCopied ? ( | ||
<Check css={styles.copyIcon} /> | ||
) : ( | ||
<FileCopyIcon css={styles.copyIcon} /> | ||
)} | ||
{ctaCopy && <div css={{ marginLeft: 8 }}>{ctaCopy}</div>} | ||
</IconButton> | ||
</div> | ||
</Tooltip> | ||
); | ||
}, | ||
); | ||
|
||
const styles = { | ||
button: (theme) => css` | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
/** | ||
* userEvent.setup is supposed to stub out a clipboard for you, since it doesn't | ||
* normally exist in JSDOM. Spent ages trying to figure out how to make it work, | ||
* but couldn't figure it out. So the code is using a home-grown mock. | ||
* | ||
* The bad news is that not using userEvent.setup means that you have to test | ||
* all events through fireEvent instead of userEvent | ||
* | ||
* @todo Figure out how to swap userEvent in, just to help make sure that the | ||
* tests mirror actual user flows more closely. | ||
*/ | ||
import { fireEvent, renderHook, waitFor } from "@testing-library/react"; | ||
import { useClipboard } from "./useClipboard"; | ||
|
||
type MockClipboard = Readonly< | ||
Clipboard & { | ||
resetText: () => void; | ||
} | ||
>; | ||
|
||
function makeMockClipboard(): MockClipboard { | ||
let mockClipboardValue = ""; | ||
|
||
return { | ||
readText: async () => mockClipboardValue, | ||
writeText: async (newText) => { | ||
mockClipboardValue = newText; | ||
}, | ||
resetText: () => { | ||
mockClipboardValue = ""; | ||
}, | ||
|
||
addEventListener: jest.fn(), | ||
removeEventListener: jest.fn(), | ||
dispatchEvent: jest.fn(), | ||
read: jest.fn(), | ||
write: jest.fn(), | ||
}; | ||
} | ||
|
||
const mockClipboard = makeMockClipboard(); | ||
|
||
beforeAll(() => { | ||
const originalNavigator = window.navigator; | ||
jest.spyOn(window, "navigator", "get").mockImplementation(() => { | ||
return { ...originalNavigator, clipboard: mockClipboard }; | ||
}); | ||
|
||
jest.spyOn(document, "hasFocus").mockImplementation(() => true); | ||
jest.useFakeTimers(); | ||
}); | ||
|
||
afterEach(() => { | ||
mockClipboard.resetText(); | ||
}); | ||
|
||
afterAll(() => { | ||
jest.resetAllMocks(); | ||
jest.useRealTimers(); | ||
}); | ||
|
||
async function prepareInitialClipboardValue(clipboardText: string) { | ||
/* eslint-disable-next-line testing-library/render-result-naming-convention -- | ||
Need to pass the whole render result back */ | ||
const rendered = renderHook(({ text }) => useClipboard(text), { | ||
initialProps: { text: clipboardText }, | ||
}); | ||
|
||
await rendered.result.current.copyToClipboard(); | ||
await waitFor(() => expect(rendered.result.current.isCopied).toBe(true)); | ||
|
||
return rendered; | ||
} | ||
|
||
const text1 = "blah"; | ||
const text2 = "nah"; | ||
|
||
describe(useClipboard.name, () => { | ||
describe(".copyToClipboard", () => { | ||
it("Injects a new value into the clipboard when called", async () => { | ||
await prepareInitialClipboardValue(text1); | ||
}); | ||
|
||
it("Injects the most recent value the hook rendered with", async () => { | ||
const { result, rerender } = await prepareInitialClipboardValue(text1); | ||
rerender({ text: text2 }); | ||
await waitFor(() => expect(result.current.isCopied).toBe(false)); | ||
|
||
await result.current.copyToClipboard(); | ||
await waitFor(() => expect(result.current.isCopied).toBe(true)); | ||
}); | ||
|
||
it("Maintains a stable reference as long as text doesn't change", async () => { | ||
const { result, rerender } = await prepareInitialClipboardValue(text1); | ||
const originalFunction = result.current.copyToClipboard; | ||
|
||
rerender({ text: text1 }); | ||
expect(result.current.copyToClipboard).toBe(originalFunction); | ||
|
||
rerender({ text: text2 }); | ||
expect(result.current.copyToClipboard).not.toBe(originalFunction); | ||
}); | ||
}); | ||
|
||
describe(".isCopied", () => { | ||
it("Does not change its value if the clipboard never changes", async () => { | ||
const { result } = await prepareInitialClipboardValue(text1); | ||
for (let i = 1; i <= 10; i++) { | ||
setTimeout(() => { | ||
expect(result.current.isCopied).toBe(true); | ||
}, i * 10_000); | ||
} | ||
|
||
await jest.advanceTimersByTimeAsync(100_000); | ||
}); | ||
|
||
it("Listens to the user copying different text while in the same tab", async () => { | ||
const { result } = await prepareInitialClipboardValue(text1); | ||
|
||
await mockClipboard.writeText(text2); | ||
fireEvent(window, new Event("copy")); | ||
await waitFor(() => expect(result.current.isCopied).toBe(false)); | ||
}); | ||
|
||
it("Re-syncs state when user navigates to a different tab and comes back", async () => { | ||
const { result, rerender } = await prepareInitialClipboardValue(text1); | ||
rerender({ text: text2 }); | ||
await waitFor(() => expect(result.current.isCopied).toBe(false)); | ||
|
||
fireEvent(window, new FocusEvent("blur")); | ||
await mockClipboard.writeText(text2); | ||
fireEvent(window, new FocusEvent("focus")); | ||
await waitFor(() => expect(result.current.isCopied).toBe(true)); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's always good to share screenshots and demos when making UI changes or features. Some design improvements I see we can make:
