-
Notifications
You must be signed in to change notification settings - Fork 205
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add node search component (#193)
- Loading branch information
Showing
20 changed files
with
597 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} | ||
/> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); |
Oops, something went wrong.