Skip to content

Commit

Permalink
feat: reflect search in URL [#16287]
Browse files Browse the repository at this point in the history
  • Loading branch information
jh3y committed Jan 18, 2022
1 parent afdddc9 commit 171f659
Show file tree
Hide file tree
Showing 5 changed files with 129 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/api/src/modules/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface UI {
url?: string;
enableShortcuts: boolean;
docsMode: boolean;
filter?: string;
}

export interface SubState {
Expand Down
2 changes: 2 additions & 0 deletions lib/api/src/modules/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const initialUrlSupport = ({
selectedKind, // deprecated
selectedStory, // deprecated
path: queryPath,
filter,
...otherParams // the rest gets passed to the iframe
} = queryFromLocation(location);

Expand All @@ -65,6 +66,7 @@ const initialUrlSupport = ({
};
const ui: Partial<UI> = {
enableShortcuts: parseBoolean(shortcuts),
filter,
};
const selectedPanel = addonPanel || undefined;

Expand Down
98 changes: 98 additions & 0 deletions lib/ui/src/components/sidebar/Search.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, themes, convert } from '@storybook/theming';
import * as api from '@storybook/api';

import { FILTER_KEY } from './Search';
import * as Stories from './Search.stories';
import * as SidebarStories from './Sidebar.stories';

const TEST_URL = 'http://localhost';
const FILTER_VALUE = 'filter';
const PLACEHOLDER = 'Find components';
const DEFAULT_SEARCH = '?path=story';

const setLocation = (search = DEFAULT_SEARCH) => {
global.window.history.replaceState({}, 'Test', search);
};

const renderSearch = (Story = Stories.Simple) =>
render(
<ThemeProvider theme={convert(themes.light)}>
<Story />
</ThemeProvider>
);

jest.mock('@storybook/api');
const mockedApi = api as jest.Mocked<typeof api>;

beforeEach(() => {
const { history, location } = global.window;
delete global.window.location;
delete global.window.history;
global.window.location = { ...location };
global.window.history = { ...history };

global.window.history.replaceState = (state, title, url: string) => {
global.window.location.href = url;
global.window.location.search = url.indexOf('?') !== -1 ? url.slice(url.indexOf('?')) : '';
};

mockedApi.useStorybookState.mockReset();
});

describe('Search - reflect search in URL', () => {
it('renders OK', async () => {
setLocation();
renderSearch();
const INPUT = (await screen.getByPlaceholderText(PLACEHOLDER)) as HTMLInputElement;
expect(INPUT.value).toBe('');
});
it('prefills input with search params', async () => {
const state: Partial<api.State> = {
storyId: 'jest',
ui: { filter: 'filter', enableShortcuts: true, docsMode: false },
};
mockedApi.useStorybookState.mockReturnValue(state as any);
setLocation('?path=story&filter=filter');
renderSearch(SidebarStories.Simple);
const INPUT = (await screen.getByPlaceholderText(PLACEHOLDER)) as HTMLInputElement;
expect(INPUT.value).toBe(FILTER_VALUE);
});
it('updates location on input update with current query', async () => {
setLocation();
renderSearch();
const INPUT = await screen.getByPlaceholderText(PLACEHOLDER);
userEvent.clear(INPUT);
// Using "paste" due to bug with @testing-library/user-event || jest: https://github.com/testing-library/user-event/issues/369
userEvent.paste(INPUT, FILTER_VALUE);
expect(global.window.location.href).toBe(
`${TEST_URL}${DEFAULT_SEARCH}&${FILTER_KEY}=${FILTER_VALUE}`
);
expect(global.window.location.search).toBe(`${DEFAULT_SEARCH}&${FILTER_KEY}=${FILTER_VALUE}`);
});
it('updates location on input update without current query', async () => {
setLocation();
renderSearch();
const INPUT = await screen.getByPlaceholderText(PLACEHOLDER);
userEvent.clear(INPUT);
userEvent.paste(INPUT, FILTER_VALUE);
expect(global.window.location.href).toBe(
`${TEST_URL}${DEFAULT_SEARCH}&${FILTER_KEY}=${FILTER_VALUE}`
);
expect(global.window.location.search).toBe(`${DEFAULT_SEARCH}&${FILTER_KEY}=${FILTER_VALUE}`);
});
it('initialQuery updates URL', async () => {
const SEARCH_TERM = 'Search query';
const QUERY_VALUE = 'Search+query';
setLocation();
renderSearch(Stories.FilledIn);
const INPUT = (await screen.getByPlaceholderText(PLACEHOLDER)) as HTMLInputElement;
expect(INPUT.value).toBe(SEARCH_TERM);
expect(global.window.location.href).toBe(
`${TEST_URL}${DEFAULT_SEARCH}&${FILTER_KEY}=${QUERY_VALUE}`
);
expect(global.window.location.search).toBe(`${DEFAULT_SEARCH}&${FILTER_KEY}=${QUERY_VALUE}`);
});
});
23 changes: 23 additions & 0 deletions lib/ui/src/components/sidebar/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ import { searchItem } from './utils';
const { document } = global;

const DEFAULT_MAX_SEARCH_RESULTS = 50;
export const FILTER_KEY = 'filter';

let searchParams: URLSearchParams;
/**
* Used if we want to sync user input in the address bar.
*/
const syncUrlToFilter = (value: string) => {
if (!searchParams) searchParams = new URLSearchParams(global.window.location.search);
if (global.window.history.replaceState) {
if (value !== '') searchParams.set(FILTER_KEY, value);
else searchParams.delete(FILTER_KEY);
global.window.history.replaceState(
{},
'',
`${global.window.location.origin}${
searchParams.toString() !== '' ? '?' : ''
}${searchParams.toString()}`
);
}
};

const options = {
shouldSort: true,
Expand Down Expand Up @@ -286,6 +306,7 @@ export const Search = React.memo<{
return (
<Downshift<DownshiftItem>
initialInputValue={initialQuery}
initialIsOpen={initialQuery !== ''}
stateReducer={stateReducer}
// @ts-ignore
itemToString={(result) => result?.item?.name || ''}
Expand All @@ -306,6 +327,8 @@ export const Search = React.memo<{
const input = inputValue ? inputValue.trim() : '';
let results: DownshiftItem[] = input ? getResults(input) : [];

syncUrlToFilter(input);

const lastViewed = !input && getLastViewed();
if (lastViewed && lastViewed.length) {
results = lastViewed.reduce((acc, { storyId, refId }) => {
Expand Down
5 changes: 5 additions & 0 deletions lib/ui/src/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { FunctionComponent, useMemo } from 'react';
import { styled } from '@storybook/theming';
import { ScrollArea, Spaced } from '@storybook/components';
import type { StoriesHash, State } from '@storybook/api';
import { useStorybookState } from '@storybook/api';

import { Heading } from './Heading';

Expand Down Expand Up @@ -100,6 +101,7 @@ export const Sidebar: FunctionComponent<SidebarProps> = React.memo(
enableShortcuts = true,
refs = {},
}) => {
const state = useStorybookState();
const selected: Selection = useMemo(() => storyId && { storyId, refId }, [storyId, refId]);
const stories = useMemo(
() => (DOCS_MODE ? collapseAllStories : collapseDocsOnlyStories)(storiesHash),
Expand All @@ -109,6 +111,8 @@ export const Sidebar: FunctionComponent<SidebarProps> = React.memo(
const isLoading = !dataset.hash[DEFAULT_REF_ID].ready;
const lastViewedProps = useLastViewed(selected);

console.info(state, 'STATE');

return (
<Container className="container sidebar-container">
<CustomScrollArea vertical>
Expand All @@ -124,6 +128,7 @@ export const Sidebar: FunctionComponent<SidebarProps> = React.memo(
dataset={dataset}
isLoading={isLoading}
enableShortcuts={enableShortcuts}
initialQuery={state?.ui?.filter}
{...lastViewedProps}
>
{({
Expand Down

0 comments on commit 171f659

Please sign in to comment.