Skip to content

Commit

Permalink
feat: URL Sharable Search in Explorer (#1103)
Browse files Browse the repository at this point in the history
* Making search persist in URL by having input in search box exist as parameter in URL

* Making state driven by URL: URL search parameters will update the search box in SideNav

* Unit tests added for ensuring URL searching persists in parameter and drives state

* Created custom navigation hook, replaced history pushes where necessary to update routing across ApiExplorer

* Added unit tests for navigation hook

Co-authored-by: John Kaster <kaster@google.com>
  • Loading branch information
patnir41 and jkaster authored Jul 19, 2022
1 parent 84f1efe commit 0eb10ee
Show file tree
Hide file tree
Showing 22 changed files with 330 additions and 87 deletions.
8 changes: 7 additions & 1 deletion packages/api-explorer/src/ApiExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export const ApiExplorer: FC<ApiExplorerProps> = ({
const specs = useSelector(selectSpecs)
const spec = useSelector(selectCurrentSpec)
const { initLodesAction } = useLodeActions()
const { initSettingsAction } = useSettingActions()
const { initSettingsAction, setSearchPatternAction } = useSettingActions()
const { initSpecsAction, setCurrentSpecAction } = useSpecActions()

const location = useLocation()
Expand Down Expand Up @@ -123,6 +123,12 @@ export const ApiExplorer: FC<ApiExplorerProps> = ({
}
}, [location.pathname, spec])

useEffect(() => {
const searchParams = new URLSearchParams(location.search)
const searchPattern = searchParams.get('s') || ''
setSearchPatternAction({ searchPattern: searchPattern! })
}, [location.search])

useEffect(() => {
if (headless) {
window.addEventListener('message', hasNavigationToggle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@

import type { FC } from 'react'
import React from 'react'
import { useHistory } from 'react-router-dom'
import { Markdown } from '@looker/code-editor'
import { useSelector } from 'react-redux'
import { getEnvAdaptor } from '@looker/extension-utils'
import { selectSearchPattern } from '../../state'
import { useNavigation } from '../../utils'
import { transformURL } from './utils'

interface DocMarkdownProps {
Expand All @@ -40,13 +40,13 @@ interface DocMarkdownProps {

export const DocMarkdown: FC<DocMarkdownProps> = ({ source, specKey }) => {
const searchPattern = useSelector(selectSearchPattern)
const history = useHistory()
const navigate = useNavigation()

const linkClickHandler = (pathname: string, url: string) => {
if (pathname.startsWith(`/${specKey}`)) {
history.push(pathname)
navigate(pathname)
} else if (url.startsWith(`/${specKey}`)) {
history.push(url)
navigate(url)
} else if (url.startsWith('https://')) {
const adaptor = getEnvAdaptor()
adaptor.openBrowserWindow(url)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jest.mock('react-router-dom', () => {
useLocation: () => ({
pathname: '/4.0/methods/Dashboard/dashboard',
}),
useHistory: jest.fn().mockReturnValue({ push: jest.fn() }),
useHistory: jest.fn().mockReturnValue({ push: jest.fn(), location }),
}
})

Expand Down Expand Up @@ -83,6 +83,9 @@ describe('ApiSpecSelector', () => {
})
const button = screen.getByText('3.1')
userEvent.click(button)
expect(push).toHaveBeenCalledWith('/3.1/methods/Dashboard/dashboard')
expect(push).toHaveBeenCalledWith({
pathname: '/3.1/methods/Dashboard/dashboard',
search: '',
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
import type { FC } from 'react'
import React from 'react'
import { Select } from '@looker/components'
import { useHistory, useLocation } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import type { SpecItem } from '@looker/sdk-codegen'
import { useSelector } from 'react-redux'
import { useNavigation } from '../../utils'

import { selectSpecs } from '../../state'

Expand All @@ -38,8 +39,8 @@ interface ApiSpecSelectorProps {
}

export const ApiSpecSelector: FC<ApiSpecSelectorProps> = ({ spec }) => {
const history = useHistory()
const location = useLocation()
const navigate = useNavigation()
const specs = useSelector(selectSpecs)
const options = Object.entries(specs).map(([key, spec]) => ({
value: key,
Expand All @@ -49,7 +50,7 @@ export const ApiSpecSelector: FC<ApiSpecSelectorProps> = ({ spec }) => {

const handleChange = (specKey: string) => {
const matchPath = location.pathname.replace(`/${spec.key}`, `/${specKey}`)
history.push(matchPath)
navigate(matchPath)
}

return (
Expand Down
44 changes: 39 additions & 5 deletions packages/api-explorer/src/components/SideNav/SideNav.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import userEvent from '@testing-library/user-event'
import { screen, waitFor } from '@testing-library/react'

import { getLoadedSpecs } from '../../test-data'
import { renderWithRouterAndReduxProvider } from '../../test-utils'
import {
createTestStore,
renderWithRouterAndReduxProvider,
} from '../../test-utils'
import { defaultSettingsState } from '../../state'
import { SideNav } from './SideNav'
import { countMethods, countTypes } from './searchUtils'
Expand Down Expand Up @@ -88,14 +91,45 @@ describe('SideNav', () => {
})
})

const mockHistoryPush = jest.fn()
jest.mock('react-router-dom', () => {
const ReactRouterDOM = jest.requireActual('react-router-dom')
return {
...ReactRouterDOM,
useHistory: () => ({
push: mockHistoryPush,
location,
}),
}
})

describe('Search', () => {
test('it filters methods and types on input', async () => {
renderWithRouterAndReduxProvider(<SideNav spec={spec} />)
test('inputting text in search box updates URL', async () => {
renderWithRouterAndReduxProvider(<SideNav spec={spec} />, ['/3.1/methods'])
const searchPattern = 'embedsso'
const input = screen.getByLabelText('Search')
jest.spyOn(spec.api!, 'search')
/** Pasting to avoid triggering search multiple times */
await userEvent.paste(input, searchPattern)
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: '/3.1/methods',
search: `s=${searchPattern}`,
})
})
})

test('sets search default value from store on load', async () => {
const searchPattern = 'embedsso'
const store = createTestStore({
settings: { searchPattern: searchPattern },
})
jest.spyOn(spec.api!, 'search')
renderWithRouterAndReduxProvider(
<SideNav spec={spec} />,
['/3.1/methods?s=embedsso'],
store
)
const input = screen.getByLabelText('Search')
expect(input).toHaveValue(searchPattern)
await waitFor(() => {
expect(spec.api!.search).toHaveBeenCalledWith(
searchPattern,
Expand Down
43 changes: 22 additions & 21 deletions packages/api-explorer/src/components/SideNav/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import {
TabList,
Tab,
Expand All @@ -44,10 +44,9 @@ import type {
} from '@looker/sdk-codegen'
import { criteriaToSet, tagTypes } from '@looker/sdk-codegen'
import { useSelector } from 'react-redux'

import { useWindowSize } from '../../utils'
import { useWindowSize, useNavigation } from '../../utils'
import { HEADER_REM } from '../Header'
import { selectSearchCriteria, useSettingActions } from '../../state'
import { selectSearchCriteria, selectSearchPattern } from '../../state'
import { SideNavMethodTags } from './SideNavMethodTags'
import { SideNavTypeTags } from './SideNavTypeTags'
import { useDebounce, countMethods, countTypes } from './searchUtils'
Expand All @@ -68,8 +67,8 @@ interface SideNavProps {
}

export const SideNav: FC<SideNavProps> = ({ headless = false, spec }) => {
const history = useHistory()
const location = useLocation()
const navigate = useNavigation()
const specKey = spec.key
const tabNames = ['methods', 'types']
const pathParts = location.pathname.split('/')
Expand All @@ -83,20 +82,19 @@ export const SideNav: FC<SideNavProps> = ({ headless = false, spec }) => {
if (parts[1] === 'diff') {
if (parts[3] !== tabNames[index]) {
parts[3] = tabNames[index]
history.push(parts.join('/'))
navigate(parts.join('/'))
}
} else {
if (parts[2] !== tabNames[index]) {
parts[2] = tabNames[index]
history.push(parts.join('/'))
navigate(parts.join('/'))
}
}
}
const tabs = useTabs({ defaultIndex, onChange: onTabChange })
const searchCriteria = useSelector(selectSearchCriteria)
const { setSearchPatternAction } = useSettingActions()

const [pattern, setSearchPattern] = useState('')
const searchPattern = useSelector(selectSearchPattern)
const [pattern, setSearchPattern] = useState(searchPattern)
const debouncedPattern = useDebounce(pattern, 250)
const [sideNavState, setSideNavState] = useState<SideNavState>(() => ({
tags: spec?.api?.tags || {},
Expand All @@ -111,15 +109,26 @@ export const SideNav: FC<SideNavProps> = ({ headless = false, spec }) => {
setSearchPattern(value)
}

useEffect(() => {
const searchParams = new URLSearchParams(location.search)
if (debouncedPattern && debouncedPattern !== searchParams.get('s')) {
searchParams.set('s', debouncedPattern)
navigate(location.pathname, { search: searchParams.toString() })
} else if (!debouncedPattern && searchParams.get('s')) {
searchParams.delete('s')
navigate(location.pathname, { search: searchParams.toString() })
}
}, [location.search, debouncedPattern])

useEffect(() => {
let results
let newTags
let newTypes
let newTypeTags
const api = spec.api || ({} as ApiModel)

if (debouncedPattern && api.search) {
results = api.search(pattern, criteriaToSet(searchCriteria))
if (searchPattern && api.search) {
results = api.search(searchPattern, criteriaToSet(searchCriteria))
newTags = results.tags
newTypes = results.types
newTypeTags = tagTypes(api, results.types)
Expand All @@ -136,15 +145,7 @@ export const SideNav: FC<SideNavProps> = ({ headless = false, spec }) => {
methodCount: countMethods(newTags),
searchResults: results,
})
setSearchPatternAction({ searchPattern: debouncedPattern })
}, [
debouncedPattern,
specKey,
spec,
setSearchPatternAction,
pattern,
searchCriteria,
])
}, [searchPattern, specKey, spec, searchCriteria])

useEffect(() => {
const { selectedIndex, onSelectTab } = tabs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jest.mock('react-router-dom', () => {
...ReactRouterDOM,
useHistory: () => ({
push: mockHistoryPush,
location,
}),
}
})
Expand Down Expand Up @@ -71,7 +72,10 @@ describe('SideNavMethods', () => {
const firstMethod = Object.values(methods)[0].schema.summary
expect(screen.queryByText(firstMethod)).not.toBeInTheDocument()
userEvent.click(screen.getByText(tag))
expect(mockHistoryPush).toHaveBeenCalledWith(`/${specKey}/methods/${tag}`)
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: `/${specKey}/methods/${tag}`,
search: '',
})
expect(screen.getByRole('link', { name: firstMethod })).toBeInTheDocument()
expect(screen.getAllByRole('link')).toHaveLength(
Object.values(methods).length
Expand All @@ -93,7 +97,10 @@ describe('SideNavMethods', () => {
Object.values(methods).length
)
userEvent.click(screen.getByText(tag))
expect(mockHistoryPush).toHaveBeenCalledWith(`/${specKey}/methods`)
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: `/${specKey}/methods`,
search: '',
})
expect(screen.queryByText(firstMethod)).not.toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
Expand Down
23 changes: 15 additions & 8 deletions packages/api-explorer/src/components/SideNav/SideNavMethods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@ import styled from 'styled-components'
import { Accordion2, Heading } from '@looker/components'
import type { MethodList } from '@looker/sdk-codegen'
import { useSelector } from 'react-redux'
import { useHistory, useRouteMatch } from 'react-router-dom'

import { useLocation, useRouteMatch } from 'react-router-dom'
import { useNavigation, highlightHTML, buildMethodPath } from '../../utils'
import { Link } from '../Link'
import { buildMethodPath, highlightHTML } from '../../utils'
import { selectSearchPattern } from '../../state'

interface MethodsProps {
Expand All @@ -45,20 +44,21 @@ interface MethodsProps {

export const SideNavMethods = styled(
({ className, methods, tag, specKey, defaultOpen = false }: MethodsProps) => {
const location = useLocation()
const navigate = useNavigation()
const searchParams = new URLSearchParams(location.search)
const searchPattern = useSelector(selectSearchPattern)
const match = useRouteMatch<{ methodTag: string }>(
`/:specKey/methods/:methodTag/:methodName?`
)
const [isOpen, setIsOpen] = useState(defaultOpen)
const history = useHistory()

const handleOpen = () => {
const _isOpen = !isOpen
setIsOpen(_isOpen)
if (_isOpen) {
history.push(`/${specKey}/methods/${tag}`)
navigate(`/${specKey}/methods/${tag}`)
} else {
history.push(`/${specKey}/methods`)
navigate(`/${specKey}/methods`)
}
}

Expand All @@ -84,7 +84,14 @@ export const SideNavMethods = styled(
<ul>
{Object.values(methods).map((method) => (
<li key={method.name}>
<Link to={`${buildMethodPath(specKey, tag, method.name)}`}>
<Link
to={`${buildMethodPath(
specKey,
tag,
method.name,
searchParams.toString()
)}`}
>
{highlightHTML(searchPattern, method.summary)}
</Link>
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jest.mock('react-router-dom', () => {
...ReactRouterDOM,
useHistory: () => ({
push: mockHistoryPush,
location,
}),
}
})
Expand All @@ -63,7 +64,10 @@ describe('SideNavTypes', () => {
)
expect(screen.queryByText(typeTags[0])).not.toBeInTheDocument()
userEvent.click(screen.getByText(tag))
expect(mockHistoryPush).toHaveBeenCalledWith(`/${specKey}/types/${tag}`)
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: `/${specKey}/types/${tag}`,
search: '',
})
expect(screen.getByRole('link', { name: typeTags[0] })).toBeInTheDocument()
})

Expand All @@ -78,7 +82,10 @@ describe('SideNavTypes', () => {
)
expect(screen.getByRole('link', { name: typeTags[0] })).toBeInTheDocument()
userEvent.click(screen.getAllByText(tag)[0])
expect(mockHistoryPush).toHaveBeenCalledWith(`/${specKey}/types`)
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: `/${specKey}/types`,
search: '',
})
expect(
screen.queryByRole('link', { name: typeTags[0] })
).not.toBeInTheDocument()
Expand Down
Loading

0 comments on commit 0eb10ee

Please sign in to comment.