Skip to content

feat: Tag scene filter selection as URL search parameter #1140

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

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions packages/api-explorer/src/ApiExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,16 @@ import {
selectSpecs,
selectCurrentSpec,
selectSdkLanguage,
selectTagFilter,
} from './state'
import { getSpecKey, diffPath, useNavigation, findSdk, allAlias } from './utils'
import {
getSpecKey,
diffPath,
useNavigation,
findSdk,
allAlias,
isValidFilter,
} from './utils'

export interface ApiExplorerProps {
adaptor: IApixAdaptor
Expand All @@ -92,9 +100,14 @@ export const ApiExplorer: FC<ApiExplorerProps> = ({
const specs = useSelector(selectSpecs)
const spec = useSelector(selectCurrentSpec)
const selectedSdkLanguage = useSelector(selectSdkLanguage)
const selectedTagFilter = useSelector(selectTagFilter)
const { initLodesAction } = useLodeActions()
const { initSettingsAction, setSearchPatternAction, setSdkLanguageAction } =
useSettingActions()
const {
initSettingsAction,
setSearchPatternAction,
setSdkLanguageAction,
setTagFilterAction,
} = useSettingActions()
const { initSpecsAction, setCurrentSpecAction } = useSpecActions()

const location = useLocation()
Expand Down Expand Up @@ -125,10 +138,13 @@ export const ApiExplorer: FC<ApiExplorerProps> = ({
// reconcile local storage state with URL or vice versa
if (initialized) {
const sdkParam = searchParams.get('sdk') || ''
const verbParam = searchParams.get('v') || ''
const sdk = findSdk(sdkParam)
const validSdkParam =
!sdkParam.localeCompare(sdk.alias, 'en', { sensitivity: 'base' }) ||
!sdkParam.localeCompare(sdk.language, 'en', { sensitivity: 'base' })
const validVerbParam = isValidFilter(location, verbParam)

if (validSdkParam) {
// sync store with URL
setSdkLanguageAction({
Expand All @@ -141,6 +157,14 @@ export const ApiExplorer: FC<ApiExplorerProps> = ({
sdk: alias === allAlias ? null : alias,
})
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gives code smell, as I can see it continually expanding as new parameters are added. I'm wondering if there is any insight on how to go about syncing multiple parameters at once in this initialization effect.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a good way to do this will be to augment initSettings so that url parameters can be passed in and override persisted settings when applicable. As a result, this effect will then become a one liner that syncs the url with the store using navigate.

if (validVerbParam) {
setTagFilterAction({ tagFilter: verbParam.toUpperCase() })
} else {
navigate(location.pathname, {
v: selectedTagFilter === 'ALL' ? null : selectedTagFilter,
})
}
}
}, [initialized])

Expand All @@ -153,11 +177,18 @@ export const ApiExplorer: FC<ApiExplorerProps> = ({

useEffect(() => {
if (!initialized) return
const searchParams = new URLSearchParams(location.search)
const searchPattern = searchParams.get('s') || ''
const sdkParam = searchParams.get('sdk') || 'all'
const verbParam = searchParams.get('v') || 'ALL'
const { language: sdkLanguage } = findSdk(sdkParam)
setSearchPatternAction({ searchPattern })
setSdkLanguageAction({ sdkLanguage })
setTagFilterAction({
tagFilter: isValidFilter(location, verbParam)
? verbParam.toUpperCase()
: 'ALL',
})
}, [location.search])

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const SelectorContainer: FC<SelectorContainerProps> = ({
...spaceProps
}) => {
const searchParams = new URLSearchParams(location.search)
// TODO: noticing that there are certain pages where we must delete extra params
// before pushing its link, what's a way we can handle this?
Copy link
Contributor Author

@patnir41 patnir41 Aug 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this was not included here, navigating to the diffScene after selecting a filter will persist that parameter in the URL. I worry that there will be a growing number of scenes to render where certain parameters are no longer necessary and must be explicitly removed/nullified to avoid bloating the URL.

  • What if we include an optional toRemove parameter to the buildPath functions, which is a list of strings corresponding to search params which we will remove from the URL? This could clean up my current changes in SideNavMethods.tsx and SideNavTypes.tsx.

searchParams.delete('v')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we replace the link component with say a Span that behaves like a link and calls navigate on click?

return (
<Space width="auto" {...spaceProps}>
<SdkLanguageSelector />
Expand Down
4 changes: 2 additions & 2 deletions packages/api-explorer/src/components/SideNav/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ export const SideNav: FC<SideNavProps> = ({ headless = false, spec }) => {
if (parts[1] === 'diff') {
if (parts[3] !== tabNames[index]) {
parts[3] = tabNames[index]
navigate(parts.join('/'))
navigate(parts.join('/'), { v: null })
}
} else {
if (parts[2] !== tabNames[index]) {
parts[2] = tabNames[index]
navigate(parts.join('/'))
navigate(parts.join('/'), { v: null })
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,15 @@ export const SideNavMethods = styled(
{Object.values(methods).map((method) => (
<li key={method.name}>
<Link
to={`${buildMethodPath(
specKey,
tag,
method.name,
searchParams.toString()
)}`}
to={() => {
searchParams.delete('v')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relates to prior comment about removing parameters from the URL in a cleaner fashion, pointing out for reviewers

return buildMethodPath(
specKey,
tag,
method.name,
searchParams.toString()
)
}}
>
{highlightHTML(searchPattern, method.summary)}
</Link>
Expand Down
15 changes: 9 additions & 6 deletions packages/api-explorer/src/components/SideNav/SideNavTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,15 @@ export const SideNavTypes = styled(
{Object.values(types).map((type) => (
<li key={type.name}>
<Link
to={`${buildTypePath(
specKey,
tag,
type.name,
searchParams.toString()
)}`}
to={() => {
searchParams.delete('v')
return buildTypePath(
specKey,
tag,
type.name,
searchParams.toString()
)
}}
>
{highlightHTML(searchPattern, type.name)}
</Link>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,28 @@ import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import { api } from '../../test-data'
import { renderWithRouter } from '../../test-utils'
import { renderWithRouterAndReduxProvider } from '../../test-utils'
import { MethodTagScene } from './MethodTagScene'

const opBtnNames = /ALL|GET|POST|PUT|PATCH|DELETE/

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

describe('MethodTagScene', () => {
Element.prototype.scrollTo = jest.fn()

test('it renders operation buttons and all methods for a given method tag', () => {
renderWithRouter(
renderWithRouterAndReduxProvider(
<Route path="/:specKey/methods/:methodTag">
<MethodTagScene api={api} />
</Route>,
Expand All @@ -62,7 +74,7 @@ describe('MethodTagScene', () => {

test('it only renders operation buttons for operations that exist under that tag', () => {
/** ApiAuth contains two POST methods and a DELETE method */
renderWithRouter(
renderWithRouterAndReduxProvider(
<Route path="/:specKey/methods/:methodTag">
<MethodTagScene api={api} />
</Route>,
Expand All @@ -75,30 +87,29 @@ describe('MethodTagScene', () => {
).toHaveLength(3)
})

test('it filters methods by operation type', async () => {
renderWithRouter(
test('it pushes filter to URL on toggle', async () => {
renderWithRouterAndReduxProvider(
<Route path="/:specKey/methods/:methodTag">
<MethodTagScene api={api} />
</Route>,
['/3.1/methods/Look']
)
const allLookMethods = /^\/look.*/
expect(screen.getAllByText(allLookMethods)).toHaveLength(7)
/** Filter by GET operation */
userEvent.click(screen.getByRole('button', { name: 'GET' }))
await waitFor(() => {
expect(screen.getAllByText(allLookMethods)).toHaveLength(4)
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: location.pathname,
search: 'v=get',
})
})
/** Filter by DELETE operation */
userEvent.click(screen.getByRole('button', { name: 'DELETE' }))
await waitFor(() => {
// eslint-disable-next-line jest-dom/prefer-in-document
expect(screen.getAllByText(allLookMethods)).toHaveLength(1)
})
/** Restore original state */
userEvent.click(screen.getByRole('button', { name: 'ALL' }))
await waitFor(() => {
expect(screen.getAllByText(allLookMethods)).toHaveLength(7)
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: location.pathname,
search: 'v=delete',
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ import React, { useEffect, useState } from 'react'
import { useHistory, useParams } from 'react-router-dom'
import { Grid, ButtonToggle, ButtonItem } from '@looker/components'
import type { ApiModel } from '@looker/sdk-codegen'
import { useSelector } from 'react-redux'
import { ApixSection, DocTitle, DocMethodSummary, Link } from '../../components'
import { buildMethodPath, useNavigation } from '../../utils'
import { selectTagFilter } from '../../state'
import { getOperations } from './utils'

interface MethodTagSceneProps {
Expand All @@ -44,16 +46,22 @@ interface MethodTagSceneParams {
export const MethodTagScene: FC<MethodTagSceneProps> = ({ api }) => {
const { specKey, methodTag } = useParams<MethodTagSceneParams>()
const history = useHistory()
const methods = api.tags[methodTag]
const navigate = useNavigation()
const selectedTagFilter = useSelector(selectTagFilter)
const [tagFilter, setTagFilter] = useState(selectedTagFilter)
const searchParams = new URLSearchParams(location.search)
const [value, setValue] = useState('ALL')

const setValue = (filter: string) => {
navigate(location.pathname, {
v: filter === 'ALL' ? null : filter.toLowerCase(),
})
}

useEffect(() => {
/** Reset ButtonToggle value on route change */
setValue('ALL')
}, [methodTag])
setTagFilter(selectedTagFilter)
}, [selectedTagFilter])

const methods = api.tags[methodTag]
useEffect(() => {
if (!methods) {
navigate(`/${specKey}/methods`)
Expand All @@ -70,7 +78,12 @@ export const MethodTagScene: FC<MethodTagSceneProps> = ({ api }) => {
return (
<ApixSection>
<DocTitle>{`${tag.name}: ${tag.description}`}</DocTitle>
<ButtonToggle mb="small" mt="xlarge" value={value} onChange={setValue}>
<ButtonToggle
mb="small"
mt="xlarge"
value={tagFilter}
onChange={setValue}
>
<ButtonItem key="ALL" px="large" py="xsmall">
ALL
</ButtonItem>
Expand All @@ -82,15 +95,19 @@ export const MethodTagScene: FC<MethodTagSceneProps> = ({ api }) => {
</ButtonToggle>
{Object.values(methods).map(
(method, index) =>
(value === 'ALL' || value === method.httpMethod) && (
(selectedTagFilter === 'ALL' ||
selectedTagFilter === method.httpMethod) && (
<Link
key={index}
to={buildMethodPath(
specKey,
tag.name,
method.name,
searchParams.toString()
)}
to={() => {
searchParams.delete('v')
return buildMethodPath(
specKey,
tag.name,
method.name,
searchParams.toString()
)
}}
>
<Grid columns={1} py="xsmall">
<DocMethodSummary key={index} method={method} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ const opBtnNames = /ALL|SPECIFICATION|WRITE|REQUEST|ENUMERATED/

const path = '/:specKey/types/:typeTag'

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

describe('TypeTagScene', () => {
Element.prototype.scrollTo = jest.fn()

Expand Down Expand Up @@ -74,30 +86,28 @@ describe('TypeTagScene', () => {
).toHaveLength(2)
})

test('it filters methods by operation type', async () => {
test('it pushes filter to URL on toggle', async () => {
renderWithRouterAndReduxProvider(
<Route path={path}>
<TypeTagScene api={api} />
</Route>,
['/3.1/types/Look']
)
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(
Object.keys(api.typeTags.Look).length
)

expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(
Object.keys(api.typeTags.Look).length
)

/** Filter by SPECIFICATION */
userEvent.click(screen.getByRole('button', { name: 'SPECIFICATION' }))
await waitFor(() => {
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(5)
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: location.pathname,
search: 'v=specification',
})
})
/** Filter by REQUEST */
userEvent.click(screen.getByRole('button', { name: 'REQUEST' }))
await waitFor(() => {
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(2)
expect(mockHistoryPush).toHaveBeenCalledWith({
pathname: location.pathname,
search: 'v=request',
})
})
})
})
Loading