Skip to content

Commit

Permalink
feat: copy link button for search (#1120)
Browse files Browse the repository at this point in the history
* Proper CopyLinkWrapper component directory established

* Unit tests for CopyLinkWrapper

* Refactored HTML for button layout

* Documentation in CopyLinkWrapper

* Added support for extension framework

* Testing for extension framework

Co-authored-by: John Kaster <kaster@google.com>
  • Loading branch information
patnir41 and jkaster authored Aug 2, 2022
1 parent 1313c2b commit d6e0c37
Show file tree
Hide file tree
Showing 10 changed files with 295 additions and 8 deletions.
25 changes: 17 additions & 8 deletions packages/api-explorer/src/components/SideNav/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
TabPanels,
useTabs,
InputSearch,
Box2,
} from '@looker/components'
import type {
SpecItem,
Expand All @@ -44,6 +45,7 @@ import type {
} from '@looker/sdk-codegen'
import { criteriaToSet, tagTypes } from '@looker/sdk-codegen'
import { useSelector } from 'react-redux'
import { CopyLinkWrapper } from '@looker/run-it'
import { useWindowSize, useNavigation } from '../../utils'
import { HEADER_REM } from '../Header'
import { selectSearchCriteria, selectSearchPattern } from '../../state'
Expand Down Expand Up @@ -160,18 +162,25 @@ export const SideNav: FC<SideNavProps> = ({ headless = false, spec }) => {

return (
<nav>
<InputSearch
<Box2
pl="large"
pr="large"
pb="large"
pt={headless ? 'u3' : 'large'}
aria-label="Search"
onChange={handleInputChange}
placeholder="Search"
value={pattern}
isClearable
/>
<SearchMessage search={searchResults} />
position={'relative'}
width={'100%'}
>
<CopyLinkWrapper visible={!!pattern}>
<InputSearch
aria-label="Search"
onChange={handleInputChange}
placeholder="Search"
value={pattern}
isClearable
/>
</CopyLinkWrapper>
<SearchMessage search={searchResults} />
</Box2>
<TabList {...tabs} distribute>
<Tab>Methods ({methodCount})</Tab>
<Tab>Types ({typeCount})</Tab>
Expand Down
5 changes: 5 additions & 0 deletions packages/extension-utils/src/adaptorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export interface IAuthAdaptor {
* Examples include: local storage operations and various link navigation functions
*/
export interface IEnvironmentAdaptor extends IAuthAdaptor {
/** Copy page URL to clipboard */
copyToClipboard: (location?: {
pathname: string
search: string
}) => Promise<void>
/** Method for determining whether running in a browser or extension environment */
isExtension(): boolean
/** Method for retrieving a keyed value from local storage */
Expand Down
16 changes: 16 additions & 0 deletions packages/extension-utils/src/browserAdaptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,20 @@ describe('BrowserAdaptor', () => {
window.location = saveLoc
}
)

const mockClipboardCopy = jest
.fn()
.mockImplementation(() => Promise.resolve())
Object.assign(navigator, {
clipboard: {
writeText: mockClipboardCopy,
},
})

test('copies location href to clipboard', async () => {
jest.spyOn(navigator.clipboard, 'writeText')
const adaptor = new BrowserAdaptor({} as IAPIMethods)
await adaptor.copyToClipboard()
expect(mockClipboardCopy).toHaveBeenCalledWith(location.href)
})
})
4 changes: 4 additions & 0 deletions packages/extension-utils/src/browserAdaptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export class BrowserAdaptor
this._themeOverrides = getThemeOverrides(hostedInternally(hostname))
}

async copyToClipboard() {
await navigator.clipboard.writeText(location.href)
}

isExtension() {
return false
}
Expand Down
30 changes: 30 additions & 0 deletions packages/extension-utils/src/extensionAdaptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,34 @@ describe('ExtensionAdaptor', () => {
).toEqual(expectedOverrides)
}
)

const adaptor = new ExtensionAdaptor(
{
lookerHostData: {} as Readonly<LookerHostData>,
} as ExtensionSDK,
{} as IAPIMethods
)

const mockClipboardWrite = jest
.fn()
.mockImplementation(() => Promise.resolve())
Object.assign(adaptor, {
extensionSdk: {
clipboardWrite: mockClipboardWrite,
lookerHostData: {
hostOrigin: 'https://self-signed.looker.com:9999',
extensionId: 'apix::api-explorer',
},
},
})

test('copies browser URL to clipboard', async () => {
jest.spyOn(adaptor.extensionSdk, 'clipboardWrite')
await adaptor.copyToClipboard(location)
const testHostData = adaptor.extensionSdk.lookerHostData
const expectedClipboardContents = `${testHostData!.hostOrigin}/extensions/${
testHostData!.extensionId
}${location.pathname}${location.search}`
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedClipboardContents)
})
})
10 changes: 10 additions & 0 deletions packages/extension-utils/src/extensionAdaptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ export class ExtensionAdaptor
)
}

