mirror of
https://github.com/Redocly/redoc.git
synced 2024-11-10 19:06:34 +03:00
feat: basis search
This commit is contained in:
parent
71e189ffc5
commit
6990cd2f24
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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/';
|
||||
|
|
|
@ -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">
|
||||
|
|
148
src/components/SearchBox/SearchBox.tsx
Normal file
148
src/components/SearchBox/SearchBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
33
src/services/SearchStore.ts
Normal file
33
src/services/SearchStore.ts
Normal 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);
|
||||
}
|
||||
}
|
58
src/services/SearchWorker.worker.ts
Normal file
58
src/services/SearchWorker.worker.ts
Normal 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]);
|
||||
}
|
|
@ -66,6 +66,18 @@ export default env => {
|
|||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.worker\.ts$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'workerize-loader',
|
||||
options: {
|
||||
inline: true,
|
||||
fallback: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: [
|
||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user