From 6990cd2f24ca8b866d2a45a7bb70496465c4b7b4 Mon Sep 17 00:00:00 2001 From: Roman Hotsiy Date: Thu, 8 Feb 2018 18:41:02 +0200 Subject: [PATCH] feat: basis search --- demo/playground/hmr-playground.tsx | 2 +- package.json | 3 + src/components/Fields/Field.tsx | 4 +- src/components/Redoc/Redoc.tsx | 9 +- src/components/SearchBox/SearchBox.tsx | 148 +++++++++++++++++++++++++ src/components/SideMenu/MenuItem.tsx | 11 +- src/services/AppStore.ts | 4 + src/services/MenuStore.ts | 8 +- src/services/SearchStore.ts | 33 ++++++ src/services/SearchWorker.worker.ts | 58 ++++++++++ webpack.config.ts | 12 ++ yarn.lock | 14 +++ 12 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 src/components/SearchBox/SearchBox.tsx create mode 100644 src/services/SearchStore.ts create mode 100644 src/services/SearchWorker.worker.ts diff --git a/demo/playground/hmr-playground.tsx b/demo/playground/hmr-playground.tsx index 194d2373d8..c90cfa4ae0 100644 --- a/demo/playground/hmr-playground.tsx +++ b/demo/playground/hmr-playground.tsx @@ -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); diff --git a/package.json b/package.json index d955719b1d..4f6a9dca4a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Fields/Field.tsx b/src/components/Fields/Field.tsx index 670e63bbf8..102f98d8af 100644 --- a/src/components/Fields/Field.tsx +++ b/src/components/Fields/Field.tsx @@ -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/'; diff --git a/src/components/Redoc/Redoc.tsx b/src/components/Redoc/Redoc.tsx index 272d69a9df..bfdc988a32 100644 --- a/src/components/Redoc/Redoc.tsx +++ b/src/components/Redoc/Redoc.tsx @@ -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 { } render() { - const { store: { spec, menu, options } } = this.props; + const { store: { spec, menu, options, search } } = this.props; const store = this.props.store; return ( @@ -38,6 +40,11 @@ export class Redoc extends React.Component { + diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx new file mode 100644 index 0000000000..fb8d2de043 --- /dev/null +++ b/src/components/SearchBox/SearchBox.tsx @@ -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) => ( + + + +))` + 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 { + constructor(props) { + super(props); + this.state = { + results: [], + term: '', + }; + } + + search = (event: React.ChangeEvent) => { + 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 ( +
+ + + {items.length > 0 && ( + + {items.map(item => ( + + ))} + + )} +
+ ); + } +} diff --git a/src/components/SideMenu/MenuItem.tsx b/src/components/SideMenu/MenuItem.tsx index 565ee65a5b..bc3ba51378 100644 --- a/src/components/SideMenu/MenuItem.tsx +++ b/src/components/SideMenu/MenuItem.tsx @@ -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 { }; render() { - const { item } = this.props; + const { item, withoutChildren } = this.props; return ( {item.type === 'operation' ? ( @@ -34,9 +35,11 @@ export class MenuItem extends React.Component { null} )} - {item.items.length > 0 && ( - - )} + {!withoutChildren && + item.items && + item.items.length > 0 && ( + + )} ); } diff --git a/src/services/AppStore.ts b/src/services/AppStore.ts index 2aa08654c4..50cb59911e 100644 --- a/src/services/AppStore.ts +++ b/src/services/AppStore.ts @@ -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() { diff --git a/src/services/MenuStore.ts b/src/services/MenuStore.ts index 401c3e64c1..1f1ce04a12 100644 --- a/src/services/MenuStore.ts +++ b/src/services/MenuStore.ts @@ -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) { diff --git a/src/services/SearchStore.ts b/src/services/SearchStore.ts new file mode 100644 index 0000000000..4fc4f59658 --- /dev/null +++ b/src/services/SearchStore.ts @@ -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) { + 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); + } +} diff --git a/src/services/SearchWorker.worker.ts b/src/services/SearchWorker.worker.ts new file mode 100644 index 0000000000..13b4d3c8aa --- /dev/null +++ b/src/services/SearchWorker.worker.ts @@ -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 = 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 { + 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]); +} diff --git a/webpack.config.ts b/webpack.config.ts index c1924f2188..16d976adfc 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -66,6 +66,18 @@ export default env => { module: { rules: [ + { + test: /\.worker\.ts$/, + use: [ + { + loader: 'workerize-loader', + options: { + inline: true, + fallback: false, + }, + }, + ], + }, { test: /\.tsx?$/, use: [ diff --git a/yarn.lock b/yarn.lock index 4c76df357f..c6ce863499 100644 --- a/yarn.lock +++ b/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"