Skip to content

Commit

Permalink
feat: basis search
Browse files Browse the repository at this point in the history
  • Loading branch information
RomanHotsiy committed Feb 8, 2018
1 parent 71e189f commit 6990cd2
Show file tree
Hide file tree
Showing 12 changed files with 296 additions and 10 deletions.
2 changes: 1 addition & 1 deletion demo/playground/hmr-playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const swagger = window.location.search.indexOf('swagger') > -1; // compatibility
const specUrl = swagger ? 'swagger.yaml' : big ? 'big-openapi.json' : 'openapi.yaml';

let store;
const options: RedocRawOptions = { nativeScrollbars: true };
const options: RedocRawOptions = { nativeScrollbars: false };

async function init() {
const spec = await loadAndBundleSpec(specUrl);
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@types/jest": "^22.1.0",
"@types/json-pointer": "^1.0.30",
"@types/lodash": "^4.14.98",
"@types/lunr": "^2.1.5",
"@types/prismjs": "^1.6.4",
"@types/prop-types": "^15.5.2",
"@types/react": "^16.0.30",
Expand Down Expand Up @@ -73,6 +74,7 @@
"webpack": "^3.10.0",
"webpack-dev-server": "^2.9.5",
"webpack-node-externals": "^1.6.0",
"workerize-loader": "^1.0.1",
"yaml-js": "^0.2.3"
},
"peerDependencies": {
Expand All @@ -85,6 +87,7 @@
"eventemitter3": "^3.0.0",
"json-pointer": "^0.6.0",
"json-schema-ref-parser": "^4.0.4",
"lunr": "^2.1.5",
"mobx": "^3.3.0",
"mobx-react": "^4.3.3",
"openapi-sampler": "1.0.0-beta.8",
Expand Down
4 changes: 2 additions & 2 deletions src/components/Fields/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { FieldDetails } from './FieldDetails';
import { ClickablePropertyNameCell, RequiredLabel } from '../../common-elements/fields';

import {
InnerPropertiesWrap,
PropertyBullet,
PropertyCellWithInner,
PropertyDetailsCell,
PropertyNameCell,
InnerPropertiesWrap,
PropertyCellWithInner,
} from '../../common-elements/fields-layout';

import { ShelfIcon } from '../../common-elements/';
Expand Down
9 changes: 8 additions & 1 deletion src/components/Redoc/Redoc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { SideMenu } from '../SideMenu/SideMenu';
import { StickyResponsiveSidebar } from '../StickySidebar/StickyResponsiveSidebar';
import { ApiContent, RedocWrap } from './elements';

import { SearchBox } from '../SearchBox/SearchBox';

export interface RedocProps {
store: AppStore;
}
Expand All @@ -30,14 +32,19 @@ export class Redoc extends React.Component<RedocProps> {
}

render() {
const { store: { spec, menu, options } } = this.props;
const { store: { spec, menu, options, search } } = this.props;
const store = this.props.store;
return (
<ThemeProvider theme={options.theme}>
<OptionsProvider options={options}>
<RedocWrap className="redoc-wrap">
<StickyResponsiveSidebar menu={menu} className="menu-content">
<ApiLogo info={spec.info} />
<SearchBox
search={search}
getItemById={menu.getItemById}
onActivate={menu.activateAndScroll}
/>
<SideMenu menu={menu} />
</StickyResponsiveSidebar>
<ApiContent className="api-content">
Expand Down
148 changes: 148 additions & 0 deletions src/components/SearchBox/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import * as React from 'react';

import styled from '../../styled-components';

import { IMenuItem } from '../../services/MenuStore';
import { SearchStore } from '../../services/SearchStore';
import { MenuItem } from '../SideMenu/MenuItem';
import { MenuItemLabel } from '../SideMenu/styled.elements';

const SearchInput = styled.input`
width: calc(100% - ${props => props.theme.spacingUnit * 2}px);
box-sizing: border-box;
margin: 0 ${props => props.theme.spacingUnit}px;
padding: 5px 0 5px ${props => props.theme.spacingUnit}px;
border: 0;
border-bottom: 1px solid #e1e1e1;
font-weight: bold;
font-size: 13px;
color: ${props => props.theme.colors.text};
background-color: transparent;
outline: none;
`;

const SearchIcon = styled((props: any) => (
<svg
className={props.className}
version="1.1"
viewBox="0 0 1000 1000"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px"
>
<path d="M968.2,849.4L667.3,549c83.9-136.5,66.7-317.4-51.7-435.6C477.1-25,252.5-25,113.9,113.4c-138.5,138.3-138.5,362.6,0,501C219.2,730.1,413.2,743,547.6,666.5l301.9,301.4c43.6,43.6,76.9,14.9,104.2-12.4C981,928.3,1011.8,893,968.2,849.4z M524.5,522c-88.9,88.7-233,88.7-321.8,0c-88.9-88.7-88.9-232.6,0-321.3c88.9-88.7,233-88.7,321.8,0C613.4,289.4,613.4,433.3,524.5,522z" />
</svg>
))`
position: absolute;
left: ${props => props.theme.spacingUnit}px;
height: 1.8em;
width: 0.9em;
path {
fill: ${props => props.theme.colors.text};
}
`;

const SearchResultsBox = styled.div`
padding: ${props => props.theme.spacingUnit / 4}px 0;
background-color: #ededed;
min-height: 150px;
max-height: 250px;
border-top: 1px solid #e1e1e1;
border-bottom: 1px solid #e1e1e1;
margin-top: 10px;
line-height: 1.4;
font-size: 0.9em;
overflow: auto;
${MenuItemLabel} {
padding-top: 6px;
padding-bottom: 6px;
&:hover {
background-color: #e1e1e1;
}
> svg {
display: none;
}
}
`;

export interface SearchBoxProps {
search: SearchStore;
getItemById: (id: string) => IMenuItem | undefined;
onActivate: (item: IMenuItem) => void;
}

export interface SearchBoxState {
results: any;
term: string;
}

export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxState> {
constructor(props) {
super(props);
this.state = {
results: [],
term: '',
};
}

search = (event: React.ChangeEvent<HTMLInputElement>) => {
const q = event.target.value;
if (q.length < 3) {
this.setState({
term: q,
results: [],
});
return;
}
this.setState({
term: q,
});

this.props.search.search(event.target.value).then(res => {
this.setState({
results: res,
});
});
};

clearIfEsq = event => {
if (event && event.keyCode === 27) {
// escape
this.setState({ term: '', results: [] });
}
};

render() {
const items: IMenuItem[] = this.state.results.map(res => this.props.getItemById(res.id));
items.sort((a, b) => (a.depth > b.depth ? 1 : a.depth < b.depth ? -1 : 0));

return (
<div>
<SearchIcon />
<SearchInput
value={this.state.term}
onKeyDown={this.clearIfEsq}
placeholder="Search..."
type="text"
onChange={this.search}
/>
{items.length > 0 && (
<SearchResultsBox>
{items.map(item => (
<MenuItem
item={item}
onActivate={this.props.onActivate}
withoutChildren={true}
key={item.id}
/>
))}
</SearchResultsBox>
)}
</div>
);
}
}
11 changes: 7 additions & 4 deletions src/components/SideMenu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MenuItemLabel, MenuItemLi, MenuItemTitle, OperationBadge } from './styl
interface MenuItemProps {
item: IMenuItem;
onActivate?: (item: IMenuItem) => void;
withoutChildren?: boolean;
}

@observer
Expand All @@ -19,7 +20,7 @@ export class MenuItem extends React.Component<MenuItemProps> {
};

render() {
const { item } = this.props;
const { item, withoutChildren } = this.props;
return (
<MenuItemLi onClick={this.activate} depth={item.depth}>
{item.type === 'operation' ? (
Expand All @@ -34,9 +35,11 @@ export class MenuItem extends React.Component<MenuItemProps> {
null}
</MenuItemLabel>
)}
{item.items.length > 0 && (
<MenuItems active={item.active} items={item.items} onActivate={this.props.onActivate} />
)}
{!withoutChildren &&
item.items &&
item.items.length > 0 && (
<MenuItems active={item.active} items={item.items} onActivate={this.props.onActivate} />
)}
</MenuItemLi>
);
}
Expand Down
4 changes: 4 additions & 0 deletions src/services/AppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MenuStore } from './MenuStore';
import { SpecStore } from './models';
import { RedocNormalizedOptions, RedocRawOptions } from './RedocNormalizedOptions';
import { ScrollService } from './ScrollService';
import { SearchStore } from './SearchStore';

interface StoreData {
menu: {
Expand Down Expand Up @@ -42,6 +43,7 @@ export class AppStore {
spec: SpecStore;
rawOptions: RedocRawOptions;
options: RedocNormalizedOptions;
search: SearchStore;

private scroll: ScrollService;

Expand All @@ -51,6 +53,8 @@ export class AppStore {
this.scroll = new ScrollService(this.options);
this.spec = new SpecStore(spec, specUrl, this.options);
this.menu = new MenuStore(this.spec, this.scroll);

this.search = new SearchStore(this.spec);
}

dispose() {
Expand Down
8 changes: 6 additions & 2 deletions src/services/MenuStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ export class MenuStore {
return this.flatItems[this.activeItemIdx] || undefined;
}

getItemById = (id: string) => {
return this.flatItems.find(item => item.id === id);
};

/**
* flattened items as they appear in the tree depth-first (top to bottom in the view)
*/
Expand Down Expand Up @@ -235,8 +239,8 @@ export class MenuStore {
* activate menu item and scroll to it
* @see MenuStore.activate
*/
@action
activateAndScroll(item: IMenuItem | undefined, updateHash: boolean, rewriteHistory?: boolean) {
@action.bound
activateAndScroll(item: IMenuItem | undefined, updateHash?: boolean, rewriteHistory?: boolean) {
this.activate(item, updateHash, rewriteHistory);
this.scrollToActive();
if (!item || !item.items.length) {
Expand Down
33 changes: 33 additions & 0 deletions src/services/SearchStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { SpecStore } from '../index';
import { GroupModel, OperationModel } from './models';
import worker from './SearchWorker.worker';

export class SearchStore {
searchWorker = new worker();

constructor(private spec: SpecStore) {
this.indexGroups(this.spec.operationGroups);
this.done();
}

indexGroups(groups: Array<GroupModel | OperationModel>) {
groups.forEach(group => {
if (group.type !== 'group') {
this.add(group.name, group.description || '', group.id);
}
this.indexGroups(group.items);
});
}

add(title: string, body: string, ref: string) {
this.searchWorker.add(title, body, ref);
}

done() {
this.searchWorker.done();
}

search(q: string) {
return this.searchWorker.search(q);
}
}
58 changes: 58 additions & 0 deletions src/services/SearchWorker.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as lunr from 'lunr';

/* just for better typings */
export default class Worker {
add = add;
done = done;
search = search;
}

export interface SearchDocument {
title: string;
description: string;
id: string;
}

const store: { [id: string]: SearchDocument } = {};

let resolveIndex: (v: lunr.Index) => void;
const index: Promise<lunr.Index> = new Promise(resolve => {
resolveIndex = resolve;
});

const builder = new lunr.Builder();
builder.field('title');
builder.field('description');
builder.ref('id');

builder.pipeline.add(lunr.trimmer, lunr.stopWordFilter, lunr.stemmer);

const expandTerm = term => '*' + lunr.stemmer(new lunr.Token(term, {})) + '*';

export function add(title: string, description: string, id: string) {
const item = { title, description, id };
builder.add(item);
store[id] = item;
}

export async function done() {
resolveIndex(builder.build());
}

export async function search(q: string): Promise<SearchDocument[]> {
if (q.trim().length === 0) {
return [];
}

return (await index)
.query(t => {
q
.trim()
.split(/\s+/)
.forEach(term => {
const exp = expandTerm(term);
t.term(exp, {});
});
})
.map(res => store[res.ref]);
}

0 comments on commit 6990cd2

Please sign in to comment.