Skip to content

Commit

Permalink
feat: add node search component (#193)
Browse files Browse the repository at this point in the history
  • Loading branch information
ssspear committed Jan 30, 2020
1 parent 2268610 commit 390aaeb
Show file tree
Hide file tree
Showing 20 changed files with 597 additions and 16 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"openapi-sampler": "^1.0.0-beta.15",
"react-error-boundary": "^1.2.5",
"react-graph-vis": "^1.0.5",
"swr": "^0.1.16",
"unist-util-select": "^2.0.2",
"urijs": "^1.19.2"
},
Expand Down
33 changes: 33 additions & 0 deletions src/__stories__/components/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { action } from '@storybook/addon-actions';
import { withKnobs } from '@storybook/addon-knobs';
import { boolean, object, text } from '@storybook/addon-knobs/react';
import { storiesOf } from '@storybook/react';
import * as React from 'react';

import { ISearchComponent, Search } from '../../components/Search/';
import { Provider } from '../../containers/Provider';
import { providerKnobs } from '../containers/Provider';

const data = require('../../__fixtures__/table-of-contents/studio');

export const searchKnobs = (): ISearchComponent => ({
query: text('query', ''),
nodes: object('nodes', data.nodes, 'Nodes'),
isLoading: boolean('loadSearch', false),
isOpen: boolean('openSearch', true),
onChange: action('onChange'),
onClose: action('onClose'),
onReset: action('onReset'),
});

