feat: basis search

This commit is contained in:
Roman Hotsiy 2018-02-08 18:41:02 +02:00
parent 71e189ffc5
commit 6990cd2f24
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
12 changed files with 296 additions and 10 deletions

View File

@ -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);

View File

@ -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",
@ -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": {
@ -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",

View File

@ -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/';

View File

@ -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;
}
@ -30,7 +32,7 @@ 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}>
@ -38,6 +40,11 @@ export class Redoc extends React.Component<RedocProps> {
<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">

View File

@ -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>
);
}
}

View File

@ -9,6 +9,7 @@ import { MenuItemLabel, MenuItemLi, MenuItemTitle, OperationBadge } from './styl
interface MenuItemProps {
item: IMenuItem;
onActivate?: (item: IMenuItem) => void;
withoutChildren?: boolean;
}
@observer
@ -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' ? (
@ -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>
);
}

View File

@ -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: {
@ -42,6 +43,7 @@ export class AppStore {
spec: SpecStore;
rawOptions: RedocRawOptions;
options: RedocNormalizedOptions;
search: SearchStore;
private scroll: ScrollService;
@ -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() {

View File

@ -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)
*/
@ -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) {

View File

@ -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);
}
}

View File

@ -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]);
}

View File

@ -66,6 +66,18 @@ export default env => {
module: {
rules: [
{
test: /\.worker\.ts$/,
use: [
{
loader: 'workerize-loader',
options: {
inline: true,
fallback: false,
},
},
],
},
{
test: /\.tsx?$/,
use: [

View File

@ -99,6 +99,10 @@
version "4.14.101"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.101.tgz#512f6c9e1749890f4d024e98cb995a63f562d458"
"@types/lunr@^2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@types/lunr/-/lunr-2.1.5.tgz#afb90226a6d2eb472eb1732cef7493a02b0177fd"
"@types/minimatch@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.1.tgz#b683eb60be358304ef146f5775db4c0e3696a550"
@ -4598,6 +4602,10 @@ lru-cache@^4.0.1:
pseudomap "^1.0.2"
yallist "^2.1.2"
lunr@^2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.1.5.tgz#826601ccaeac29148e224154b34760faf4d81b70"
macaddress@^0.2.8:
version "0.2.8"
resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12"
@ -7757,6 +7765,12 @@ wordwrap@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
workerize-loader@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/workerize-loader/-/workerize-loader-1.0.1.tgz#703c7203d616693064309dc1a276bd105e6a1bca"
dependencies:
loader-utils "^1.1.0"
wrap-ansi@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"