async copyToClipboard(location?: { pathname: string; search: string }) {
const { lookerHostData } = this.extensionSdk
if (lookerHostData && location) {
const { hostOrigin, extensionId } = lookerHostData
const { pathname, search } = location
const url = `${hostOrigin}/extensions/${extensionId}${pathname}${search}`
await this.extensionSdk.clipboardWrite(url)
}
}

isExtension() {
return true
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
MIT License
Copyright (c) 2021 Looker Data Sciences, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

import { renderWithTheme } from '@looker/components-test-utils'
import { screen, waitFor } from '@testing-library/react'
import React from 'react'
import userEvent from '@testing-library/user-event'
import { BrowserAdaptor, registerTestEnvAdaptor } from '@looker/extension-utils'
import { CopyLinkWrapper } from './index'
import { initRunItSdk } from '@looker/run-it'

jest.mock('react-router-dom', () => {
const ReactRouterDOM = jest.requireActual('react-router-dom')
return {
...ReactRouterDOM,
useLocation: () => ({
pathname: location.pathname,
search: location.search,
}),
}
})

describe('CopyLinkWrapper', () => {
test('it renders and hides button upon mouse hover', () => {
renderWithTheme(
<CopyLinkWrapper>
<div>test</div>
</CopyLinkWrapper>
)
const div = screen.getByText('test')
userEvent.hover(div)
expect(screen.queryByRole('button')).toBeInTheDocument()
userEvent.unhover(div)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})

const mockClipboardCopy = jest
.fn()
.mockImplementation(() => Promise.resolve())
Object.assign(navigator, {
clipboard: {
writeText: mockClipboardCopy,
},
})

test('it copies to clipboard', async () => {
registerTestEnvAdaptor()
jest.spyOn(navigator.clipboard, 'writeText')
renderWithTheme(
<CopyLinkWrapper visible={true}>
<div>test</div>
</CopyLinkWrapper>
)
const div = screen.getByText('test')
userEvent.hover(div)
await waitFor(() => {
userEvent.click(screen.getByRole('button'))
expect(mockClipboardCopy).toHaveBeenCalledWith(location.href)
})
})

test('it updates tooltip content upon copy', async () => {
const sdk = initRunItSdk()
registerTestEnvAdaptor(new BrowserAdaptor(sdk))
renderWithTheme(
<CopyLinkWrapper visible={true}>
<div>test</div>
</CopyLinkWrapper>
)
const div = screen.getByText('test')
userEvent.hover(div)
const button = screen.getByRole('button')
await waitFor(() => {
userEvent.hover(button)
expect(screen.getByText('Copy to clipboard')).toBeInTheDocument()
})
await waitFor(() => {
userEvent.click(screen.getByRole('button'))
expect(mockClipboardCopy).toHaveBeenCalledWith(location.href)
userEvent.hover(button)
expect(screen.getAllByText('Copied to clipboard')[0]).toBeVisible()
})
})
})
79 changes: 79 additions & 0 deletions packages/run-it/src/components/CopyLinkWrapper/CopyLinkWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
MIT License
Copyright (c) 2021 Looker Data Sciences, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import type { ReactNode, ReactNodeArray } from 'react'
import React, { useState } from 'react'
import { IconButton, Space } from '@looker/components'
import { Link } from '@styled-icons/material-outlined/Link'
import { getEnvAdaptor } from '@looker/extension-utils'
import { useLocation } from 'react-router-dom'

interface CopyLinkWrapperProps {
children: ReactNode | ReactNodeArray
visible?: boolean
}

const COPY_TO_CLIPBOARD = 'Copy to clipboard'

/**
* Displays a copy link button on hover
*
* @param children component(s) which will render left of the button
* @param visible boolean determining button visibility
*/
export const CopyLinkWrapper = ({
children,
visible = true,
}: CopyLinkWrapperProps) => {
const [tooltipContent, setTooltipContent] = useState(COPY_TO_CLIPBOARD)
const [showCopyLinkButton, setShowCopyLinkButton] = useState(false)
const location = useLocation()
const handleCopyLink = async () => {
await getEnvAdaptor().copyToClipboard(location)
setTooltipContent('Copied to clipboard')
}
const handleMouseLeave = () => {
setTooltipContent(COPY_TO_CLIPBOARD)
}
return (
<Space
width={'100%'}
onMouseEnter={() => setShowCopyLinkButton(true)}
onMouseLeave={() => setShowCopyLinkButton(false)}
>
{children}
{showCopyLinkButton && visible && (
<IconButton
onClick={handleCopyLink}
icon={<Link />}
size="small"
label={tooltipContent}
tooltipPlacement="bottom"
onMouseLeave={handleMouseLeave}
/>
)}
</Space>
)
}
26 changes: 26 additions & 0 deletions packages/run-it/src/components/CopyLinkWrapper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
MIT License
Copyright (c) 2021 Looker Data Sciences, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
export { CopyLinkWrapper } from './CopyLinkWrapper'
1 change: 1 addition & 0 deletions packages/run-it/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
export * from './common'
export * from './Collapser'
export * from './ConfigForm'
export * from './CopyLinkWrapper'
export * from './DocSdkCalls'
export * from './DataGrid'
export * from './LoginForm'
Expand Down

0 comments on commit d6e0c37

Please sign in to comment.