diff --git a/packages/api-explorer/e2e/diffScene.spec.ts b/packages/api-explorer/e2e/diffScene.spec.ts index fe6fa60b3..9f6b0180c 100644 --- a/packages/api-explorer/e2e/diffScene.spec.ts +++ b/packages/api-explorer/e2e/diffScene.spec.ts @@ -36,6 +36,7 @@ const resultCardsSelector = 'section#top div[class*=SpaceVertical] div[class*=Card]' const baseInputSelector = 'input#listbox-input-base' const compInputSelector = 'input#listbox-input-compare' +const compOptionsInputSelector = 'input#listbox-input-options' const globalOptionsSelector = '#modal-root [role=option] span' const switchButtonSelector = '.switch-button' @@ -225,7 +226,9 @@ describe('Diff Scene', () => { // Check the URL // Would like to do this earlier, but not sure what to wait on const compUrl = page.url() - expect(compUrl).toEqual(`${BASE_URL}/3.1/diff/4.0?sdk=py`) + expect(compUrl).toEqual( + `${BASE_URL}/3.1/diff/4.0?sdk=py&opts=missing%2Cparams%2Ctype%2Cbody%2Cresponse` + ) // Check the results const diffResultCards = await page.$$(resultCardsSelector) @@ -253,7 +256,9 @@ describe('Diff Scene', () => { await page.waitForTimeout(150) const switchUrl = page.url() - expect(switchUrl).toEqual(`${BASE_URL}/4.0/diff/3.1?sdk=py`) + expect(switchUrl).toEqual( + `${BASE_URL}/4.0/diff/3.1?sdk=py&opts=missing%2Cparams%2Ctype%2Cbody%2Cresponse` + ) // Check the results again, even though they should be the same const diff40to31Page1Methods = await Promise.all( @@ -265,4 +270,70 @@ describe('Diff Scene', () => { expect(diff40to31Page1Methods).toHaveLength(15) expect(diff40to31Page1Methods).toContain('delete_board_item') }) + + it('updates when a comparison option is toggled', async () => { + await goToPage(`${BASE_URL}/3.1/diff/4.0`) + + // expect default diff options in url + expect(page.url()).toEqual( + `${BASE_URL}/3.1/diff/4.0?sdk=py&opts=missing%2Cparams%2Ctype%2Cbody%2Cresponse` + ) + + // "Base" input element + const baseInputElement = await page.$(baseInputSelector) + expect(baseInputElement).not.toBeNull() + + // "Comparison" input element + const compInputElement = await page.$(compInputSelector) + expect(compInputElement).not.toBeNull() + + // "Comparison Options" input element + const compOptionsInputElement = await page.$(compOptionsInputSelector) + expect(compOptionsInputElement).not.toBeNull() + + // Check that initial results exist with default comparison options + const initDiffResults = await page.$$(resultCardsSelector) + expect(initDiffResults).not.toHaveLength(0) + const initDiff31to40Page1Methods = await Promise.all( + initDiffResults.map((resultCard) => + page.evaluate((el) => el.innerText.match(/^[a-z_]*/)[0], resultCard) + ) + ) + expect(initDiff31to40Page1Methods).toHaveLength(15) + expect(initDiff31to40Page1Methods).toContain('delete_alert') + + // Click comparison input + await compOptionsInputElement!.click() + const compOptionsOnClick = await page.$$(globalOptionsSelector) + expect(compOptionsOnClick).toHaveLength(6) + + // Find an option containing the text Missing + const missingOptionIndex = await page.$$eval(globalOptionsSelector, (els) => + els.findIndex((el) => el?.textContent?.match('Missing')) + ) + const missingOption = compOptionsOnClick[missingOptionIndex] + expect(missingOption).not.toBeUndefined() + + // Click that option + await missingOption.click() + await page.waitForSelector(resultCardsSelector, { timeout: 5000 }) + + // Check the URL + // Would like to do this earlier, but not sure what to wait on + const compUrl = page.url() + expect(compUrl).toEqual( + `${BASE_URL}/3.1/diff/4.0?sdk=py&opts=params%2Ctype%2Cbody%2Cresponse` + ) + + // Check that there are new results + const newDiffResults = await page.$$(resultCardsSelector) + expect(newDiffResults).not.toHaveLength(0) + const newDiff31to40Page1Methods = await Promise.all( + newDiffResults.map((resultCard) => + page.evaluate((el) => el.innerText.match(/^[a-z_]*/)[0], resultCard) + ) + ) + expect(newDiff31to40Page1Methods).toHaveLength(15) + expect(newDiff31to40Page1Methods).not.toContain('delete_alert') + }) }) diff --git a/packages/api-explorer/src/components/SideNav/SideNavMethods.spec.tsx b/packages/api-explorer/src/components/SideNav/SideNavMethods.spec.tsx index 508d6f457..c9518dd11 100644 --- a/packages/api-explorer/src/components/SideNav/SideNavMethods.spec.tsx +++ b/packages/api-explorer/src/components/SideNav/SideNavMethods.spec.tsx @@ -76,7 +76,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 @@ -98,7 +101,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() }) diff --git a/packages/api-explorer/src/components/SideNav/SideNavMethods.tsx b/packages/api-explorer/src/components/SideNav/SideNavMethods.tsx index 2db5244b3..03519e359 100644 --- a/packages/api-explorer/src/components/SideNav/SideNavMethods.tsx +++ b/packages/api-explorer/src/components/SideNav/SideNavMethods.tsx @@ -44,8 +44,7 @@ interface MethodsProps { export const SideNavMethods = styled( ({ className, methods, tag, specKey, defaultOpen = false }: MethodsProps) => { - const { buildPathWithGlobalParams, navigateWithGlobalParams } = - useNavigation() + const { buildPathWithGlobalParams, navigate } = useNavigation() const searchPattern = useSelector(selectSearchPattern) const match = useRouteMatch<{ methodTag: string }>( `/:specKey/methods/:methodTag/:methodName?` @@ -55,9 +54,9 @@ export const SideNavMethods = styled( const _isOpen = !isOpen setIsOpen(_isOpen) if (_isOpen) { - navigateWithGlobalParams(`/${specKey}/methods/${tag}`) + navigate(`/${specKey}/methods/${tag}`, { opts: null }) } else { - navigateWithGlobalParams(`/${specKey}/methods`) + navigate(`/${specKey}/methods`, { opts: null }) } } diff --git a/packages/api-explorer/src/components/SideNav/SideNavTypes.tsx b/packages/api-explorer/src/components/SideNav/SideNavTypes.tsx index 8f5dd0459..9928fa26f 100644 --- a/packages/api-explorer/src/components/SideNav/SideNavTypes.tsx +++ b/packages/api-explorer/src/components/SideNav/SideNavTypes.tsx @@ -54,9 +54,9 @@ export const SideNavTypes = styled( const _isOpen = !isOpen setIsOpen(_isOpen) if (_isOpen) { - navigate(`/${specKey}/types/${tag}`) + navigate(`/${specKey}/types/${tag}`, { opts: null }) } else { - navigate(`/${specKey}/types`) + navigate(`/${specKey}/types`, { opts: null }) } } diff --git a/packages/api-explorer/src/scenes/DiffScene/DiffScene.spec.tsx b/packages/api-explorer/src/scenes/DiffScene/DiffScene.spec.tsx new file mode 100644 index 000000000..d24ad1737 --- /dev/null +++ b/packages/api-explorer/src/scenes/DiffScene/DiffScene.spec.tsx @@ -0,0 +1,199 @@ +/* + + 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 React from 'react' +import { screen, waitFor, within } from '@testing-library/react' + +import type { SpecItem } from '@looker/sdk-codegen' +import userEvent from '@testing-library/user-event' +import { getLoadedSpecs } from '../../test-data' +import { + createTestStore, + renderWithRouterAndReduxProvider, +} from '../../test-utils' +import { getApixAdaptor } from '../../utils' +import { DiffScene } from './DiffScene' + +const mockHistoryPush = jest.fn() +jest.mock('react-router-dom', () => { + const location = { + pathname: '/3.1/diff', + search: '', + hash: '', + state: {}, + key: '', + } + const ReactRouter = jest.requireActual('react-router-dom') + return { + __esModule: true, + ...ReactRouter, + useHistory: () => ({ + push: mockHistoryPush, + location, + }), + useLocation: jest.fn().mockReturnValue(location), + } +}) + +const specs = getLoadedSpecs() +class MockApixAdaptor { + async fetchSpec(spec: SpecItem) { + return new Promise(() => specs[spec.key]) + } +} + +const mockApixAdaptor = new MockApixAdaptor() +jest.mock('../../utils/apixAdaptor', () => { + const apixAdaptor = jest.requireActual('../../utils/apixAdaptor') + return { + __esModule: true, + ...apixAdaptor, + getApixAdaptor: jest.fn(), + } +}) + +describe('DiffScene', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + ;(getApixAdaptor as jest.Mock).mockReturnValue(mockApixAdaptor) + Element.prototype.scrollTo = jest.fn() + Element.prototype.scrollIntoView = jest.fn() + + const toggleNavigation = () => false + + test('rendering with no url opts param results in default comparison options toggled', () => { + const store = createTestStore({ + specs: { specs, currentSpecKey: '3.1' }, + }) + renderWithRouterAndReduxProvider( + , + ['/3.1/diff'], + store + ) + + expect( + screen.getByRole('option', { + name: 'Missing Delete', + }) + ).toBeInTheDocument() + expect( + screen.getByRole('option', { + name: 'Parameters Delete', + }) + ).toBeInTheDocument() + expect( + screen.getByRole('option', { + name: 'Type Delete', + }) + ).toBeInTheDocument() + expect( + screen.getByRole('option', { + name: 'Body Delete', + }) + ).toBeInTheDocument() + expect( + screen.queryByRole('option', { + name: 'Status Delete', + }) + ).not.toBeInTheDocument() + expect( + screen.getByRole('option', { + name: 'Response Delete', + }) + ).toBeInTheDocument() + }) + + test('selecting a comparison option pushes it to url opts param', async () => { + const store = createTestStore({ + specs: { specs, currentSpecKey: '3.1' }, + settings: { diffOptions: [] }, + }) + renderWithRouterAndReduxProvider( + , + ['/3.1/diff'], + store + ) + userEvent.click(screen.getByPlaceholderText('Comparison options')) + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + userEvent.click( + screen.getByRole('option', { + name: 'Missing', + }) + ) + await waitFor(() => { + expect(mockHistoryPush).toHaveBeenLastCalledWith({ + pathname: '/3.1/diff', + search: 'opts=missing', + }) + }) + }) + + test('unselecting comparison option will remove it from url opts param', async () => { + const store = createTestStore({ + specs: { specs, currentSpecKey: '3.1' }, + settings: { diffOptions: ['missing', 'params'] }, + }) + renderWithRouterAndReduxProvider( + , + ['/3.1/diff'], + store + ) + const missingOption = screen.getByRole('option', { + name: 'Missing Delete', + }) + userEvent.click(within(missingOption).getByRole('button')) + await waitFor(() => { + expect(mockHistoryPush).toHaveBeenLastCalledWith({ + pathname: '/3.1/diff', + search: 'opts=params', + }) + }) + }) + + test('selecting clear option will remove all params from url opts param', async () => { + const store = createTestStore({ + specs: { specs, currentSpecKey: '3.1' }, + }) + renderWithRouterAndReduxProvider( + , + ['/3.1/diff'], + store + ) + userEvent.click( + screen.getByRole('button', { + name: 'Clear Field', + }) + ) + await waitFor(() => { + expect(mockHistoryPush).toHaveBeenLastCalledWith({ + pathname: '/3.1/diff', + search: '', + }) + }) + }) +}) diff --git a/packages/api-explorer/src/scenes/DiffScene/DiffScene.tsx b/packages/api-explorer/src/scenes/DiffScene/DiffScene.tsx index 196a22f72..524723e0e 100644 --- a/packages/api-explorer/src/scenes/DiffScene/DiffScene.tsx +++ b/packages/api-explorer/src/scenes/DiffScene/DiffScene.tsx @@ -27,7 +27,7 @@ import type { FC } from 'react' import React, { useState, useEffect } from 'react' import type { ApiModel, DiffRow, SpecList } from '@looker/sdk-codegen' -import { useRouteMatch } from 'react-router-dom' +import { useLocation, useRouteMatch } from 'react-router-dom' import { Box, Flex, @@ -41,9 +41,16 @@ import { SyncAlt } from '@styled-icons/material/SyncAlt' import { useSelector } from 'react-redux' import { ApixSection } from '../../components' -import { selectCurrentSpec, selectSpecs } from '../../state' +import { + selectCurrentSpec, + selectSpecs, + selectDiffOptions, + useSettingActions, + useSettingStoreState, +} from '../../state' import { diffPath, getApixAdaptor, useNavigation } from '../../utils' -import { diffSpecs, standardDiffToggles } from './diffUtils' +import { useDiffStoreSync } from '../utils' +import { diffSpecs, getValidDiffOptions } from './diffUtils' import { DocDiff } from './DocDiff' const diffToggles = [ @@ -84,6 +91,10 @@ const validateParam = (specs: SpecList, specKey = '') => { export const DiffScene: FC = ({ toggleNavigation }) => { const adaptor = getApixAdaptor() const { navigate } = useNavigation() + const location = useLocation() + const selectedDiffOptions = useSelector(selectDiffOptions) + const { initialized } = useSettingStoreState() + const { setDiffOptionsAction } = useSettingActions() const spec = useSelector(selectCurrentSpec) const specs = useSelector(selectSpecs) const currentSpecKey = spec.key @@ -102,7 +113,8 @@ export const DiffScene: FC = ({ toggleNavigation }) => { const [rightApi, setRightApi] = useState(() => rightKey ? specs[rightKey].api! : specs[leftKey].api! ) - const [toggles, setToggles] = useState(standardDiffToggles) + const [toggles, setToggles] = useState(selectedDiffOptions) + useDiffStoreSync() useEffect(() => { if (r !== rightKey) { @@ -151,9 +163,22 @@ export const DiffScene: FC = ({ toggleNavigation }) => { const handleTogglesChange = (values?: string[]) => { const newToggles = values || [] - setToggles(newToggles) + navigate(location.pathname, { opts: newToggles.join(',') }) } + useEffect(() => { + if (!initialized) return + const searchParams = new URLSearchParams(location.search) + const diffOptionsParam = getValidDiffOptions(searchParams.get('opts')) + setDiffOptionsAction({ + diffOptions: diffOptionsParam, + }) + }, [location.search]) + + useEffect(() => { + setToggles(selectedDiffOptions) + }, [selectedDiffOptions]) + return ( @@ -197,7 +222,7 @@ export const DiffScene: FC = ({ toggleNavigation }) => { id="options" name="toggles" placeholder="Comparison options" - defaultValues={toggles} + values={toggles} onChange={handleTogglesChange} options={diffToggles} /> diff --git a/packages/api-explorer/src/scenes/DiffScene/DocDiff/DiffItem.spec.tsx b/packages/api-explorer/src/scenes/DiffScene/DocDiff/DiffItem.spec.tsx index 01109034c..8ba670eaa 100644 --- a/packages/api-explorer/src/scenes/DiffScene/DocDiff/DiffItem.spec.tsx +++ b/packages/api-explorer/src/scenes/DiffScene/DocDiff/DiffItem.spec.tsx @@ -51,10 +51,9 @@ describe('DiffMethodLink', () => { const link = screen.getByRole('link') expect(link).toHaveTextContent(`${method.name} for ${specKey}`) fireEvent.click(link) - expect(pushSpy).toHaveBeenCalledWith({ - pathname: `/${specKey}/methods/${method.schema.tags[0]}/${method.name}`, - search: '', - }) + expect(pushSpy).toHaveBeenCalledWith( + `/${specKey}/methods/${method.schema.tags[0]}/${method.name}` + ) }) test('it renders missing method and does not navigate on click', () => { diff --git a/packages/api-explorer/src/scenes/DiffScene/DocDiff/DiffItem.tsx b/packages/api-explorer/src/scenes/DiffScene/DocDiff/DiffItem.tsx index be5536d26..7b6eb212e 100644 --- a/packages/api-explorer/src/scenes/DiffScene/DocDiff/DiffItem.tsx +++ b/packages/api-explorer/src/scenes/DiffScene/DocDiff/DiffItem.tsx @@ -59,7 +59,7 @@ export const DiffMethodLink: FC = ({ method, specKey, }) => { - const { navigate } = useNavigation() + const { navigateWithGlobalParams } = useNavigation() if (!method) return {`Missing in ${specKey}`} @@ -70,7 +70,7 @@ export const DiffMethodLink: FC = ({ { - navigate(path) + navigateWithGlobalParams(path) }} >{`${method.name} for ${specKey}`} ) diff --git a/packages/api-explorer/src/scenes/DiffScene/diffUtils.spec.ts b/packages/api-explorer/src/scenes/DiffScene/diffUtils.spec.ts index 9c305f3e3..0fd68e68f 100644 --- a/packages/api-explorer/src/scenes/DiffScene/diffUtils.spec.ts +++ b/packages/api-explorer/src/scenes/DiffScene/diffUtils.spec.ts @@ -27,7 +27,7 @@ import type { DiffRow } from '@looker/sdk-codegen' import { startCount } from '@looker/sdk-codegen' import { api, api40 } from '../../test-data' -import { diffToSpec } from './diffUtils' +import { diffToSpec, getValidDiffOptions } from './diffUtils' describe('diffUtils', () => { test('builds a psuedo spec from diff', () => { @@ -63,4 +63,35 @@ describe('diffUtils', () => { expect(Object.keys(spec.tags)).toEqual(['Query', 'Dashboard']) expect(Object.keys(spec.types)).toEqual([]) }) + + describe('getValidDiffOptions', () => { + test('returns null if provided null input', () => { + expect(getValidDiffOptions(null)).toHaveLength(0) + }) + + test('returns null if input contains no valid diffscene options', () => { + const testOptionsParam = 'INVALID,INVALID1,INVALID2' + expect(getValidDiffOptions(testOptionsParam)).toHaveLength(0) + }) + + test('omits invalid diffScene options given input with valid options', () => { + const testOptionsParam = 'INVALID,missing,INVALID,type,INVALID' + expect(getValidDiffOptions(testOptionsParam)).toEqual(['missing', 'type']) + }) + + test('omits duplicate diffScene options from input', () => { + const testOptionsParam = 'missing,missing,type,type,type' + expect(getValidDiffOptions(testOptionsParam)).toEqual(['missing', 'type']) + }) + + test('disregards case sensitivity of options', () => { + const testOptionsParam = 'mIssInG,tYpE,PARAMS,boDy' + expect(getValidDiffOptions(testOptionsParam)).toEqual([ + 'missing', + 'type', + 'params', + 'body', + ]) + }) + }) }) diff --git a/packages/api-explorer/src/scenes/DiffScene/diffUtils.ts b/packages/api-explorer/src/scenes/DiffScene/diffUtils.ts index d4bba547a..4113f4a28 100644 --- a/packages/api-explorer/src/scenes/DiffScene/diffUtils.ts +++ b/packages/api-explorer/src/scenes/DiffScene/diffUtils.ts @@ -118,3 +118,19 @@ export const diffToSpec = ( result.types = {} return result } + +/** + * Returns all valid diff options contained in list + * @param opts url diff options parameter value + */ +export const getValidDiffOptions = (opts: string | null) => { + if (!opts) return [] + const diffOptions: string[] = [] + for (const option of opts.split(',')) { + const op = option.toLowerCase() + if (allDiffToggles.includes(op) && !diffOptions.includes(op)) { + diffOptions.push(option.toLowerCase()) + } + } + return diffOptions +} diff --git a/packages/api-explorer/src/scenes/utils/hooks/diffStoreSync.spec.ts b/packages/api-explorer/src/scenes/utils/hooks/diffStoreSync.spec.ts new file mode 100644 index 000000000..93d905ea4 --- /dev/null +++ b/packages/api-explorer/src/scenes/utils/hooks/diffStoreSync.spec.ts @@ -0,0 +1,129 @@ +/* + + MIT License + + Copyright (c) 2022 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 { renderHook } from '@testing-library/react-hooks' +import { useHistory } from 'react-router-dom' +import type { Location } from 'history' +import * as reactRedux from 'react-redux' +import * as routerLocation from 'react-router-dom' +import { createTestStore, withReduxProvider } from '../../../test-utils' +import { useDiffStoreSync } from './diffStoreSync' + +jest.mock('react-router', () => { + const ReactRouterDom = jest.requireActual('react-router-dom') + return { + ...ReactRouterDom, + useHistory: jest.fn().mockReturnValue({ push: jest.fn(), location }), + useLocation: jest.fn().mockReturnValue({ pathname: '/', search: '' }), + } +}) + +describe('useDiffStoreSync', () => { + const mockDispatch = jest.fn() + + afterEach(() => { + jest.clearAllMocks() + }) + + test('does nothing if uninitialized', () => { + const { push } = useHistory() + const wrapper = ({ children }: any) => withReduxProvider(children) + renderHook(() => useDiffStoreSync(), { wrapper }) + expect(push).not.toHaveBeenCalled() + }) + + describe('diff scene options parameter', () => { + test('overrides store diff options given valid url diff options param', () => { + const { push } = useHistory() + const store = createTestStore({ + settings: { + initialized: true, + }, + }) + const testOptions = ['missing', 'params', 'type'] + jest.spyOn(routerLocation, 'useLocation').mockReturnValue({ + pathname: `/`, + search: `opts=${testOptions.join(',')}`, + } as unknown as Location) + jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(mockDispatch) + const wrapper = ({ children }: any) => withReduxProvider(children, store) + renderHook(() => useDiffStoreSync(), { wrapper }) + expect(push).toHaveBeenCalledWith({ + pathname: '/', + search: 'opts=missing%2Cparams%2Ctype', + }) + expect(mockDispatch).toHaveBeenLastCalledWith({ + payload: { diffOptions: testOptions }, + type: 'settings/setDiffOptionsAction', + }) + }) + + test('updates url with store diff options given invalid url diff options param', () => { + const { push } = useHistory() + const store = createTestStore({ + settings: { + initialized: true, + }, + }) + jest.spyOn(routerLocation, 'useLocation').mockReturnValue({ + pathname: '/', + search: 'opts=invalid', + } as unknown as Location) + jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(mockDispatch) + const wrapper = ({ children }: any) => withReduxProvider(children, store) + renderHook(() => useDiffStoreSync(), { wrapper }) + expect(push).toHaveBeenCalledWith({ + pathname: `/`, + search: 'opts=missing%2Cparams%2Ctype%2Cbody%2Cresponse', + }) + }) + + test('filters invalid options out of url options parameter and updates url during sync', () => { + const { push } = useHistory() + const store = createTestStore({ + settings: { + initialized: true, + }, + }) + const testOptions = ['missing', 'INVALID_OPTION', 'type'] + jest.spyOn(routerLocation, 'useLocation').mockReturnValue({ + pathname: `/`, + search: `opts=${testOptions.join(',')}`, + } as unknown as Location) + jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(mockDispatch) + const wrapper = ({ children }: any) => withReduxProvider(children, store) + renderHook(() => useDiffStoreSync(), { wrapper }) + const expectedOptions = ['missing', 'type'] + expect(mockDispatch).toHaveBeenLastCalledWith({ + payload: { diffOptions: expectedOptions }, + type: 'settings/setDiffOptionsAction', + }) + expect(push).toHaveBeenCalledWith({ + pathname: `/`, + search: 'opts=missing%2Ctype', + }) + }) + }) +}) diff --git a/packages/api-explorer/src/scenes/utils/hooks/diffStoreSync.ts b/packages/api-explorer/src/scenes/utils/hooks/diffStoreSync.ts new file mode 100644 index 000000000..b37629303 --- /dev/null +++ b/packages/api-explorer/src/scenes/utils/hooks/diffStoreSync.ts @@ -0,0 +1,66 @@ +/* + + MIT License + + Copyright (c) 2022 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 { useLocation } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { useEffect } from 'react' +import { + selectDiffOptions, + useSettingActions, + useSettingStoreState, +} from '../../../state' +import { useNavigation } from '../../../utils' +import { getValidDiffOptions } from '../../DiffScene/diffUtils' + +/** + * Hook for syncing diff scene URL params with the Redux store + * + * Diff scene specific search parameters: 'opts' + */ +export const useDiffStoreSync = () => { + const location = useLocation() + const { navigate } = useNavigation() + const { setDiffOptionsAction } = useSettingActions() + const { initialized } = useSettingStoreState() + const selectedDiffOptions = useSelector(selectDiffOptions) + + useEffect(() => { + if (initialized) { + const params = new URLSearchParams(location.search) + // sync store with url opts param if valid + const diffOptionsParam = getValidDiffOptions(params.get('opts')) + if (diffOptionsParam.length) { + setDiffOptionsAction({ diffOptions: diffOptionsParam }) + // update url to reflect valid param entries + navigate(location.pathname, { opts: diffOptionsParam.join(',') }) + } else { + // sync url opts param with store + navigate(location.pathname, { + opts: selectedDiffOptions ? selectedDiffOptions.join(',') : null, + }) + } + } + }, [initialized]) +} diff --git a/packages/api-explorer/src/scenes/utils/hooks/index.ts b/packages/api-explorer/src/scenes/utils/hooks/index.ts index 760873280..b4451f197 100644 --- a/packages/api-explorer/src/scenes/utils/hooks/index.ts +++ b/packages/api-explorer/src/scenes/utils/hooks/index.ts @@ -25,3 +25,4 @@ */ export { useTagStoreSync } from './tagStoreSync' +export { useDiffStoreSync } from './diffStoreSync' diff --git a/packages/api-explorer/src/scenes/utils/index.ts b/packages/api-explorer/src/scenes/utils/index.ts index 409d04295..22f453fd4 100644 --- a/packages/api-explorer/src/scenes/utils/index.ts +++ b/packages/api-explorer/src/scenes/utils/index.ts @@ -24,4 +24,4 @@ */ -export { useTagStoreSync } from './hooks' +export { useTagStoreSync, useDiffStoreSync } from './hooks' diff --git a/packages/api-explorer/src/state/settings/selectors.spec.ts b/packages/api-explorer/src/state/settings/selectors.spec.ts index 762ddce04..9dee34ff3 100644 --- a/packages/api-explorer/src/state/settings/selectors.spec.ts +++ b/packages/api-explorer/src/state/settings/selectors.spec.ts @@ -24,13 +24,24 @@ */ import { createTestStore, preloadedState } from '../../test-utils' -import { selectSdkLanguage, isInitialized, selectTagFilter } from './selectors' +import { + selectSdkLanguage, + isInitialized, + selectTagFilter, + selectDiffOptions, +} from './selectors' const testStore = createTestStore() describe('Settings selectors', () => { const state = testStore.getState() + test('selectDiffOptions selects', () => { + expect(selectDiffOptions(state)).toEqual( + preloadedState.settings.diffOptions + ) + }) + test('selectSdkLanguage selects', () => { expect(selectSdkLanguage(state)).toEqual( preloadedState.settings.sdkLanguage diff --git a/packages/api-explorer/src/state/settings/selectors.ts b/packages/api-explorer/src/state/settings/selectors.ts index fdc62d786..8d0440cb0 100644 --- a/packages/api-explorer/src/state/settings/selectors.ts +++ b/packages/api-explorer/src/state/settings/selectors.ts @@ -27,6 +27,9 @@ import type { RootState } from '../store' const selectSettingsState = (state: RootState) => state.settings +export const selectDiffOptions = (state: RootState) => + selectSettingsState(state).diffOptions + export const selectSdkLanguage = (state: RootState) => selectSettingsState(state).sdkLanguage diff --git a/packages/api-explorer/src/state/settings/slice.ts b/packages/api-explorer/src/state/settings/slice.ts index a5799e4c0..c0f93b1cb 100644 --- a/packages/api-explorer/src/state/settings/slice.ts +++ b/packages/api-explorer/src/state/settings/slice.ts @@ -36,6 +36,7 @@ export interface UserDefinedSettings { } export interface SettingState extends UserDefinedSettings { + diffOptions: string[] searchPattern: string searchCriteria: SearchCriterionTerm[] tagFilter: string @@ -44,6 +45,7 @@ export interface SettingState extends UserDefinedSettings { } export const defaultSettings = { + diffOptions: ['missing', 'params', 'type', 'body', 'response'], sdkLanguage: 'Python', searchPattern: '', searchCriteria: setToCriteria(SearchAll) as SearchCriterionTerm[], @@ -55,6 +57,7 @@ export const defaultSettingsState: SettingState = { initialized: false, } +type SetDiffOptionsAction = Pick type SetSearchPatternAction = Pick type SetSdkLanguageAction = Pick type SetTagFilterAction = Pick @@ -78,6 +81,9 @@ export const settingsSlice = createSlice({ state.error = action.payload state.initialized = false }, + setDiffOptionsAction(state, action: PayloadAction) { + state.diffOptions = action.payload.diffOptions + }, setSdkLanguageAction(state, action: PayloadAction) { state.sdkLanguage = action.payload.sdkLanguage }, diff --git a/packages/api-explorer/src/utils/hooks/navigation.ts b/packages/api-explorer/src/utils/hooks/navigation.ts index c0d80fe3f..ff34a36d5 100644 --- a/packages/api-explorer/src/utils/hooks/navigation.ts +++ b/packages/api-explorer/src/utils/hooks/navigation.ts @@ -34,6 +34,8 @@ interface QueryParamProps { sdk?: string | null /** Tag Scene Filter **/ t?: string | null + /** Diff Scene Options **/ + opts?: string | null } /**