storiesOf('components/Search', module)
.addDecorator(withKnobs)
.add('Search', () => {
return (
<div>
<Provider {...providerKnobs()}>
<Search {...searchKnobs()} />
</Provider>
</div>
);
});
2 changes: 1 addition & 1 deletion src/__stories__/containers/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const providerKnobs = (): IProvider => ({
components: object('components', {
link: ({ node, children }, key) => {
return (
<a key={key} href={node.url}>
<a key={key} className={node.className} href={node.url}>
{children}
</a>
);
Expand Down
32 changes: 32 additions & 0 deletions src/__stories__/containers/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { action } from '@storybook/addon-actions';
import { withKnobs } from '@storybook/addon-knobs';
import { boolean, text } from '@storybook/addon-knobs/react';
import { storiesOf } from '@storybook/react';
import cn from 'classnames';
import * as React from 'react';

import { Provider } from '../../containers/Provider';
import { ISearchContainer, Search } from '../../containers/Search';
import { providerKnobs } from './Provider';

export const darkMode = () => boolean('dark mode', false);

export const searchKnobs = (): ISearchContainer => ({
srn: text('srn', 'gh/stoplightio/studio-demo'),
group: text('group', 'master'),
isOpen: boolean('isOpen', true),
onClose: action('onClose'),
});

storiesOf('containers/Search', module)
.addDecorator(withKnobs)
.add('Search', () => (
<div
className={cn('px-12 pt-12 absolute bottom-0 left-0 right-0 top-0', { 'bp3-dark bg-gray-8': darkMode() })}
style={{ width: 400 }}
>
<Provider {...providerKnobs()}>
<Search {...searchKnobs()} />
</Provider>
</div>
));
2 changes: 2 additions & 0 deletions src/__stories__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import './components/Dependencies';
import './components/HttpRequest';
import './components/Page';
import './components/PageHeadings';
import './components/Search';
import './components/TableOfContents';
import './components/TryIt';

import './containers/Hub';
import './containers/Page';
import './containers/Search';
import './containers/TableOfContents';
180 changes: 180 additions & 0 deletions src/components/Search/List.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { deserializeSrn } from '@stoplight/path';
import { Button, Callout, Classes, Icon, NonIdealState, Spinner, Tag } from '@stoplight/ui-kit';
import { ScrollContainer } from '@stoplight/ui-kit/ScrollContainer';
import cn from 'classnames';
import * as React from 'react';
import { useComponents } from '../../hooks';
import { IProjectNode } from '../../types';
import { NodeTypeColors, NodeTypeIcons, NodeTypePrettyName } from '../../utils/node';

export const NodeList: React.FC<{
nodes?: IProjectNode[];
error?: Error;
isLoading?: boolean;
onReset?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
onClose?: () => void;
}> = ({ isLoading, error, nodes, onReset, onClose }) => {
if (error) {
return (
<NonIdealState
title="An error has occured!"
description="Try refreshing the page. If the error persists, please reach out to us at support@stoplight.io."
icon="error"
action={
<Button
text="Reload the Page"
onClick={() => {
window.location.reload();
}}
/>
}
/>
);
}

if (!nodes || !nodes.length) {
if (!nodes && isLoading) {
return <Spinner className="mt-32" />;
} else {
return (
<NonIdealState
title="No Results"
description="Try tweaking your filters or search term."
icon="zoom-out"
action={<Button text="Clear Search & Filters" onClick={onReset} />}
/>
);
}
}

return (
<ScrollContainer className="NodeList">
{nodes.map((item, i) => (
<NodeListItem key={i} item={item} isLoading={isLoading} onClose={onClose} onReset={onReset} />
))}

{isLoading && (
<div className="mt-2 mb-8">
<Spinner className="mt-2" />
</div>
)}
</ScrollContainer>
);
};

const NodeListItem: React.FC<{
item: IProjectNode;
isLoading?: boolean;
onReset?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
onClose?: () => void;
}> = ({ isLoading, item, onReset, onClose }) => {
const components = useComponents();
const { orgSlug, projectSlug } = deserializeSrn(item.srn);
const onClick = React.useCallback(
e => {
if (onReset) {
onReset(e);
}

if (onClose) {
onClose();
}
},
[onClose, onReset],
);

let dataContext = null;
if (item.data && item.data.match('<em>')) {
dataContext = (
<Callout
style={{ maxHeight: 150 }}
className={cn('mt-4 -mb-1 -mx-1 overflow-auto', {
[Classes.SKELETON]: isLoading,
})}
>
<HighlightSearchContext markup={item.data} />
</Callout>
);
}

const children: any = (
<div
key="1"
className="NodeList__item flex px-6 py-8 border-b cursor-pointer dark:border-lighten-4 hover:bg-gray-1 dark-hover:bg-lighten-3"
onClick={onClick}
>
<div className="mr-4">
<Tag
icon={NodeTypeIcons[item.type] && <Icon icon={NodeTypeIcons[item.type]} iconSize={11} />}
style={{ backgroundColor: NodeTypeColors[item.type] || undefined }}
title={NodeTypePrettyName[item.type] || item.type}
className="py-1 dark:text-white"
/>
</div>

<div className="flex-1">
<div className="flex items-center">
<div
className={cn(Classes.HEADING, 'inline-block flex items-center m-0', {
[Classes.SKELETON]: isLoading,
})}
>
<HighlightSearchContext markup={item.name || 'No Name...'} />
</div>

<div className="flex-1" />

<div
className={cn(Classes.TEXT_MUTED, 'flex text-sm', {
[Classes.SKELETON]: isLoading,
})}
>
<div>{orgSlug}</div>
<div className="px-1">/</div>
<div>{projectSlug}</div>
</div>
</div>

{item.summary && (
<div className="flex">
<div
className={cn('flex-1 mt-2', {
[Classes.SKELETON]: isLoading,
})}
>
<HighlightSearchContext markup={item.summary} />
</div>
</div>
)}
{dataContext}
</div>
</div>
);

if (components.link) {
return components.link(
{
node: {
className: 'reset',
url: item.srn,
},
children,
defaultComponents: components,
parent: {},
path: [],
},
item.id,
);
}

return children;
};

const HighlightSearchContext: React.FC<{ markup: string; className?: string }> = ({ markup, className }) => {
return (
<div
className={cn('Search__highlight whitespace-pre-wrap', className)}
dangerouslySetInnerHTML={{ __html: markup }}
/>
);
};
35 changes: 35 additions & 0 deletions src/components/Search/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Button, FormInput } from '@stoplight/ui-kit';
import * as React from 'react';

interface ISearchBar {
placeholder?: string;
query?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onReset?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
onClose?: () => void;
}

export const SearchBar: React.FunctionComponent<ISearchBar> = ({
placeholder = 'What are you looking for?',
query,
onChange,
onReset,
onClose,
}) => {
return (
<div className="Search__bar flex items-center h-20 px-3 py-6 border-b dark:border-lighten-4">
<FormInput
className="flex-1 mr-3 Search__input"
large
autoFocus
leftIcon="search"
placeholder={placeholder}
value={query}
onChange={onChange}
rightElement={query ? <Button minimal icon="cross" onClick={onReset} /> : undefined}
/>

<Button className="Search__button" icon="arrow-right" minimal onClick={onClose} />
</div>
);
};
83 changes: 83 additions & 0 deletions src/components/Search/__tests__/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { NodeType } from '@stoplight/types';
import { FormInput, InputGroup } from '@stoplight/ui-kit';
import { mount, ReactWrapper } from 'enzyme';
import 'jest-enzyme';
import * as React from 'react';
import { IProjectNode } from '../../../types';
import { Search } from '../index';
import { NodeList } from '../List';
import { SearchBar } from '../SearchBar';

jest.mock('@stoplight/ui-kit/ScrollContainer', () => ({
__esModule: true,
ScrollContainer: (props: any) => props.children,
}));

jest.mock('../../../hooks/useComponents', () => ({
__esModule: true,
useComponents: () => ({ link: ({ node, children }: any, id: any) => <a href={node.url}>{children}</a> }),
}));

describe('Search', () => {
let wrapper: ReactWrapper;

afterEach(() => {
wrapper.unmount();
});

const nodes: IProjectNode[] = [
{
id: 'gzv028oc',
type: NodeType.Article,
name: 'UI Overview',
srn: 'gh/stoplightio/studio/docs/ui-overview.md',
},
{
id: 'ks8cwyvs',
type: NodeType.Article,
name: 'Modeling Introduction',
srn: 'gh/stoplightio/studio/docs/designing-apis/10-getting-started.md',
},
{
id: 'vxeympcn',
type: NodeType.Article,
name: 'Project Structure (Design & Modeling)',
srn: 'gh/stoplightio/studio/docs/designing-apis/directory-structure.md',
summary: 'Project Structure summary Node',
},
];

it('should execute onChange function', () => {
const onChange = jest.fn();
wrapper = mount(<Search query={'test'} nodes={nodes} isOpen={true} isLoading={false} onChange={onChange} />);

wrapper
.find(SearchBar)
.find(FormInput)
.find(InputGroup)
.find('.bp3-input')
.simulate('change', { target: { value: 'test' } });

expect(onChange).toHaveBeenCalled();
});

it('should render highlighted text with summary', () => {
wrapper = mount(<NodeList nodes={nodes} />);

expect(wrapper.html()).toContain(
'<div class="flex-1 mt-2"><div class="Search__highlight whitespace-pre-wrap">Project Structure summary Node</div></div>',
);
});

it('should not render highlighted text without summary', () => {
wrapper = mount(<NodeList nodes={[nodes[0]]} />);

expect(wrapper.html()).not.toContain('<div class="flex-1 mt-2"><div class="Search_highlight whitespace-pre-wrap">');
});

it('should render loading component when loading nodes', () => {
wrapper = mount(<NodeList isLoading />);

expect(wrapper.html()).toContain('spinner');
});
});
Loading

0 comments on commit 390aaeb

Please sign in to comment.